/* Copyright 2005-2006 Tim Fennell
*
* 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 net.sourceforge.stripes.util;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import net.sourceforge.stripes.action.ActionBean;
import net.sourceforge.stripes.action.DefaultHandler;
import net.sourceforge.stripes.config.Configuration;
import net.sourceforge.stripes.controller.StripesFilter;
import net.sourceforge.stripes.controller.UrlBinding;
import net.sourceforge.stripes.controller.UrlBindingFactory;
import net.sourceforge.stripes.controller.UrlBindingParameter;
import net.sourceforge.stripes.exception.StripesRuntimeException;
import net.sourceforge.stripes.exception.UrlBindingConflictException;
import net.sourceforge.stripes.format.Formatter;
import net.sourceforge.stripes.format.FormatterFactory;
import net.sourceforge.stripes.validation.ValidationMetadata;
import net.sourceforge.stripes.validation.ValidationMetadataProvider;
/**
* <p>Simple class that encapsulates the process of building up a URL from a path fragment
* and a zero or more parameters. Parameters can be single valued, array valued or
* collection valued. In the case of arrays and collections, each value in the array or
* collection will be added as a separate URL parameter (with the same name). The assembled
* URL can then be retrieved by calling toString().</p>
*
* <p>While not immediately obvious, it is possible to add a single parameter with multiple
* values by invoking the addParameter() method that uses varargs, and supplying a Collection as
* the single parameter value to the method.</p>
*
* @author Tim Fennell
* @since Stripes 1.1.2
*/
public class UrlBuilder {
/**
* Holds the name and value of a parameter to be appended to the URL.
*/
private static class Parameter {
String name;
Object value;
Parameter(String name, Object value) {
this.name = name;
this.value = value;
}
public boolean isEvent() {
return UrlBindingParameter.PARAMETER_NAME_EVENT.equals(name);
}
@Override
public String toString() {
return "" + this.name + "=" + this.value;
}
}
private String baseUrl;
private String anchor;
private Locale locale;
private String parameterSeparator;
private Parameter event;
private List<Parameter> parameters = new ArrayList<Parameter>();
private String url;
/**
* Constructs a UrlBuilder with the path to a resource. Parameters can be added
* later using addParameter(). If the link is to be used in a page then the ampersand
* character usually used to separate parameters will be escaped using the XML entity
* for ampersand.
*
* @param url the path part of the URL
* @param isForPage true if the URL is to be embedded in a page (e.g. in an anchor of img
* tag), false if for some other purpose.
* @deprecated As of Stripes 1.5, this constructor has been replaced by
* {@link #UrlBuilder(Locale, String, boolean)}.
*/
@Deprecated
public UrlBuilder(String url, boolean isForPage) {
this(Locale.getDefault(), url, isForPage);
}
/**
* Constructs a UrlBuilder with the path to a resource. Parameters can be added
* later using addParameter(). If the link is to be used in a page then the ampersand
* character usually used to separate parameters will be escaped using the XML entity
* for ampersand.
*
* @param locale the locale to use when formatting parameters with a {@link Formatter}
* @param url the path part of the URL
* @param isForPage true if the URL is to be embedded in a page (e.g. in an anchor of img
* tag), false if for some other purpose.
*/
public UrlBuilder(Locale locale, String url, boolean isForPage) {
this(locale, isForPage);
if (url != null) {
// Check to see if there is an embedded anchor, and strip it out for later
int index = url.indexOf('#');
if (index != -1) {
if (index < url.length() - 1) {
this.anchor = url.substring(index + 1);
}
url = url.substring(0, index);
}
this.baseUrl = url;
}
}
/**
* Constructs a UrlBuilder that references an {@link ActionBean}. Parameters can be added later
* using addParameter(). If the link is to be used in a page then the ampersand character
* usually used to separate parameters will be escaped using the XML entity for ampersand.
*
* @param locale the locale to use when formatting parameters with a {@link Formatter}
* @param beanType {@link ActionBean} class for which the URL will be built
* @param isForPage true if the URL is to be embedded in a page (e.g. in an anchor of img tag),
* false if for some other purpose.
*/
public UrlBuilder(Locale locale, Class<? extends ActionBean> beanType, boolean isForPage) {
this(locale, isForPage);
Configuration configuration = StripesFilter.getConfiguration();
if (configuration != null) {
this.baseUrl = configuration.getActionResolver().getUrlBinding(beanType);
}
else {
throw new StripesRuntimeException("Unable to lookup URL binding for ActionBean class "
+ "because there is no Configuration object available.");
}
}
/**
* Sets the locale and sets the parameter separator based on the value of <code>isForPage</code>.
*
* @param locale the locale to use when formatting parameters with a {@link Formatter}
* @param isForPage true if the URL is to be embedded in a page (e.g. in an anchor of img tag),
* false if for some other purpose.
*/
protected UrlBuilder(Locale locale, boolean isForPage) {
this.locale = locale;
if (isForPage) {
this.parameterSeparator = "&";
}
else {
this.parameterSeparator = "&";
}
}
/**
* Get the name of the event to be executed by this URL.
*/
public String getEvent() {
return (String) (event == null ? null : event.value);
}
/**
* Set the name of the event to be executed by this URL. A default event name can be defined for
* an {@link ActionBean} using either {@link DefaultHandler} or by assigning a default value to
* the $event parameter in a clean URL. If {@code event} is null then the default event name
* will be ignored and no event parameter will be added to the URL.
*
* @param event the event name
*/
public UrlBuilder setEvent(String event) {
this.event = new Parameter(UrlBindingParameter.PARAMETER_NAME_EVENT, event);
return this;
}
/**
* Returns the string that will be used to separate parameters in the query string.
* Will usually be either '&' for query strings that will be embedded in HTML
* pages and '&' otherwise.
*/
public String getParameterSeparator() { return parameterSeparator; }
/**
* Sets the string that will be used to separate parameters. By default the values is a
* single ampersand character. If the URL is to be embedded in a page the value should be
* set to the XML ampersand entity.
*/
public void setParameterSeparator(String parameterSeparator) {
this.parameterSeparator = parameterSeparator;
}
/**
* <p>Appends one or more values of a parameter to the URL. Checks to see if each value is
* null, and if so generates a parameter with no value. URL Encodes the parameter values
* to make sure that it is safe to insert into the URL.</p>
*
* <p>If any parameter value passed is a Collection or an Array then this method is called
* recursively with the contents of the collection or array. As a result you can pass
* arbitrarily nested arrays and collections to this method and it will recurse through them
* adding all scalar values as parameters to the URL.</p.
*
* @param name the name of the request parameter being added
* @param values one or more values for the parameter supplied
*/
public UrlBuilder addParameter(String name, Object... values) {
// If values is null or empty, then simply sub in a single empty string
if (values == null || values.length == 0) {
values = Literal.array("");
}
for (Object v : values) {
// Special case: recurse for nested collections and arrays!
if (v instanceof Collection) {
addParameter(name, ((Collection<?>) v).toArray());
}
else if (v != null && v.getClass().isArray()) {
addParameter(name, CollectionUtil.asObjectArray(v));
}
else {
parameters.add(new Parameter(name, v));
url = null;
}
}
return this;
}
/**
* Appends one or more parameters to the URL. Various assumptions are made about the Map
* parameter. Firstly, that the keys are all either Strings, or objects that can be safely
* toString()'d to yield parameter names. Secondly that the values either toString() to form
* a single parameter value, or are arrays or collections that contain toString()'able
* objects.
*
* @param parameters a non-null Map as described above
*/
public UrlBuilder addParameters(Map<? extends Object,? extends Object> parameters) {
for (Map.Entry<? extends Object,? extends Object> parameter : parameters.entrySet()) {
String name = parameter.getKey().toString();
Object valueOrValues = parameter.getValue();
if (valueOrValues == null) {
addParameter(name, (Object) null);
}
else if (valueOrValues.getClass().isArray()) {
addParameter(name, CollectionUtil.asObjectArray(valueOrValues));
}
else if (valueOrValues instanceof Collection) {
addParameter(name, (Collection<?>) valueOrValues);
}
else {
addParameter(name, valueOrValues);
}
}
return this;
}
/**
* Gets the anchor, if any, that will be appended to the URL. E.g. if this method
* returns 'input' then the URL will be terminated with '#input' in order to instruct
* the browser to navigate to the HTML anchor called 'input' when accessing the URL.
*
* @return the anchor (if any) without the leading pound sign, or null
*/
public String getAnchor() { return anchor; }
/**
* Sets the anchor, if any, that will be appended to the URL. E.g. if supplied with
* 'input' then the URL will be terminated with '#input' in order to instruct
* the browser to navigate to the HTML anchor called 'input' when accessing the URL.
*
* @param anchor the anchor with or without the leading pound sign, or null to disable
*/
public UrlBuilder setAnchor(String anchor) {
if (anchor != null && anchor.startsWith("#") && anchor.length() > 1) {
this.anchor = anchor.substring(1);
}
else {
this.anchor = anchor;
}
return this;
}
/**
* Returns the URL composed thus far as a String. All paramter values will have been
* URL encoded and appended to the URL before returning it.
*/
@Override
public String toString() {
if (url == null) {
url = build();
}
if (this.anchor != null && this.anchor.length() > 0) {
return url + "#" + this.anchor;
}
else {
return url;
}
}
/**
* Attempts to format an object using an appropriate {@link Formatter}. If
* no formatter is available for the object, then this method will call
* <code>toString()</code> on the object. A null <code>value</code> will
* be formatted as an empty string.
*
* @param value
* the object to be formatted
* @return the formatted value
*/
@SuppressWarnings("unchecked")
protected String format(Object value) {
if (value == null) {
return "";
}
else {
Formatter formatter = getFormatter(value);
if (formatter == null)
return value.toString();
else
return formatter.format(value);
}
}
/**
* Tries to get a formatter for the given value using the {@link FormatterFactory}. Returns
* null if there is no {@link Configuration} or {@link FormatterFactory} available (e.g. in a
* test environment) or if there is no {@link Formatter} configured for the value's type.
*
* @param value the object to be formatted
* @return a formatter, if one can be found; null otherwise
*/
@SuppressWarnings("unchecked")
protected Formatter getFormatter(Object value) {
Configuration configuration = StripesFilter.getConfiguration();
if (configuration == null)
return null;
FormatterFactory factory = configuration.getFormatterFactory();
if (factory == null)
return null;
return factory.getFormatter(value.getClass(), locale, null, null);
}
/**
* Get a map of property names to {@link ValidationMetadata} for the {@link ActionBean} class
* bound to the URL being built. If the URL does not point to an ActionBean class or no
* validation metadata exists for the ActionBean class then an empty map will be returned.
*
* @return a map of ActionBean property names to their validation metadata
* @see ValidationMetadataProvider#getValidationMetadata(Class)
*/
protected Map<String, ValidationMetadata> getValidationMetadata() {
Map<String, ValidationMetadata> validations = null;
Configuration configuration = StripesFilter.getConfiguration();
if (configuration != null) {
Class<? extends ActionBean> beanType = null;
try {
beanType = configuration.getActionResolver().getActionBeanType(this.baseUrl);
}
catch (UrlBindingConflictException e) {
// This can be safely ignored
}
if (beanType != null) {
validations = configuration.getValidationMetadataProvider().getValidationMetadata(
beanType);
}
}
if (validations == null)
validations = Collections.emptyMap();
return validations;
}
/**
* Build and return the URL
*/
protected String build() {
// special handling for event parameter
List<Parameter> parameters = new ArrayList<Parameter>(this.parameters.size() + 1);
if (this.event != null) {
parameters.add(this.event);
}
parameters.addAll(this.parameters);
// lookup validation info for the bean class to find encrypted properties
Map<String, ValidationMetadata> validations = getValidationMetadata();
StringBuilder buffer = new StringBuilder(256);
buffer.append(getBaseURL(this.baseUrl, parameters));
boolean seenQuestionMark = buffer.indexOf("?") != -1;
for (Parameter param : parameters) {
// special handling for event parameter
if (param == this.event) {
if (param.value == null)
continue;
else
param = new Parameter((String) this.event.value, "");
}
// Figure out whether we already have params or not
if (!seenQuestionMark) {
buffer.append('?');
seenQuestionMark = true;
}
else {
buffer.append(getParameterSeparator());
}
buffer.append(StringUtil.urlEncode(param.name)).append('=');
if (param.value != null) {
ValidationMetadata validation = validations.get(param.name);
String formatted = format(param.value);
if (validation != null && validation.encrypted())
formatted = CryptoUtil.encrypt(formatted);
buffer.append(StringUtil.urlEncode(formatted));
}
}
return buffer.toString();
}
/**
* Get the base URL (without a query string). If a {@link UrlBinding} exists for the URL or
* {@link ActionBean} type that was passed into the constructor, then this method will return
* the base URL after appending any URI parameters that have been added with a call to
* {@link #addParameter(String, Object[])} or {@link #addParameters(Map)}. Otherwise, it
* returns the original base URL.
*
* @param baseUrl The base URL to start with. In many cases, this value will be returned
* unchanged.
* @param parameters The query parameters. Any parameters that should not be appended to the
* query string by {@link #build()} (e.g., because they are embedded in the URL)
* should be removed from the collection before this method returns.
* @return the base URL, without a query string
* @see #UrlBuilder(Locale, Class, boolean)
* @see #UrlBuilder(Locale, String, boolean)
*/
protected String getBaseURL(String baseUrl, Collection<Parameter> parameters) {
UrlBinding binding = null;
try {
binding = UrlBindingFactory.getInstance().getBindingPrototype(baseUrl);
}
catch (UrlBindingConflictException e) {
// This can be safely ignored
}
if (binding == null || binding.getParameters().size() == 0) {
return baseUrl;
}
// if we have a parameterized binding then we need to trim it down to the path
if (baseUrl.equals(binding.toString())) {
baseUrl = binding.getPath();
}
// if any extra path info is present then do not add URI parameters
if (binding.getPath().length() < baseUrl.length()) {
return baseUrl;
}
// lookup validation info for the bean class to find encrypted properties
Map<String, ValidationMetadata> validations = getValidationMetadata();
// map the declared URI parameter names to values
Map<String, Parameter> map = new HashMap<String, Parameter>();
for (Parameter p : parameters) {
if (!map.containsKey(p.name))
map.put(p.name, p);
}
StringBuilder buf = new StringBuilder(256);
buf.append(baseUrl);
String nextLiteral = null;
for (Object component : binding.getComponents()) {
if (component instanceof String) {
nextLiteral = (String) component;
}
else if (component instanceof UrlBindingParameter) {
boolean ok = false;
// get the value for the parameter, falling back to default value if present
UrlBindingParameter parameter = (UrlBindingParameter) component;
Parameter assigned = map.get(parameter.getName());
Object value;
if (assigned != null && (assigned.value != null || assigned.isEvent()))
value = assigned.value;
else
value = parameter.getDefaultValue();
if (value != null) {
// format (and maybe encrypt) the value as a string
String formatted = format(value);
ValidationMetadata validation = validations.get(parameter.getName());
if (validation != null && validation.encrypted())
formatted = CryptoUtil.encrypt(formatted);
// if after formatting we still have a value then embed it in the URI
if (formatted != null && formatted.length() > 0) {
if (nextLiteral != null) {
buf.append(nextLiteral);
}
buf.append(StringUtil.urlEncode(formatted));
parameters.remove(assigned);
ok = true;
}
}
else if (assigned != null && assigned.isEvent()) {
// remove event parameter even if value is null
parameters.remove(assigned);
}
nextLiteral = null;
if (!ok)
break;
}
}
// always append trailing literal if one is present
if (nextLiteral != null) {
buf.append(nextLiteral);
}
else if (binding.getSuffix() != null) {
buf.append(binding.getSuffix());
}
// Test the URL to make sure it won't throw an exception when Stripes tries to dispatch it
String url = buf.toString();
try {
StripesFilter.getConfiguration().getActionResolver().getActionBeanType(url);
}
catch (UrlBindingConflictException e) {
if (binding != null) {
UrlBindingConflictException tmp = new UrlBindingConflictException(binding
.getBeanType(), e.getPath(), e.getMatches());
tmp.setStackTrace(e.getStackTrace());
e = tmp;
}
throw e;
}
return url;
}
}