/* * Copyright 2012 Cedric Hauber * * 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 org.jboss.errai.mvp.client.places; import com.google.gwt.http.client.URL; import javax.inject.Inject; import java.util.ArrayList; import java.util.List; import java.util.Set; /** * Formats tokens from {@code String} values to {@link PlaceRequest} and {@link PlaceRequest} * hierarchies and vice-versa. The default implementation * uses: * <ul> * <li>{@code '/'} to separate {@link PlaceRequest}s in a hierarchy;</li> * <li>{@code ';'} to separate parameters in a {@link PlaceRequest};</li> * <li>{@code '='} to separate the parameter name from its value;</li> * <li>{@code '\'} to escape separators inside parameters names and values in a * {@link PlaceRequest}.</li> * </ul> * These symbols cannot be used in a name token. If one of the separating symbol is encountered in a * parameter or a value it is escaped using the {@code '\'} character by replacing {@code '/'} with * {@code '\0'}, {@code ';'} with {@code '\1'}, {@code '='} with {@code '\2'} and {@code '\'} with * {@code '\3'}. * <p /> * Before decoding a {@link String} URL fragment into a {@link PlaceRequest} or a * {@link PlaceRequest} hierarchy, {@link org.jboss.errai.mvp.client.places.ParameterTokenFormatter} will first pass the * {@link String} through {@link com.google.gwt.http.client.URL#decodeQueryString(String)} so that if the URL was URL-encoded * by some user agent, like a mail user agent, it is still parsed correctly. * <p /> * For example, {@link org.jboss.errai.mvp.client.places.ParameterTokenFormatter} would parse any of the following: * * <pre> * nameToken1%3Bparam1.1%3Dvalue1.1%3Bparam1.2%3Dvalue1.2%2FnameToken2%2FnameToken3%3Bparam3.1%3Dvalue%03%11 * nameToken1;param1.1=value1.1;param1.2=value1.2/nameToken2/nameToken3;param3.1=value\03\21 * </pre> * * Into the following hierarchy of {@link PlaceRequest}: * * <pre> * { * { "nameToken1", { {"param1.1", "value1.1"}, {"parame1.2","value1.2"} }, * "nameToken2", {}, * "nameToken3", { {"param3.1", "value/3=1"} } } * } * </pre> * * If you want to use different symbols as separator, use the * {@link #ParameterTokenFormatter(String, String, String, String)} constructor. * * @author Philippe Beaudoin * @author Yannis Gonianakis * @author Daniel Colchete */ public class ParameterTokenFormatter implements TokenFormatter { protected static final String DEFAULT_HIERARCHY_SEPARATOR = "/"; protected static final String DEFAULT_PARAM_SEPARATOR = ";"; protected static final String DEFAULT_VALUE_SEPARATOR = "="; // Escaped versions of the above. protected static final char ESCAPE_CHARACTER = '\\'; protected static final String ESCAPED_HIERARCHY_SEPARATOR = "\\0"; protected static final String ESCAPED_PARAM_SEPARATOR = "\\1"; protected static final String ESCAPED_VALUE_SEPARATOR = "\\2"; protected static final String ESCAPED_ESCAPE_CHAR = "\\3"; private final String hierarchySeparator; private final String paramSeparator; private final String valueSeparator; /** * Builds a {@link org.jboss.errai.mvp.client.places.ParameterTokenFormatter} using the default separators and escape character. */ @Inject public ParameterTokenFormatter() { this(DEFAULT_HIERARCHY_SEPARATOR, DEFAULT_PARAM_SEPARATOR, DEFAULT_VALUE_SEPARATOR); } /** * This constructor makes it possible to use custom separators in your token formatter. The * separators must be 1-letter strings, they must all be different from one another, and they * must be encoded when ran through {@link com.google.gwt.http.client.URL#encodeQueryString(String)}). * * @param hierarchySeparator The symbol used to separate {@link PlaceRequest} in a hierarchy. * Must be a 1-character string and can't be {@code %}. * @param paramSeparator The symbol used to separate parameters in a {@link PlaceRequest}. Must * be a 1-character string and can't be {@code %}. * @param valueSeparator The symbol used to separate the parameter name from its value. Must be * a 1-character string and can't be {@code %}. */ public ParameterTokenFormatter(String hierarchySeparator, String paramSeparator, String valueSeparator) { assert hierarchySeparator.length() == 1; assert paramSeparator.length() == 1; assert valueSeparator.length() == 1; assert !hierarchySeparator.equals(paramSeparator); assert !hierarchySeparator.equals(valueSeparator); assert !paramSeparator.equals(valueSeparator); assert !valueSeparator.equals(URL.encodeQueryString(valueSeparator)); assert !hierarchySeparator.equals(URL.encodeQueryString(hierarchySeparator)); assert !paramSeparator.equals(URL.encodeQueryString(paramSeparator)); assert !hierarchySeparator.equals("%"); assert !paramSeparator.equals("%"); assert !valueSeparator.equals("%"); this.hierarchySeparator = hierarchySeparator; this.paramSeparator = paramSeparator; this.valueSeparator = valueSeparator; } @Override public String toHistoryToken(List<PlaceRequest> placeRequestHierarchy) throws TokenFormatException { StringBuilder out = new StringBuilder(); for (int i = 0; i < placeRequestHierarchy.size(); ++i) { if (i != 0) { out.append(hierarchySeparator); } out.append(placeTokenToUnescapedString(placeRequestHierarchy.get(i))); } return out.toString(); } @Override public PlaceRequest toPlaceRequest(String placeToken) throws TokenFormatException { return unescapedStringToPlaceRequest(URL.decodeQueryString(placeToken)); } /** * Converts an unescaped string to a place request. To unescape the hash fragment you must run it * through {@link com.google.gwt.http.client.URL#decodeQueryString(String)}. * @param unescapedPlaceToken The unescaped string to convert to a place request. * @return The place request. * @throws TokenFormatException if there is an error converting. */ private PlaceRequest unescapedStringToPlaceRequest(String unescapedPlaceToken) throws TokenFormatException { PlaceRequest req = null; int split = unescapedPlaceToken.indexOf(paramSeparator); if (split == 0) { throw new TokenFormatException("Place history token is missing."); } else if (split == -1) { // No parameters. req = new PlaceRequest(customUnescape(unescapedPlaceToken)); } else if (split >= 0) { req = new PlaceRequest(customUnescape(unescapedPlaceToken.substring(0, split))); String paramsChunk = unescapedPlaceToken.substring(split + 1); String[] paramTokens = paramsChunk.split(paramSeparator); for (String paramToken : paramTokens) { if (paramToken.isEmpty()) { throw new TokenFormatException("Bad parameter: Successive parameters require a single '" + paramSeparator + "' between them."); } String[] param = paramToken.split(valueSeparator); if (param.length == 1) { // If there is only one parameter, then we need an '=' at the last position. if (paramToken.charAt(paramToken.length() - 1) != valueSeparator.charAt(0)) { throw new TokenFormatException("Bad parameter: Need exactly one key and one value."); } } else if (param.length == 2) { // If there are two parameters, then there must not be a '=' at the last position. if (paramToken.charAt(paramToken.length() - 1) == valueSeparator.charAt(0)) { throw new TokenFormatException("Bad parameter: Need exactly one key and one value."); } } else { throw new TokenFormatException("Bad parameter: Need exactly one key and one value."); } String key = customUnescape(param[0]); String value = param.length == 2 ? customUnescape(param[1]) : ""; req = req.with(key, value); } } return req; } @Override public List<PlaceRequest> toPlaceRequestHierarchy(String historyToken) throws TokenFormatException { String unescapedHistoryToken = URL.decodeQueryString(historyToken); int split = unescapedHistoryToken.indexOf(hierarchySeparator); List<PlaceRequest> result = new ArrayList<PlaceRequest>(); if (split == -1) { // History token consists of a single place token. result.add(unescapedStringToPlaceRequest(unescapedHistoryToken)); } else { String[] unescapedPlaceTokens = unescapedHistoryToken.split(hierarchySeparator); if (unescapedPlaceTokens.length == 0) { throw new TokenFormatException("Bad parameter: nothing in the history token."); } for (String unescapedPlaceToken : unescapedPlaceTokens) { if (unescapedPlaceToken.isEmpty()) { throw new TokenFormatException("Bad parameter: Successive place tokens require a single '" + hierarchySeparator + "' between them."); } result.add(unescapedStringToPlaceRequest(unescapedPlaceToken)); } } return result; } @Override public String toPlaceToken(PlaceRequest placeRequest) throws TokenFormatException { return placeTokenToUnescapedString(placeRequest); } /** * Converts a place token to an unescaped string. If the name token or the parameters contain any * of the separator symbols, they will be escaped with our custom escaping mechanism. * @param placeRequest The place request to convert. * @return The unescaped string for the place token corresponding to that place request. * @throws TokenFormatException if there is an error converting. */ private String placeTokenToUnescapedString(PlaceRequest placeRequest) throws TokenFormatException { StringBuilder out = new StringBuilder(); out.append(customEscape(placeRequest.getNameToken())); Set<String> params = placeRequest.getParameterNames(); if (params != null) { for (String name : params) { out.append(paramSeparator).append(customEscape(name)).append(valueSeparator).append( customEscape(placeRequest.getParameter(name, null))); } } return out.toString(); } /** * Use our custom escaping mechanism to escape the provided string. This should be used on the * name token, and the parameter keys and values, before they are attached with the various * separators. The string will also be passed through {@link com.google.gwt.http.client.URL#encodeQueryString}. * Visible for testing. * @param string The string to escape. * @return The escaped string. */ String customEscape(String string) { StringBuffer sbuf = new StringBuffer(); int len = string.length(); char hierarchyChar = hierarchySeparator.charAt(0); char paramChar = paramSeparator.charAt(0); char valueChar = valueSeparator.charAt(0); for (int i = 0; i < len; i++) { char ch = string.charAt(i); if (ch == ESCAPE_CHARACTER) { sbuf.append(ESCAPED_ESCAPE_CHAR); } else if (ch == hierarchyChar) { sbuf.append(ESCAPED_HIERARCHY_SEPARATOR); } else if (ch == paramChar) { sbuf.append(ESCAPED_PARAM_SEPARATOR); } else if (ch == valueChar) { sbuf.append(ESCAPED_VALUE_SEPARATOR); } else { sbuf.append(ch); } } return URL.encodeQueryString(sbuf.toString()); } /** * Use our custom escaping mechanism to unescape the provided string. This should be used on the * name token, and the parameter keys and values, after they have been split using the various * separators. The input string is expected to already be sent through * {@link com.google.gwt.http.client.URL#decodeQueryString}. * @param string The string to unescape, must have passed through {@link com.google.gwt.http.client.URL#decodeQueryString}. * @return The unescaped string. * @throws TokenFormatException if there is an error converting. */ private String customUnescape(String string) throws TokenFormatException { StringBuffer sbuf = new StringBuffer(); int len = string.length(); char hierarchyNum = ESCAPED_HIERARCHY_SEPARATOR.charAt(1); char paramNum = ESCAPED_PARAM_SEPARATOR.charAt(1); char valueNum = ESCAPED_VALUE_SEPARATOR.charAt(1); char escapeNum = ESCAPED_ESCAPE_CHAR.charAt(1); int i = 0; while (i < len - 1) { char ch = string.charAt(i); if (ch == ESCAPE_CHARACTER) { i++; char ch2 = string.charAt(i); if (ch2 == hierarchyNum) { sbuf.append(hierarchySeparator); } else if (ch2 == paramNum) { sbuf.append(paramSeparator); } else if (ch2 == valueNum) { sbuf.append(valueSeparator); } else if (ch2 == escapeNum) { sbuf.append(ESCAPE_CHARACTER); } } else { sbuf.append(ch); } i++; } if (i == len - 1) { char ch = string.charAt(i); if (ch == ESCAPE_CHARACTER) { throw new TokenFormatException("Last character of string being unescaped cannot be '" + ESCAPE_CHARACTER + "'."); } sbuf.append(ch); } return sbuf.toString(); } }