ResourcePath.java
/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2013-2015 ForgeRock AS.
*/
package org.forgerock.json.resource;
import static java.util.Arrays.asList;
import static org.forgerock.http.util.Paths.*;
import java.util.Collection;
import java.util.Iterator;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.regex.Pattern;
/**
* A relative path, or URL, to a resource. A resource path is an ordered list of
* zero or more path elements in big-endian order. The string representation of
* a resource path conforms to the URL path encoding rules defined in <a
* href="http://tools.ietf.org/html/rfc3986#section-3.3">RFC 3986 section
* 3.3</a>:
*
* <pre>
* {@code
* path = path-abempty ; begins with "/" or is empty
* / ...
*
* path-abempty = *( "/" segment )
* segment = *pchar
* pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
*
* unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
* pct-encoded = "%" HEXDIG HEXDIG
* sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
* / "*" / "+" / "," / ";" / "="
*
* HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"
* ALPHA = %x41-5A / %x61-7A ; A-Z / a-z
* DIGIT = %x30-39 ; 0-9
* }
* </pre>
*
* The empty resource path having zero path elements may be obtained by calling
* {@link #empty()}. Resource paths are case insensitive and empty path elements
* are not allowed. In addition, resource paths will be automatically trimmed
* such that any leading or trailing slashes are removed. In other words, all
* resource paths will be considered to be "relative". At the moment the
* relative path elements "." and ".." are not supported.
* <p>
* New resource paths can be created from their string representation using
* {@link #resourcePath(String)}, or by deriving new resource paths from existing
* values, e.g. using {@link #parent()} or {@link #child(Object)}.
* <p>
* Example:
*
* <pre>
* ResourcePath base = ResourcePath.valueOf("commons/rest");
* ResourcePath child = base.child("hello world");
* child.toString(); // commons/rest/hello%20world
*
* ResourcePath user = base.child("users").child(123);
* user.toString(); // commons/rest/users/123
* </pre>
*/
public final class ResourcePath implements Comparable<ResourcePath>, Iterable<String> {
private static final ResourcePath EMPTY = new ResourcePath();
/**
* Returns the empty resource path whose string representation is the empty
* string and which has zero path elements.
*
* @return The empty resource path.
*/
public static ResourcePath empty() {
return EMPTY;
}
/**
* Creates a new resource path using the provided path template and
* unencoded path elements. This method first URL encodes each of the path
* elements and then substitutes them into the template using
* {@link String#format(String, Object...)}. Finally, the formatted string
* is parsed as a resource path using {@link #resourcePath(String)}.
* <p>
* This method may be useful in cases where the structure of a resource path
* is not known at compile time, for example, it may be obtained from a
* configuration file. Example usage:
*
* <pre>
* String template = "rest/users/%s"
* ResourcePath path = ResourcePath.format(template, "bjensen");
* </pre>
*
* @param template
* The resource path template.
* @param pathElements
* The path elements to be URL encoded and then substituted into
* the template.
* @return The formatted template parsed as a resource path.
* @throws IllegalArgumentException
* If the formatted template contains empty path elements.
* @see org.forgerock.http.util.Paths#urlEncode(Object)
*/
public static ResourcePath format(final String template, final Object... pathElements) {
final String[] encodedPathElements = new String[pathElements.length];
for (int i = 0; i < pathElements.length; i++) {
encodedPathElements[i] = urlEncode(pathElements[i]);
}
return resourcePath(String.format(template, (Object[]) encodedPathElements));
}
/**
* Compiled regular expression for splitting resource paths into path
* elements.
*/
private static final Pattern PATH_SPLITTER = Pattern.compile("/");
/**
* Parses the provided string representation of a resource path.
*
* @param path
* The URL-encoded resource path to be parsed.
* @return The provided string representation of a resource path.
* @throws IllegalArgumentException
* If the resource path contains empty path elements.
* @see #toString()
*/
public static ResourcePath resourcePath(final String path) {
return valueOf(path);
}
/**
* Parses the provided string representation of a resource path.
*
* @param path
* The URL-encoded resource path to be parsed.
* @return The provided string representation of a resource path.
* @throws IllegalArgumentException
* If the resource path contains empty path elements.
* @see #toString()
*/
public static ResourcePath valueOf(final String path) {
if (path.isEmpty()) {
// Fast-path.
return EMPTY;
}
// Split on path separators and trim leading slash or trailing slash.
final String[] elements = PATH_SPLITTER.split(path, -1);
final int sz = elements.length;
final int startIndex = elements[0].isEmpty() ? 1 : 0;
final int endIndex = sz > 1 && elements[sz - 1].isEmpty() ? sz - 1 : sz;
if (startIndex == endIndex) {
return EMPTY;
}
// Normalize the path elements checking for empty elements.
final StringBuilder trimmedPath = new StringBuilder(path.length());
final StringBuilder normalizedPath = new StringBuilder(path.length());
for (int i = startIndex; i < endIndex; i++) {
final String element = elements[i];
if (element.isEmpty()) {
throw new IllegalArgumentException("Resource path '" + path
+ "' contains empty path elements");
}
final String normalizedElement = normalizePathElement(element, true);
if (i != startIndex) {
trimmedPath.append('/');
normalizedPath.append('/');
}
trimmedPath.append(element);
normalizedPath.append(normalizedElement);
}
return new ResourcePath(trimmedPath.toString(), normalizedPath.toString(), endIndex - startIndex);
}
private static String normalizePathElement(final String element, final boolean needsDecoding) {
if (needsDecoding) {
return urlEncode(urlDecode(element).toLowerCase(Locale.ENGLISH));
} else {
return element.toLowerCase(Locale.ENGLISH);
}
}
private final String path; // uri encoded
private final String normalizedPath; // uri encoded
private final int size;
/**
* Creates a new empty resource path whose string representation is the
* empty string and which has zero path elements. This method is provided in
* order to comply with the Java Collections Framework recommendations.
* However, it is recommended that applications use {@link #empty()} in
* order to avoid unnecessary memory allocation.
*/
public ResourcePath() {
this.path = this.normalizedPath = "";
this.size = 0;
}
/**
* Creates a new resource path having the provided path elements.
*
* @param pathElements
* The unencoded path elements.
*/
public ResourcePath(final Collection<? extends Object> pathElements) {
int i = 0;
final StringBuilder pathBuilder = new StringBuilder();
final StringBuilder normalizedPathBuilder = new StringBuilder();
for (final Object element : pathElements) {
final String s = element.toString();
if (i > 0) {
pathBuilder.append('/');
normalizedPathBuilder.append('/');
}
final String encodedPathElement = urlEncode(s);
pathBuilder.append(encodedPathElement);
final String normalizedPathElement = normalizePathElement(s, false);
normalizedPathBuilder.append(urlEncode(normalizedPathElement));
i++;
}
this.path = pathBuilder.toString();
this.normalizedPath = normalizedPathBuilder.toString();
this.size = pathElements.size();
}
/**
* Creates a new resource path having the provided path elements.
*
* @param pathElements
* The unencoded path elements.
*/
public ResourcePath(final Object... pathElements) {
this(asList(pathElements));
}
private ResourcePath(final String path, final String normalizedPath, final int size) {
this.path = path;
this.normalizedPath = normalizedPath;
this.size = size;
}
/**
* Creates a new resource path which is a child of this resource path. The
* returned resource path will have the same path elements as this resource
* path and, in addition, the provided path element.
*
* @param pathElement
* The unencoded child path element.
* @return A new resource path which is a child of this resource path.
*/
public ResourcePath child(final Object pathElement) {
final String s = pathElement.toString();
final String encodedPathElement = urlEncode(s);
final String normalizedPathElement = normalizePathElement(s, false);
final String normalizedEncodedPathElement = urlEncode(normalizedPathElement);
if (isEmpty()) {
return new ResourcePath(encodedPathElement, normalizedEncodedPathElement, 1);
} else {
final String newPath = path + "/" + encodedPathElement;
final String newNormalizedPath = normalizedPath + "/" + normalizedEncodedPathElement;
return new ResourcePath(newPath, newNormalizedPath, size + 1);
}
}
/**
* Compares this resource path with the provided resource path. Resource
* paths are compared case sensitively and ancestors sort before
* descendants.
*
* @param o
* {@inheritDoc}
* @return {@inheritDoc}
*/
@Override
public int compareTo(final ResourcePath o) {
return normalizedPath.compareTo(o.normalizedPath);
}
/**
* Creates a new resource path which is a descendant of this resource path.
* The returned resource path will have be formed of the concatenation of
* this resource path and the provided resource path.
*
* @param suffix
* The resource path to be appended to this resource path.
* @return A new resource path which is a descendant of this resource path.
*/
public ResourcePath concat(final ResourcePath suffix) {
if (isEmpty()) {
return suffix;
} else if (suffix.isEmpty()) {
return this;
} else {
final String newPath = path + "/" + suffix.path;
final String newNormalizedPath = normalizedPath + "/" + suffix.normalizedPath;
return new ResourcePath(newPath, newNormalizedPath, size + suffix.size);
}
}
/**
* Creates a new resource path which is a descendant of this resource path.
* The returned resource path will have be formed of the concatenation of
* this resource path and the provided resource path.
*
* @param suffix
* The resource path to be appended to this resource path.
* @return A new resource path which is a descendant of this resource path.
* @throws IllegalArgumentException
* If the the suffix contains empty path elements.
*/
public ResourcePath concat(final String suffix) {
return concat(resourcePath(suffix));
}
/**
* Returns {@code true} if {@code obj} is a resource path having the exact
* same elements as this resource path.
*
* @param obj
* The object to be compared.
* @return {@code true} if {@code obj} is a resource path having the exact
* same elements as this resource path.
*/
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
} else if (obj instanceof ResourcePath) {
return normalizedPath.equals(((ResourcePath) obj).normalizedPath);
} else {
return false;
}
}
/**
* Returns the path element at the specified position in this resource path.
* The path element at position 0 is the top level element (closest to
* root).
*
* @param index
* The index of the path element to be returned, where 0 is the
* top level element.
* @return The path element at the specified position in this resource path.
* @throws IndexOutOfBoundsException
* If the index is out of range (index < 0 || index >= size()).
*/
public String get(final int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException();
}
int startIndex = 0;
int endIndex = nextElementEndIndex(path, 0);
for (int i = 0; i < index; i++) {
startIndex = endIndex + 1;
endIndex = nextElementEndIndex(path, startIndex);
}
return urlDecode(path.substring(startIndex, endIndex));
}
/**
* Returns a hash code for this resource path.
*
* @return A hash code for this resource path.
*/
@Override
public int hashCode() {
return normalizedPath.hashCode();
}
/**
* Returns a resource path which is a subsequence of the path elements
* contained in this resource path beginning with the first element (0) and
* ending with the element at position {@code endIndex-1}. The returned
* resource path will therefore have the size {@code endIndex}. Calling this
* method is equivalent to:
*
* <pre>
* subSequence(0, endIndex);
* </pre>
*
* @param endIndex
* The end index, exclusive.
* @return A resource path which is a subsequence of the path elements
* contained in this resource path.
* @throws IndexOutOfBoundsException
* If {@code endIndex} is bigger than {@code size()}.
*/
public ResourcePath head(final int endIndex) {
return subSequence(0, endIndex);
}
/**
* Returns {@code true} if this resource path contains no path elements.
*
* @return {@code true} if this resource path contains no path elements.
*/
public boolean isEmpty() {
return size == 0;
}
/**
* Returns an iterator over the path elements in this resource path. The
* returned iterator will not support the {@link Iterator#remove()} method
* and will return path elements starting with index 0, then 1, then 2, etc.
*
* @return An iterator over the path elements in this resource path.
*/
@Override
public Iterator<String> iterator() {
return new Iterator<String>() {
private int startIndex = 0;
private int endIndex = nextElementEndIndex(path, 0);
@Override
public boolean hasNext() {
return startIndex < path.length();
}
@Override
public String next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
final String element = path.substring(startIndex, endIndex);
startIndex = endIndex + 1;
endIndex = nextElementEndIndex(path, startIndex);
return urlDecode(element);
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
};
}
/**
* Returns the last path element in this resource path. Calling this method
* is equivalent to:
*
* <pre>
* resourcePath.get(resourcePath.size() - 1);
* </pre>
*
* @return The last path element in this resource path.
*/
public String leaf() {
return get(size() - 1);
}
/**
* Returns the resource path which is the immediate parent of this resource
* path, or {@code null} if this resource path is empty.
*
* @return The resource path which is the immediate parent of this resource
* path, or {@code null} if this resource path is empty.
*/
public ResourcePath parent() {
switch (size()) {
case 0:
return null;
case 1:
return EMPTY;
default:
final String newPath = path.substring(0, path.lastIndexOf('/') /* safe */);
final String newNormalizedPath =
normalizedPath.substring(0, normalizedPath.lastIndexOf('/') /* safe */);
return new ResourcePath(newPath, newNormalizedPath, size - 1);
}
}
/**
* Returns the number of elements in this resource path, or 0 if it is
* empty.
*
* @return The number of elements in this resource path, or 0 if it is
* empty.
*/
public int size() {
return size;
}
/**
* Returns {@code true} if this resource path is equal to or begins with the
* provided resource resource path.
*
* @param prefix
* The resource path prefix.
* @return {@code true} if this resource path is equal to or begins with the
* provided resource resource path.
*/
public boolean startsWith(final ResourcePath prefix) {
if (size == prefix.size) {
return equals(prefix);
} else if (size < prefix.size) {
return false;
} else if (prefix.size == 0) {
return true;
} else {
return normalizedPath.startsWith(prefix.normalizedPath)
&& normalizedPath.charAt(prefix.normalizedPath.length()) == '/';
}
}
/**
* Returns {@code true} if this resource path is equal to or begins with the
* provided resource resource path.
*
* @param prefix
* The resource path prefix.
* @return {@code true} if this resource path is equal to or begins with the
* provided resource resource path.
* @throws IllegalArgumentException
* If the the prefix contains empty path elements.
*/
public boolean startsWith(final String prefix) {
return startsWith(resourcePath(prefix));
}
/**
* Returns a resource path which is a subsequence of the path elements
* contained in this resource path beginning with the element at position
* {@code beginIndex} and ending with the element at position
* {@code endIndex-1}. The returned resource path will therefore have the
* size {@code endIndex - beginIndex}.
*
* @param beginIndex
* The beginning index, inclusive.
* @param endIndex
* The end index, exclusive.
* @return A resource path which is a subsequence of the path elements
* contained in this resource path.
* @throws IndexOutOfBoundsException
* If {@code beginIndex} is negative, or {@code endIndex} is
* bigger than {@code size()}, or if {@code beginIndex} is
* bigger than {@code endIndex}.
*/
public ResourcePath subSequence(final int beginIndex, final int endIndex) {
if (beginIndex < 0 || endIndex > size || beginIndex > endIndex) {
throw new IndexOutOfBoundsException();
}
if (beginIndex == 0 && endIndex == size) {
return this;
}
if (endIndex - beginIndex == 0) {
return EMPTY;
}
final String subPath = subPath(path, beginIndex, endIndex);
final String subNormalizedPath = subPath(normalizedPath, beginIndex, endIndex);
return new ResourcePath(subPath, subNormalizedPath, endIndex - beginIndex);
}
/**
* Returns a resource path which is a subsequence of the path elements
* contained in this resource path beginning with the element at position
* {@code beginIndex} and ending with the last element in this resource
* path. The returned resource path will therefore have the size
* {@code size() - beginIndex}. Calling this method is equivalent to:
*
* <pre>
* subSequence(beginIndex, size());
* </pre>
*
* @param beginIndex
* The beginning index, inclusive.
* @return A resource path which is a subsequence of the path elements
* contained in this resource path.
* @throws IndexOutOfBoundsException
* If {@code beginIndex} is negative, or if {@code beginIndex}
* is bigger than {@code size()}.
*/
public ResourcePath tail(final int beginIndex) {
return subSequence(beginIndex, size);
}
/**
* Returns the URL path encoded string representation of this resource path.
*
* @return The URL path encoded string representation of this resource path.
* @see #resourcePath(String)
*/
@Override
public String toString() {
return path;
}
private int nextElementEndIndex(final String s, final int startIndex) {
final int index = s.indexOf('/', startIndex);
return index < 0 ? s.length() : index;
}
private String subPath(final String s, final int beginIndex, final int endIndex) {
int startCharIndex = 0;
int endCharIndex = nextElementEndIndex(s, 0);
for (int i = 0; i < beginIndex; i++) {
startCharIndex = endCharIndex + 1;
endCharIndex = nextElementEndIndex(s, startCharIndex);
}
int tmpStartCharIndex;
for (int i = beginIndex + 1; i < endIndex; i++) {
tmpStartCharIndex = endCharIndex + 1;
endCharIndex = nextElementEndIndex(s, tmpStartCharIndex);
}
return s.substring(startCharIndex, endCharIndex);
}
}