diff --git a/src/main/java/org/springframework/hateoas/LinkTemplate.java b/src/main/java/org/springframework/hateoas/LinkTemplate.java new file mode 100644 index 00000000..5bf7a4e3 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/LinkTemplate.java @@ -0,0 +1,87 @@ +/* + * Copyright 2014 the original author or authors. + * + * 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.springframework.hateoas; + +import java.util.List; +import java.util.Map; + +/** + * A link template. + * + * @author Oliver Gierke + */ +public class LinkTemplate extends Link { + + private static final long serialVersionUID = 770560851448247262L; + + private UriTemplate uriTemplate; + + /** + * Creates a new {@link LinkTemplate} for the given template string and relation type. + * + * @param template must not be {@literal null} or empty. + * @param rel must not be {@literal null} or empty. + */ + public LinkTemplate(String template, String rel) { + + super(template, rel); + + this.uriTemplate = new UriTemplate(template); + } + + /** + * Default constructor for the marshalling frameworks. + */ + protected LinkTemplate() {} + + /** + * Returns the variable names contained in the template. + * + * @return + */ + public List getVariableNames() { + return uriTemplate.getVariableNames(); + } + + /** + * Returns whether the link is templated. + * + * @return + */ + public boolean isTemplate() { + return true; + } + + /** + * Turns the current template into a {@link Link} by expanding it using the given parameters. + * + * @param arguments + * @return + */ + public Link toLink(Object... arguments) { + return new Link(uriTemplate.expand(arguments).toString(), getRel()); + } + + /** + * Turns the current template into a {@link Link} by expanding it using the given parameters. + * + * @param arguments + * @return + */ + public Link toLink(Map arguments) { + return new Link(uriTemplate.expand(arguments).toString(), getRel()); + } +} diff --git a/src/main/java/org/springframework/hateoas/UriTemplate.java b/src/main/java/org/springframework/hateoas/UriTemplate.java new file mode 100644 index 00000000..8c2add65 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/UriTemplate.java @@ -0,0 +1,316 @@ +/* + * Copyright 2014 the original author or authors. + * + * 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.springframework.hateoas; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.hateoas.UriTemplate.TemplateVariable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Custom URI template to support qualified URI template variables. + * + * @author Oliver Gierke + * @see http://tools.ietf.org/html/rfc6570 + * @since 0.9 + */ +public class UriTemplate implements Iterable { + + private static final Pattern VARIABLE_REGEX = Pattern.compile("\\{([\\?\\&#/]?)([\\w\\,]+)\\}"); + + private final List variables = new ArrayList(); + private String baseUri; + + /** + * Creates a new {@link UriTemplate} using the given template string. + * + * @param template must not be {@literal null} or empty. + */ + public UriTemplate(String template) { + + Assert.hasText(template, "Template must not be null or empty!"); + + Matcher matcher = VARIABLE_REGEX.matcher(template); + + while (matcher.find()) { + + if (baseUri == null) { + this.baseUri = template.substring(0, matcher.start(0)); + } + + VariableType type = VariableType.from(matcher.group(1)); + String[] names = matcher.group(2).split(","); + + for (String name : names) { + this.variables.add(new TemplateVariable(name, type)); + } + } + } + + /** + * Returns whether the given candidate is a URI template. + * + * @param candidate + * @return + */ + public static boolean isTemplate(String candidate) { + + if (!StringUtils.hasText(candidate)) { + return false; + } + + return VARIABLE_REGEX.matcher(candidate).find(); + } + + /** + * Returns the {@link TemplateVariable}s discovered. + * + * @return + */ + public List getVariables() { + return this.variables; + } + + /** + * Returns the names of the variables discovered. + * + * @return + */ + public List getVariableNames() { + + List names = new ArrayList(); + + for (TemplateVariable variable : variables) { + names.add(variable.getName()); + } + + return names; + } + + /** + * Expands the {@link UriTemplate} using the given parameters. The values will be applied in the order of the + * variables discovered. + * + * @param parameters + * @return + * @see #expand(Map) + */ + public URI expand(Object... parameters) { + + UriComponentsBuilder builder = UriComponentsBuilder.fromPath(baseUri); + Iterator iterator = Arrays.asList(parameters).iterator(); + + for (TemplateVariable variable : variables) { + + Object value = iterator.hasNext() ? iterator.next() : null; + appendToBuilder(builder, variable, value); + } + + return builder.build().toUri(); + } + + /** + * Expands the {@link UriTemplate} using the given parameters. + * + * @param parameters must not be {@literal null}. + * @return + */ + public URI expand(Map parameters) { + + Assert.notNull(parameters, "Parameters must not be null!"); + UriComponentsBuilder builder = UriComponentsBuilder.fromPath(baseUri); + + for (TemplateVariable variable : variables) { + appendToBuilder(builder, variable, parameters.get(variable.name)); + } + + return builder.build().toUri(); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return this.variables.iterator(); + } + + /** + * Appends the value for the given {@link TemplateVariable} to the given {@link UriComponentsBuilder}. + * + * @param builder must not be {@literal null}. + * @param variable must not be {@literal null}. + * @param value can be {@literal null}. + */ + private static void appendToBuilder(UriComponentsBuilder builder, TemplateVariable variable, Object value) { + + if (value == null) { + + if (variable.isRequired()) { + throw new IllegalArgumentException(String.format("Template variable %s is required but no value was given!", + variable.name)); + } + + return; + } + + switch (variable.type) { + case REQUEST_PARAM: + case REQUEST_PARAM_CONTINUED: + builder.queryParam(variable.name, value); + break; + case PATH_VARIABLE: + case SEGMENT: + builder.pathSegment(value.toString()); + break; + case FRAGMENT: + builder.fragment(value.toString()); + break; + } + } + + public static final class TemplateVariable { + + private final String name; + private final VariableType type; + + /** + * Creates a new {@link TemplateVariable} with the given name and type. + * + * @param name must not be {@literal null} or empty. + * @param type must not be {@literal null}. + */ + TemplateVariable(String name, VariableType type) { + + Assert.hasText("Variable name must not be null or empty!"); + Assert.notNull("Variable type must not be null!"); + + this.name = name; + this.type = type; + } + + /** + * Returns the name of the variable. + * + * @return + */ + public String getName() { + return this.name; + } + + /** + * Returns whether the template variable is optional, which means the template can be expanded to a URI without a + * value given for that variable. + * + * @return + */ + boolean isRequired() { + return !type.isOptional(); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object obj) { + + if (obj == this) { + return true; + } + + if (!(obj instanceof TemplateVariable)) { + return false; + } + + TemplateVariable that = (TemplateVariable) obj; + return this.name.equals(that.name) && this.type.equals(that.type); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + + int result = 17; + + result += this.name.hashCode(); + result += this.type.hashCode(); + + return result; + } + } + + /** + * An enumeration for all supported variable types. + * + * @author Oliver Gierke + */ + static enum VariableType { + + PATH_VARIABLE("", false), // + REQUEST_PARAM("?", true), // + REQUEST_PARAM_CONTINUED("&", true), // + SEGMENT("/", true), // + FRAGMENT("#", true); + + private final String key; + private final boolean optional; + + private VariableType(String key, boolean optional) { + this.key = key; + this.optional = optional; + } + + /** + * Returns whether the variable of this type is optional. + * + * @return + */ + public boolean isOptional() { + return optional; + } + + /** + * Returns the {@link VariableType} for the given variable key. + * + * @param key must not be {@literal null}. + * @return + */ + public static VariableType from(String key) { + + for (VariableType type : values()) { + if (type.key.equals(key)) { + return type; + } + } + + throw new IllegalArgumentException("Unsupported variable type " + key + "!"); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/core/JsonPathLinkDiscoverer.java b/src/main/java/org/springframework/hateoas/core/JsonPathLinkDiscoverer.java index a57c9a40..8c326789 100644 --- a/src/main/java/org/springframework/hateoas/core/JsonPathLinkDiscoverer.java +++ b/src/main/java/org/springframework/hateoas/core/JsonPathLinkDiscoverer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012 the original author or authors. + * Copyright 2012-2013 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,8 @@ import net.minidev.json.JSONArray; import org.springframework.hateoas.Link; import org.springframework.hateoas.LinkDiscoverer; +import org.springframework.hateoas.LinkTemplate; +import org.springframework.hateoas.UriTemplate; import org.springframework.http.MediaType; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -143,7 +145,8 @@ public class JsonPathLinkDiscoverer implements LinkDiscoverer { return Collections.unmodifiableList(links); } - Link link = new Link(parseResult.toString(), rel); + String href = parseResult.toString(); + Link link = UriTemplate.isTemplate(href) ? new LinkTemplate(href, rel) : new Link(href, rel); return Collections.unmodifiableList(Arrays.asList(link)); } diff --git a/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java b/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java index 00859174..81643ef3 100644 --- a/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java +++ b/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2013 the original author or authors. + * Copyright 2012-2014 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,8 @@ import java.net.URI; import org.springframework.hateoas.Identifiable; import org.springframework.hateoas.Link; import org.springframework.hateoas.LinkBuilder; +import org.springframework.hateoas.LinkTemplate; +import org.springframework.hateoas.UriTemplate; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponents; @@ -111,7 +113,8 @@ public abstract class LinkBuilderSupport implements LinkB * @see org.springframework.hateoas.LinkBuilder#withRel(java.lang.String) */ public Link withRel(String rel) { - return new Link(this.toString(), rel); + String href = this.toString(); + return UriTemplate.isTemplate(href) ? new LinkTemplate(href, rel) : new Link(href, rel); } /* @@ -119,7 +122,7 @@ public abstract class LinkBuilderSupport implements LinkB * @see org.springframework.hateoas.LinkBuilder#withSelfRel() */ public Link withSelfRel() { - return new Link(this.toString()); + return withRel(Link.REL_SELF); } /* diff --git a/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java b/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java index 6fd9a6fc..c7003049 100644 --- a/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java +++ b/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java @@ -1,5 +1,5 @@ /* - * Copyright 2012 the original author or authors. + * Copyright 2012-2014 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import java.util.Map; import org.springframework.beans.BeanUtils; import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkTemplate; import org.springframework.hateoas.RelProvider; import org.springframework.hateoas.Resource; import org.springframework.hateoas.ResourceSupport; @@ -80,6 +81,7 @@ public class Jackson2HalModule extends SimpleModule { super("json-hal-module", new Version(1, 0, 0, null, "org.springframework.hateoas", "spring-hateoas")); setMixInAnnotation(Link.class, LinkMixin.class); + setMixInAnnotation(LinkTemplate.class, LinkTemplateMixin.class); setMixInAnnotation(ResourceSupport.class, ResourceSupportMixin.class); setMixInAnnotation(Resources.class, ResourcesMixin.class); } diff --git a/src/main/java/org/springframework/hateoas/hal/LinkTemplateMixin.java b/src/main/java/org/springframework/hateoas/hal/LinkTemplateMixin.java new file mode 100644 index 00000000..08646eea --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/LinkTemplateMixin.java @@ -0,0 +1,35 @@ +/* + * Copyright 2014 the original author or authors. + * + * 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.springframework.hateoas.hal; + +import java.util.List; + +import org.springframework.hateoas.LinkTemplate; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Mixin for {@link LinkTemplate}s. + * + * @author Oliver Gierke + */ +abstract class LinkTemplateMixin extends LinkMixin { + + private static final long serialVersionUID = -7227899604445243148L; + + @JsonIgnore + public abstract List getVariableNames(); +} diff --git a/src/test/java/org/springframework/hateoas/UriTemplateUnitTests.java b/src/test/java/org/springframework/hateoas/UriTemplateUnitTests.java new file mode 100644 index 00000000..d8617cae --- /dev/null +++ b/src/test/java/org/springframework/hateoas/UriTemplateUnitTests.java @@ -0,0 +1,176 @@ +/* + * Copyright 2014 the original author or authors. + * + * 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.springframework.hateoas; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; +import org.springframework.hateoas.UriTemplate.TemplateVariable; +import org.springframework.hateoas.UriTemplate.VariableType; + +/** + * Unit tests for {@link UriTemplate}. + * + * @author Oliver Gierke + */ +public class UriTemplateUnitTests { + + /** + * @see #137 + */ + @Test + public void discoversTemplate() { + + assertThat(UriTemplate.isTemplate("/foo{?bar}"), is(true)); + assertThat(UriTemplate.isTemplate("/foo"), is(false)); + assertThat(UriTemplate.isTemplate(null), is(false)); + assertThat(UriTemplate.isTemplate(""), is(false)); + } + + /** + * @see #137 + */ + @Test + public void discoversRequestParam() { + + UriTemplate template = new UriTemplate("/foo{?bar}"); + + assertVariables(template, new TemplateVariable("bar", VariableType.REQUEST_PARAM)); + } + + /** + * @see #137 + */ + @Test + public void discoversRequestParamCntinued() { + + UriTemplate template = new UriTemplate("/foo?bar{&foobar}"); + + assertVariables(template, new TemplateVariable("foobar", VariableType.REQUEST_PARAM_CONTINUED)); + } + + /** + * @see #137 + */ + @Test + public void discoversOptionalPathVariable() { + + UriTemplate template = new UriTemplate("/foo{/bar}"); + + assertVariables(template, new TemplateVariable("bar", VariableType.SEGMENT)); + } + + /** + * @see #137 + */ + @Test + public void discoversPathVariable() { + + UriTemplate template = new UriTemplate("/foo/{bar}"); + + assertVariables(template, new TemplateVariable("bar", VariableType.PATH_VARIABLE)); + } + + /** + * @see #137 + */ + @Test + public void discoversFragment() { + + UriTemplate template = new UriTemplate("/foo{#bar}"); + + assertVariables(template, new TemplateVariable("bar", VariableType.FRAGMENT)); + } + + /** + * @see #137 + */ + @Test + public void discoversMultipleRequestParam() { + + UriTemplate template = new UriTemplate("/foo{?bar,foobar}"); + + assertVariables(template, new TemplateVariable("bar", VariableType.REQUEST_PARAM), new TemplateVariable("foobar", + VariableType.REQUEST_PARAM)); + } + + /** + * @see #137 + */ + @Test + public void expandsRequestParameter() { + + UriTemplate template = new UriTemplate("/foo{?bar}"); + + URI uri = template.expand(Collections. singletonMap("bar", "myBar")); + assertThat(uri.toString(), is("/foo?bar=myBar")); + } + + /** + * @see #137 + */ + @Test + public void expandsMultipleRequestParameters() { + + Map parameters = new HashMap(); + parameters.put("bar", "myBar"); + parameters.put("fooBar", "myFooBar"); + + UriTemplate template = new UriTemplate("/foo{?bar,fooBar}"); + + URI uri = template.expand(parameters); + assertThat(uri.toString(), is("/foo?bar=myBar&fooBar=myFooBar")); + } + + /** + * @see #137 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsMissingRequiredPathVariable() { + + UriTemplate template = new UriTemplate("/foo/{bar}"); + template.expand(Collections. emptyMap()); + } + + /** + * @see #137 + */ + @Test + public void expandsMultipleVariablesViaArray() { + + UriTemplate template = new UriTemplate("/foo{/bar}{?firstname,lastname}{#anchor}"); + URI uri = template.expand("path", "Dave", "Matthews", "discography"); + assertThat(uri.toString(), is("/foo/path?firstname=Dave&lastname=Matthews#discography")); + } + + private static void assertVariables(UriTemplate template, TemplateVariable... variables) { + + assertThat(template.getVariableNames(), hasSize(variables.length)); + assertThat(template.getVariables(), hasSize(variables.length)); + + for (TemplateVariable variable : variables) { + + assertThat(template, hasItem(variable)); + assertThat(template.getVariableNames(), hasItems(variable.getName())); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java b/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java index a7723edf..8032b794 100644 --- a/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/hal/Jackson2HalIntegrationTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2013 the original author or authors. + * Copyright 2012-2014 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.hateoas.AbstractJackson2MarshallingIntegrationTest; import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkTemplate; import org.springframework.hateoas.Links; import org.springframework.hateoas.PagedResources; import org.springframework.hateoas.PagedResources.PageMetadata; @@ -64,6 +65,8 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg static final String SINGLE_NON_CURIE_LINK = "{\"_links\":{\"self\":{\"href\":\"foo\"}}}"; static final String EMPTY_DOCUMENT = "{}"; + static final String LINK_TEMPLATE = "{\"_links\":{\"search\":{\"href\":\"/foo{?bar}\",\"template\":true}}}"; + @Before public void setUpModule() { @@ -308,6 +311,18 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg assertThat(getCuriedObjectMapper().writeValueAsString(resources), is(SINGLE_NON_CURIE_LINK)); } + /** + * @see #137 + */ + @Test + public void rendersTemplate() throws Exception { + + ResourceSupport support = new ResourceSupport(); + support.add(new LinkTemplate("/foo{?bar}", "search")); + + assertThat(write(support), is(LINK_TEMPLATE)); + } + private static Resources> setupAnnotatedPagedResources() { List> content = new ArrayList>();