/**
* Copyright 2014 55 Minutes (http://www.55minutes.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fiftyfive.wicket.mapper;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.component.IRequestablePage;
import org.apache.wicket.request.mapper.MountedMapper;
import org.apache.wicket.request.mapper.parameter.IPageParametersEncoder;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.apache.wicket.request.mapper.parameter.PageParametersEncoder;
import org.apache.wicket.util.ClassProvider;
import org.apache.wicket.util.string.StringValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* An improved version of Wicket's standard {@link MountedMapper} that additionally allows
* regular expressions inside placeholders. This feature is inspired by the pattern matching
* behavior of the JAX-RS {@code @Path} annotation.
* <pre class="example">
* mount(new PatternMountedMapper("people/${personId:\\d+}", PersonPage.class));</pre>
* This will map URLs like {@code people/12345} but yield a 404 not found for something like
* {@code people/abc} since {@code abc} doesn't match the {@code \d+} regular expression.
*
* @since 3.0
*/
public class PatternMountedMapper extends MountedMapper
{
private static final Logger LOGGER = LoggerFactory.getLogger(PatternMountedMapper.class);
private final int numSegments;
private final List<PatternPlaceholder> patternPlaceholders;
private boolean exact = false;
/**
* @see MountedMapper#MountedMapper(String, Class)
*/
public PatternMountedMapper(String mountPath, Class<? extends IRequestablePage> pageClass)
{
this(mountPath, pageClass, new PageParametersEncoder());
}
/**
* @see MountedMapper#MountedMapper(String, ClassProvider)
*/
public PatternMountedMapper(String mountPath,
ClassProvider<? extends IRequestablePage> pageClassProvider)
{
this(mountPath, pageClassProvider, new PageParametersEncoder());
}
/**
* @see MountedMapper#MountedMapper(String, Class, IPageParametersEncoder)
*/
public PatternMountedMapper(String mountPath,
Class<? extends IRequestablePage> pageClass,
IPageParametersEncoder pageParametersEncoder)
{
this(mountPath, ClassProvider.of(pageClass), pageParametersEncoder);
}
/**
* @see MountedMapper#MountedMapper(String, ClassProvider, IPageParametersEncoder)
*/
public PatternMountedMapper(String mountPath,
ClassProvider<? extends IRequestablePage> pageClassProvider,
IPageParametersEncoder pageParametersEncoder)
{
super(removePatternsFromPlaceholders(mountPath), pageClassProvider, pageParametersEncoder);
String[] segments = getMountSegments(mountPath);
this.numSegments = segments.length;
this.patternPlaceholders = new ArrayList<PatternPlaceholder>(1);
for(String seg: segments)
{
String placeholder = getPlaceholder(seg);
if(placeholder != null)
{
this.patternPlaceholders.add(new PatternPlaceholder(placeholder));
}
}
}
/**
* Set to {@code true}, to force this mapper to strictly match URLs by disallowing any extra
* path elements that come after the matched pattern.
* <pre class="example">
* PatternMountedMapper m = new PatternMountedMapper(MyPage.class, "page/${id:\\d+}");
* // These will always be matched: "page/1", "page/2", "page/30", etc.
* // By default, these will be matched as well: "page/1/whatever/foo/bar", "page/2/baz"
* m.setExact(true);
* // Now these will not be matched: "page/1/whatever/foo/bar", "page/2/baz"</pre>
*
* In other words, if {@code exact} is set to {@code false}, extra path elements after the
* specified pattern will be allowed. The default is {@code false}, to match the default
* behavior of Wicket's {@link MountedMapper}.
*
* @return {@code this} to allow chaining
*/
public PatternMountedMapper setExact(boolean exact)
{
this.exact = exact;
return this;
}
/**
* First delegate to the superclass to parse the request as normal, then additionally
* verify that all regular expressions specified in the placeholders match.
*/
@Override
protected UrlInfo parseRequest(Request request)
{
// Parse the request normally. If the standard impl can't parse it, we won't either.
UrlInfo info = super.parseRequest(request);
if(null == info || null == info.getPageParameters())
{
return info;
}
// If exact matching, reject URLs that have more than expected number of segments
if(exact)
{
int requestNumSegments = request.getUrl().getSegments().size();
if(requestNumSegments > this.numSegments)
{
return null;
}
}
// Loop through each placeholder and verify that the regex of the placeholder matches
// the value that was provided in the request url. If any of the values don't match,
// immediately return null signifying that the url is not matched by this mapper.
PageParameters params = info.getPageParameters();
for(PatternPlaceholder pp : getPatternPlaceholders())
{
List<StringValue> values = params.getValues(pp.getName());
if(null == values || values.size() == 0)
{
values = Arrays.asList(StringValue.valueOf(""));
}
for(StringValue val : values)
{
if(!pp.matches(val.toString()))
{
if(LOGGER.isDebugEnabled())
{
LOGGER.debug(String.format(
"Parameter \"%s\" did not match pattern placeholder %s", val, pp));
}
return null;
}
}
}
return info;
}
/**
* The list of placeholders (in other words, the <code>${name:regex}</code> components of the
* mount path).
*/
protected List<PatternPlaceholder> getPatternPlaceholders()
{
return this.patternPlaceholders;
}
/**
* Remove the regular expression portion of all placeholders from the given path so that
* the standard {@link MountedMapper} isn't confused by them. This allows us to reuse all
* the existing code of the superclass. This method must be static because we need to call it
* before invoking the superclass constructor.
* <pre class="example">
* removePatternsFromPlaceholders("people/${personId:\\d+}");
* // "people/${personId}"</pre>
*/
protected static String removePatternsFromPlaceholders(String path)
{
if(null == path)
{
return null;
}
StringBuilder result = new StringBuilder();
String [] segments = path.split("/");
for(int i=0; i<segments.length; i++)
{
String seg = segments[i];
result.append(seg.replaceAll("^(\\$\\{[^:]+):.+\\}$", "$1}"));
if(i < segments.length-1 || path.endsWith("/"))
{
result.append("/");
}
}
return result.toString();
}
/**
* Represents a placeholder that optionally contains a regular expression.
*/
protected static class PatternPlaceholder
{
private final String placeholder;
private final String pattern;
private final String name;
public PatternPlaceholder(String placeholder)
{
this.placeholder = placeholder;
int colon = placeholder.indexOf(":");
if(colon > 0 && colon < placeholder.length() - 2)
{
this.name = placeholder.substring(0, colon);
this.pattern = placeholder.substring(colon + 1);
}
else
{
this.name = placeholder;
this.pattern = null;
}
}
/**
* Return {@code true} if this placeholder has a regex pattern and that pattern matches
* the specified value. If this placeholder doesn't have a regex, always return
* {@code true} always.
*/
public boolean matches(CharSequence value)
{
return null == this.pattern || Pattern.matches(this.pattern, value);
}
/**
* The name of this placeholder with the {@code :regex} portion removed.
*/
public String getName()
{
return this.name;
}
/**
* For debugging.
*/
public String toString()
{
return "${" + this.placeholder + "}";
}
}
}