diff --git a/src/main/java/org/springframework/hateoas/Affordance.java b/src/main/java/org/springframework/hateoas/Affordance.java index 660eec31..9dd89556 100644 --- a/src/main/java/org/springframework/hateoas/Affordance.java +++ b/src/main/java/org/springframework/hateoas/Affordance.java @@ -15,33 +15,44 @@ */ package org.springframework.hateoas; -import java.util.List; +import lombok.Value; -import org.springframework.core.MethodParameter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.hateoas.core.AffordanceModelFactory; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.util.Assert; /** - * Abstract representation of an action a link is able to take. Web frameworks must provide concrete implementation. + * Hold the {@link AffordanceModel}s for all supported media types. * * @author Greg Turnquist * @author Oliver Gierke */ -public interface Affordance { +@Value +public class Affordance { + + private static List factories = SpringFactoriesLoader.loadFactories(AffordanceModelFactory.class, Affordance.class.getClassLoader()); /** - * HTTP method this affordance covers. For multiple methods, add multiple {@link Affordance}s. - * - * @return + * Collection of {@link AffordanceModel}s related to this affordance. */ - HttpMethod getHttpMethod(); + private final Map affordanceModels = new HashMap<>(); - /** - * Name for the REST action this {@link Affordance} can take. - * - * @return - */ - String getName(); + public Affordance(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { + + Assert.notNull(httpMethod, "httpMethod must not be null!"); + Assert.notNull(queryMethodParameters, "queryMethodParameters must not be null!"); + + for (AffordanceModelFactory factory : factories) { + this.affordanceModels.put(factory.getMediaType(), factory.getAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType)); + } + } /** * Look up the {@link AffordanceModel} for the requested {@link MediaType}. @@ -49,18 +60,8 @@ public interface Affordance { * @param mediaType * @return */ - T getAffordanceModel(MediaType mediaType); - - /** - * Get a listing of {@link MethodParameter}s. - * - * @return - */ - List getInputMethodParameters(); - - /** - * Get a listing of {@link QueryParameter}s. - * @return - */ - List getQueryMethodParameters(); + @SuppressWarnings("unchecked") + public T getAffordanceModel(MediaType mediaType) { + return (T) this.affordanceModels.get(mediaType); + } } diff --git a/src/main/java/org/springframework/hateoas/AffordanceModel.java b/src/main/java/org/springframework/hateoas/AffordanceModel.java index 997a7ab1..76451c46 100644 --- a/src/main/java/org/springframework/hateoas/AffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/AffordanceModel.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2018 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. @@ -15,23 +15,61 @@ */ package org.springframework.hateoas; -import java.util.Collection; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; -import org.springframework.http.MediaType; +import java.util.List; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpMethod; /** - * An affordance model is a media type specific description of an affordance. + * Collection of attributes needed to render any form of hypermedia. * * @author Greg Turnquist - * @author Oliver Gierke */ -public interface AffordanceModel { +@EqualsAndHashCode +@AllArgsConstructor +@Getter +public abstract class AffordanceModel { /** - * The media types this is a model for. Can be multiple ones as often media types come in different flavors like an - * XML and JSON one and in simple cases a single model might serve them all. - * - * @return will never be {@literal null}. + * Name for the REST action of this resource. */ - Collection getMediaTypes(); + private String name; + + /** + * {@link Link} for the URI of the resource. + */ + private Link link; + + /** + * Request method verb for this resource. For multiple methods, add multiple {@link Affordance}s. + */ + private HttpMethod httpMethod; + + /** + * Domain type used to create a new resource. + */ + private ResolvableType inputType; + + /** + * Collection of {@link QueryParameter}s to interrogate a resource. + */ + private List queryMethodParameters; + + /** + * Response body domain type. + */ + private ResolvableType outputType; + + /** + * Expand the {@link Link} into an {@literal href} with no parameters. + * + * @return + */ + public String getURI() { + return this.link.expand().getHref(); + } } diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java index 7a5e2621..88b59725 100755 --- a/src/main/java/org/springframework/hateoas/Link.java +++ b/src/main/java/org/springframework/hateoas/Link.java @@ -30,6 +30,8 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpMethod; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -189,6 +191,48 @@ public class Link implements Serializable { return withAffordances(newAffordances); } + /** + * Convenience method when chaining an existing {@link Link}. + * + * @param name + * @param httpMethod + * @param inputType + * @param queryMethodParameters + * @param outputType + * @return + */ + public Link andAffordance(String name, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { + return andAffordance(new Affordance(name, this, httpMethod, inputType, queryMethodParameters, outputType)); + } + + /** + * Convenience method when chaining an existing {@link Link}. Defaults the name of the affordance to verb + classname, e.g. + * {@literal } produces {@literal }. + * + * @param httpMethod + * @param inputType + * @param queryMethodParameters + * @param outputType + * @return + */ + public Link andAffordance(HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { + return andAffordance(httpMethod.toString().toLowerCase() + inputType.resolve().getSimpleName(), httpMethod, inputType, queryMethodParameters, outputType); + } + + /** + * Convenience method when chaining an existing {@link Link}. Defaults the name of the affordance to verb + classname, e.g. + * {@literal } produces {@literal }. + * + * @param httpMethod + * @param inputType + * @param queryMethodParameters + * @param outputType + * @return + */ + public Link andAffordance(HttpMethod httpMethod, Class inputType, List queryMethodParameters, Class outputType) { + return andAffordance(httpMethod, ResolvableType.forClass(inputType), queryMethodParameters, ResolvableType.forClass(outputType)); + } + /** * Create new {@link Link} with additional {@link Affordance}s. * diff --git a/src/main/java/org/springframework/hateoas/QueryParameter.java b/src/main/java/org/springframework/hateoas/QueryParameter.java index f24cb5b1..3f789691 100644 --- a/src/main/java/org/springframework/hateoas/QueryParameter.java +++ b/src/main/java/org/springframework/hateoas/QueryParameter.java @@ -19,7 +19,7 @@ import lombok.Data; import lombok.RequiredArgsConstructor; /** - * Web framework-neutral representation of a web request's query parameter (http://example.com?name=foo). + * Representation of a web request's query parameter (http://example.com?name=foo) => {"name", "foo", true}. * * @author Greg Turnquist */ @@ -28,6 +28,6 @@ import lombok.RequiredArgsConstructor; public class QueryParameter { private final String name; - private final boolean required; private final String value; + private final boolean required; } diff --git a/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonAffordanceModel.java b/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonAffordanceModel.java index d5f04b8e..b9588f17 100644 --- a/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonAffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonAffordanceModel.java @@ -15,98 +15,78 @@ */ package org.springframework.hateoas.collectionjson; +import lombok.EqualsAndHashCode; import lombok.Getter; -import java.util.Arrays; -import java.util.Collection; import java.util.Collections; +import java.util.EnumSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.core.ResolvableType; import org.springframework.hateoas.Affordance; import org.springframework.hateoas.AffordanceModel; -import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.Link; import org.springframework.hateoas.QueryParameter; import org.springframework.hateoas.support.PropertyUtils; import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.web.util.UriComponents; /** * @author Greg Turnquist */ -class CollectionJsonAffordanceModel implements AffordanceModel { +@EqualsAndHashCode(callSuper = true) +public class CollectionJsonAffordanceModel extends AffordanceModel { - private static final List METHODS_FOR_INPUT_DETECTION = Arrays.asList(HttpMethod.POST, HttpMethod.PUT, - HttpMethod.PATCH); + private static final Set ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH); - private final Affordance affordance; - private final UriComponents components; private final @Getter List inputProperties; private final @Getter List queryProperties; + + public CollectionJsonAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { - CollectionJsonAffordanceModel(Affordance affordance, UriComponents components) { + super(name, link, httpMethod, inputType, queryMethodParameters, outputType); - this.affordance = affordance; - this.components = components; - - this.inputProperties = determineAffordanceInputs(); + this.inputProperties = determineInputs(); this.queryProperties = determineQueryProperties(); } - @Override - public Collection getMediaTypes() { - return Collections.singleton(MediaTypes.COLLECTION_JSON); - } + /** + * Look at the input's domain type to extract the {@link Affordance}'s properties. + * Then transform them into a list of {@link CollectionJsonData} objects. + */ + private List determineInputs() { - public String getRel() { - return isHttpGetMethod() ? this.affordance.getName() : ""; - } + if (ENTITY_ALTERING_METHODS.contains(getHttpMethod())) { - public String getUri() { - return isHttpGetMethod() ? this.components.toUriString() : ""; + return PropertyUtils.findPropertyNames(getInputType()).stream() + .map(propertyName -> new CollectionJsonData() + .withName(propertyName) + .withValue("")) + .collect(Collectors.toList()); + + } else { + return Collections.emptyList(); + + } } /** - * Transform a list of {@link QueryParameter}s into a list of {@link CollectionJsonData} objects. - * + * Transform a list of general {@link QueryParameter}s into a list of {@link CollectionJsonData} objects. + * * @return */ private List determineQueryProperties() { - if (!isHttpGetMethod()) { + if (getHttpMethod().equals(HttpMethod.GET)) { + + return getQueryMethodParameters().stream() + .map(queryProperty -> new CollectionJsonData() + .withName(queryProperty.getName()) + .withValue("")) + .collect(Collectors.toList()); + } else { return Collections.emptyList(); } - - return this.affordance.getQueryMethodParameters().stream() - .map(queryProperty -> new CollectionJsonData().withName(queryProperty.getName()).withValue("")) - .collect(Collectors.toList()); - } - - private boolean isHttpGetMethod() { - return this.affordance.getHttpMethod().equals(HttpMethod.GET); - } - - /** - * Look at the inputs for a Spring MVC controller method to decide the {@link Affordance}'s properties. - * Then transform them into a list of {@link CollectionJsonData} objects. - */ - private List determineAffordanceInputs() { - - if (!METHODS_FOR_INPUT_DETECTION.contains(affordance.getHttpMethod())) { - return Collections.emptyList(); - } - - return this.affordance.getInputMethodParameters().stream() - .findFirst() - .map(methodParameter -> { - ResolvableType resolvableType = ResolvableType.forMethodParameter(methodParameter); - return PropertyUtils.findProperties(resolvableType); - }) - .orElse(Collections.emptyList()) - .stream() - .map(property -> new CollectionJsonData().withName(property).withValue("")) - .collect(Collectors.toList()); } } diff --git a/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonAffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonAffordanceModelFactory.java index 24f31c89..ad7726e2 100644 --- a/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonAffordanceModelFactory.java +++ b/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonAffordanceModelFactory.java @@ -17,31 +17,28 @@ package org.springframework.hateoas.collectionjson; import lombok.Getter; -import org.springframework.hateoas.Affordance; +import java.util.List; + +import org.springframework.core.ResolvableType; import org.springframework.hateoas.AffordanceModel; +import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.QueryParameter; import org.springframework.hateoas.core.AffordanceModelFactory; -import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; -import org.springframework.web.util.UriComponents; /** + * Factory for creating {@link CollectionJsonAffordanceModel}s. + * * @author Greg Turnquist */ class CollectionJsonAffordanceModelFactory implements AffordanceModelFactory { private final @Getter MediaType mediaType = MediaTypes.COLLECTION_JSON; - /** - * Look up the {@link AffordanceModel} for this factory. - * - * @param affordance - * @param invocationValue - * @param components - * @return - */ @Override - public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) { - return new CollectionJsonAffordanceModel(affordance, components); + public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { + return new CollectionJsonAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType); } } diff --git a/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonData.java b/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonData.java index 8583c8f3..99a9fd1e 100644 --- a/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonData.java +++ b/src/main/java/org/springframework/hateoas/collectionjson/CollectionJsonData.java @@ -15,7 +15,6 @@ */ package org.springframework.hateoas.collectionjson; -import lombok.AccessLevel; import lombok.Value; import lombok.experimental.Wither; @@ -29,7 +28,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @author Greg Turnquist */ @Value -@Wither(AccessLevel.PACKAGE) +@Wither @JsonIgnoreProperties() class CollectionJsonData { diff --git a/src/main/java/org/springframework/hateoas/collectionjson/Jackson2CollectionJsonModule.java b/src/main/java/org/springframework/hateoas/collectionjson/Jackson2CollectionJsonModule.java index f4db4150..d21d730e 100644 --- a/src/main/java/org/springframework/hateoas/collectionjson/Jackson2CollectionJsonModule.java +++ b/src/main/java/org/springframework/hateoas/collectionjson/Jackson2CollectionJsonModule.java @@ -75,6 +75,11 @@ public class Jackson2CollectionJsonModule extends SimpleModule { setMixInAnnotation(Resource.class, ResourceMixin.class); setMixInAnnotation(Resources.class, ResourcesMixin.class); setMixInAnnotation(PagedResources.class, PagedResourcesMixin.class); + + addSerializer(new CollectionJsonPagedResourcesSerializer()); + addSerializer(new CollectionJsonResourcesSerializer()); + addSerializer(new CollectionJsonResourceSerializer()); + addSerializer(new CollectionJsonResourceSupportSerializer()); } /** @@ -853,11 +858,12 @@ public class Jackson2CollectionJsonModule extends SimpleModule { /** * For Collection+JSON, "queries" are only collected for GET affordances where the URI is NOT a self link. */ - if (affordance.getHttpMethod().equals(HttpMethod.GET) && !model.getUri().equals(selfLink.getHref())) { + if (model.getHttpMethod() == HttpMethod.GET && !model.getURI().equals(selfLink.getHref())) { + queries.add(new CollectionJsonQuery() - .withRel(model.getRel()) - .withHref(model.getUri()) + .withRel(model.getName()) + .withHref(model.getURI()) .withData(model.getQueryProperties())); } }); @@ -883,7 +889,7 @@ public class Jackson2CollectionJsonModule extends SimpleModule { /** * For Collection+JSON, "templates" are made of any non-GET affordances. */ - if (!affordance.getHttpMethod().equals(HttpMethod.GET)) { + if (!(model.getHttpMethod() == HttpMethod.GET)) { CollectionJsonTemplate template = new CollectionJsonTemplate() // .withData(model.getInputProperties()); diff --git a/src/main/java/org/springframework/hateoas/collectionjson/PagedResourcesMixin.java b/src/main/java/org/springframework/hateoas/collectionjson/PagedResourcesMixin.java index 9686294e..a4610c9d 100644 --- a/src/main/java/org/springframework/hateoas/collectionjson/PagedResourcesMixin.java +++ b/src/main/java/org/springframework/hateoas/collectionjson/PagedResourcesMixin.java @@ -20,14 +20,12 @@ import static org.springframework.hateoas.collectionjson.Jackson2CollectionJsonM import org.springframework.hateoas.PagedResources; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; /** * Jackson 2 mixin to handle {@link PagedResources}. * * @author Greg Turnquist */ -@JsonSerialize(using = CollectionJsonPagedResourcesSerializer.class) @JsonDeserialize(using = CollectionJsonPagedResourcesDeserializer.class) abstract class PagedResourcesMixin extends PagedResources { diff --git a/src/main/java/org/springframework/hateoas/collectionjson/ResourceMixin.java b/src/main/java/org/springframework/hateoas/collectionjson/ResourceMixin.java index 73631154..be39c021 100644 --- a/src/main/java/org/springframework/hateoas/collectionjson/ResourceMixin.java +++ b/src/main/java/org/springframework/hateoas/collectionjson/ResourceMixin.java @@ -20,14 +20,12 @@ import static org.springframework.hateoas.collectionjson.Jackson2CollectionJsonM import org.springframework.hateoas.Resource; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; /** * Jackson 2 mixin to invoke the related serializer/deserizer. * * @author Greg Turnquist */ -@JsonSerialize(using = CollectionJsonResourceSerializer.class) @JsonDeserialize(using = CollectionJsonResourceDeserializer.class) abstract class ResourceMixin extends Resource { diff --git a/src/main/java/org/springframework/hateoas/collectionjson/ResourceSupportMixin.java b/src/main/java/org/springframework/hateoas/collectionjson/ResourceSupportMixin.java index ec9af80c..4f246807 100644 --- a/src/main/java/org/springframework/hateoas/collectionjson/ResourceSupportMixin.java +++ b/src/main/java/org/springframework/hateoas/collectionjson/ResourceSupportMixin.java @@ -33,7 +33,6 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; * @author Greg Turnquist * @author Jens Schauder */ -@JsonSerialize(using = CollectionJsonResourceSupportSerializer.class) @JsonDeserialize(using = CollectionJsonResourceSupportDeserializer.class) abstract class ResourceSupportMixin extends ResourceSupport { diff --git a/src/main/java/org/springframework/hateoas/collectionjson/ResourcesMixin.java b/src/main/java/org/springframework/hateoas/collectionjson/ResourcesMixin.java index 88cae5f9..26dd2e63 100644 --- a/src/main/java/org/springframework/hateoas/collectionjson/ResourcesMixin.java +++ b/src/main/java/org/springframework/hateoas/collectionjson/ResourcesMixin.java @@ -20,14 +20,12 @@ import static org.springframework.hateoas.collectionjson.Jackson2CollectionJsonM import org.springframework.hateoas.Resources; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; /** * Jackson 2 mixin to invoke the related serializer/deserizer. * * @author Greg Turnquist */ -@JsonSerialize(using = CollectionJsonResourcesSerializer.class) @JsonDeserialize(using = CollectionJsonResourcesDeserializer.class) abstract class ResourcesMixin extends Resources { diff --git a/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java b/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java index 22c290d9..4098ae80 100644 --- a/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java +++ b/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java @@ -32,16 +32,19 @@ import org.springframework.context.support.MessageSourceAccessor; import org.springframework.hateoas.RelProvider; import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule; +import org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule.CollectionJsonHandlerInstantiator; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; import org.springframework.hateoas.core.DelegatingRelProvider; import org.springframework.hateoas.hal.CurieProvider; import org.springframework.hateoas.hal.HalConfiguration; import org.springframework.hateoas.hal.Jackson2HalModule; -import org.springframework.hateoas.hal.Jackson2HalModule.HalHandlerInstantiator; +import org.springframework.hateoas.hal.Jackson2HalModule.*; import org.springframework.hateoas.hal.forms.HalFormsConfiguration; import org.springframework.hateoas.hal.forms.Jackson2HalFormsModule; +import org.springframework.hateoas.hal.forms.Jackson2HalFormsModule.HalFormsHandlerInstantiator; import org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -50,6 +53,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; /** * @author Oliver Gierke + * @author Greg Turnquist */ @Configuration @RequiredArgsConstructor @@ -89,32 +93,34 @@ public class ConverterRegisteringWebMvcConfigurer implements WebMvcConfigurer, B @Override public void extendMessageConverters(List> converters) { - for (HttpMessageConverter converter : converters) { - if (converter instanceof MappingJackson2HttpMessageConverter) { - MappingJackson2HttpMessageConverter halConverterCandidate = (MappingJackson2HttpMessageConverter) converter; - ObjectMapper objectMapper = halConverterCandidate.getObjectMapper(); - if (Jackson2HalModule.isAlreadyRegisteredIn(objectMapper)) { - return; - } - } + if (converters.stream() + .filter(MappingJackson2HttpMessageConverter.class::isInstance) + .map(AbstractJackson2HttpMessageConverter.class::cast) + .map(converter -> converter.getObjectMapper()) + .anyMatch(Jackson2HalModule::isAlreadyRegisteredIn)) { + + return; } - ObjectMapper objectMapper = mapper.getIfAvailable(() -> new ObjectMapper()); - - CurieProvider curieProvider = this.curieProvider.getIfAvailable(); - RelProvider relProvider = this.relProvider.getObject(); - + ObjectMapper objectMapper = mapper.getIfAvailable(ObjectMapper::new); + MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSourceAccessor.class); - if (hypermediaTypes.contains(HypermediaType.HAL)) { - converters.add(0, createHalConverter(objectMapper, curieProvider, relProvider, linkRelationMessageSource)); - } + if (hypermediaTypes.stream().anyMatch(HypermediaType::isHalBasedMediaType)) { - if (hypermediaTypes.contains(HypermediaType.HAL_FORMS)) { - converters.add(0, createHalFormsConverter(objectMapper, curieProvider, relProvider, linkRelationMessageSource)); - } + CurieProvider curieProvider = this.curieProvider.getIfAvailable(); + RelProvider relProvider = this.relProvider.getObject(); + if (hypermediaTypes.contains(HypermediaType.HAL)) { + converters.add(0, createHalConverter(objectMapper, curieProvider, relProvider, linkRelationMessageSource)); + } + + if (hypermediaTypes.contains(HypermediaType.HAL_FORMS)) { + converters.add(0, createHalFormsConverter(objectMapper, curieProvider, relProvider, linkRelationMessageSource)); + } + } + if (hypermediaTypes.contains(HypermediaType.COLLECTION_JSON)) { converters.add(0, createCollectionJsonConverter(objectMapper, linkRelationMessageSource)); } @@ -127,18 +133,15 @@ public class ConverterRegisteringWebMvcConfigurer implements WebMvcConfigurer, B */ protected MappingJackson2HttpMessageConverter createCollectionJsonConverter(ObjectMapper objectMapper, MessageSourceAccessor linkRelationMessageSource) { - ObjectMapper collectionJsonObjectMapper = objectMapper.copy(); - collectionJsonObjectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - collectionJsonObjectMapper.registerModule(new Jackson2CollectionJsonModule()); - collectionJsonObjectMapper.setHandlerInstantiator( - new Jackson2CollectionJsonModule.CollectionJsonHandlerInstantiator(linkRelationMessageSource)); + ObjectMapper mapper = objectMapper.copy(); - MappingJackson2HttpMessageConverter collectionJsonConverter = new TypeConstrainedMappingJackson2HttpMessageConverter( - ResourceSupport.class); - collectionJsonConverter.setSupportedMediaTypes(Arrays.asList(COLLECTION_JSON)); - collectionJsonConverter.setObjectMapper(collectionJsonObjectMapper); - return collectionJsonConverter; + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.registerModule(new Jackson2CollectionJsonModule()); + mapper.setHandlerInstantiator(new CollectionJsonHandlerInstantiator(linkRelationMessageSource)); + + return new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class, Arrays.asList(COLLECTION_JSON), mapper); } /** @@ -151,21 +154,16 @@ public class ConverterRegisteringWebMvcConfigurer implements WebMvcConfigurer, B private MappingJackson2HttpMessageConverter createHalFormsConverter(ObjectMapper objectMapper, CurieProvider curieProvider, RelProvider relProvider, MessageSourceAccessor linkRelationMessageSource) { - Jackson2HalFormsModule.HalFormsHandlerInstantiator hi = new Jackson2HalFormsModule.HalFormsHandlerInstantiator( - relProvider, curieProvider, linkRelationMessageSource, true, - this.halFormsConfiguration.getIfAvailable(() -> new HalFormsConfiguration())); ObjectMapper mapper = objectMapper.copy(); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); mapper.registerModule(new Jackson2HalFormsModule()); - mapper.setHandlerInstantiator(hi); + mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator( + relProvider, curieProvider, linkRelationMessageSource, true, + this.halFormsConfiguration.getIfAvailable(HalFormsConfiguration::new))); - MappingJackson2HttpMessageConverter converter = new TypeConstrainedMappingJackson2HttpMessageConverter( - ResourceSupport.class); - converter.setSupportedMediaTypes(Arrays.asList(HAL_FORMS_JSON)); - converter.setObjectMapper(mapper); - - return converter; + return new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class, Arrays.asList(HAL_FORMS_JSON), mapper); } /** @@ -178,21 +176,14 @@ public class ConverterRegisteringWebMvcConfigurer implements WebMvcConfigurer, B private MappingJackson2HttpMessageConverter createHalConverter(ObjectMapper objectMapper, CurieProvider curieProvider, RelProvider relProvider, MessageSourceAccessor linkRelationMessageSource) { - HalConfiguration halConfiguration = this.halConfiguration.getIfAvailable(() -> new HalConfiguration()); - - HalHandlerInstantiator instantiator = new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider, - linkRelationMessageSource, halConfiguration); - ObjectMapper mapper = objectMapper.copy(); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); mapper.registerModule(new Jackson2HalModule()); - mapper.setHandlerInstantiator(instantiator); + mapper.setHandlerInstantiator(new HalHandlerInstantiator(relProvider, curieProvider, + linkRelationMessageSource, this.halConfiguration.getIfAvailable(HalConfiguration::new))); - MappingJackson2HttpMessageConverter converter = new TypeConstrainedMappingJackson2HttpMessageConverter( - ResourceSupport.class); - converter.setSupportedMediaTypes(Arrays.asList(HAL_JSON, HAL_JSON_UTF8)); - converter.setObjectMapper(mapper); - - return converter; + return new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class, Arrays.asList(HAL_JSON, HAL_JSON_UTF8), mapper); } } diff --git a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java index 1fc5e5a6..c947b248 100644 --- a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java +++ b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java @@ -20,11 +20,14 @@ import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.EnumSet; +import java.util.Set; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Import; import org.springframework.hateoas.EntityLinks; import org.springframework.hateoas.LinkDiscoverer; +import org.springframework.http.MediaType; /** * Activates hypermedia support in the {@link ApplicationContext}. Will register infrastructure beans available for @@ -84,5 +87,17 @@ public @interface EnableHypermediaSupport { * @see http://amundsen.com/media-types/collection/format/ */ COLLECTION_JSON; + + private static Set HAL_BASED_MEDIATYPES = EnumSet.of(HAL, HAL_FORMS); + + /** + * Is this {@literal HAL} or one of its derivatives? + * + * @param hypermediaType + * @return + */ + public static boolean isHalBasedMediaType(HypermediaType hypermediaType) { + return HAL_BASED_MEDIATYPES.contains(hypermediaType); + } } } diff --git a/src/main/java/org/springframework/hateoas/core/AffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/core/AffordanceModelFactory.java index 5eb429d8..4c988082 100644 --- a/src/main/java/org/springframework/hateoas/core/AffordanceModelFactory.java +++ b/src/main/java/org/springframework/hateoas/core/AffordanceModelFactory.java @@ -15,18 +15,19 @@ */ package org.springframework.hateoas.core; +import java.util.List; import java.util.Optional; -import org.springframework.hateoas.Affordance; +import org.springframework.core.ResolvableType; import org.springframework.hateoas.AffordanceModel; -import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.QueryParameter; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.plugin.core.Plugin; -import org.springframework.web.util.UriComponents; /** - * TODO: Replace this with an interface and a default implementation of {@link #supports(MediaType)} in Java 8. - * + * * @author Greg Turnquist * @author Oliver Gierke */ @@ -44,12 +45,15 @@ public interface AffordanceModelFactory extends Plugin { /** * Look up the {@link AffordanceModel} for this factory. * - * @param affordance - * @param invocationValue - * @param components + * @param name + * @param link + * @param httpMethod + * @param inputType + * @param queryMethodParameters + * @param outputType * @return */ - AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components); + AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType); /** * Returns if a plugin should be invoked according to the given delimiter. @@ -59,6 +63,7 @@ public interface AffordanceModelFactory extends Plugin { */ @Override default boolean supports(MediaType delimiter) { + return Optional.ofNullable(getMediaType()) .map(mediaType -> mediaType.equals(delimiter)) .orElse(false); diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModel.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModel.java index f5e2e627..39957b22 100644 --- a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModel.java +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModel.java @@ -15,108 +15,60 @@ */ package org.springframework.hateoas.hal.forms; +import lombok.EqualsAndHashCode; +import lombok.Getter; + import java.util.Arrays; -import java.util.Collection; import java.util.Collections; +import java.util.EnumSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.core.ResolvableType; import org.springframework.hateoas.Affordance; import org.springframework.hateoas.AffordanceModel; -import org.springframework.hateoas.MediaTypes; -import org.springframework.hateoas.core.MethodParameters; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.QueryParameter; import org.springframework.hateoas.support.PropertyUtils; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; -import org.springframework.util.Assert; -import org.springframework.web.util.UriComponents; /** - * {@link AffordanceModel} for a HAL-FORMS {@link org.springframework.http.MediaType}. + * {@link AffordanceModel} for a HAL-FORMS {@link MediaType}. * * @author Greg Turnquist */ -class HalFormsAffordanceModel implements AffordanceModel { +@EqualsAndHashCode(callSuper = true) +public class HalFormsAffordanceModel extends AffordanceModel { - private static final List METHODS_FOR_INPUT_DETECTTION = Arrays.asList(HttpMethod.POST, HttpMethod.PUT, - HttpMethod.PATCH); + private static final Set ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH); - private final Affordance affordance; - private final UriComponents components; - private final boolean required; - private final List properties; + private final @Getter List inputProperties; - public HalFormsAffordanceModel(Affordance affordance, UriComponents components) { + public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { - this.affordance = affordance; - this.components = components; - this.required = determineRequired(affordance.getHttpMethod()); - this.properties = METHODS_FOR_INPUT_DETECTTION.contains(affordance.getHttpMethod()) // - ? determineAffordanceInputs() // - : Collections.emptyList(); + super(name, link, httpMethod, inputType, queryMethodParameters, outputType); + + this.inputProperties = determineInputs(); } /** - * Transform the details of the REST method's {@link MethodParameters} into - * {@link HalFormsProperty}s. - * - * @return + * Look at the input's domain type to extract the {@link Affordance}'s properties. + * Then transform them into a list of {@link HalFormsProperty} objects. */ - public List getProperties() { + private List determineInputs() { - return properties.stream() // - .map(name -> HalFormsProperty.named(name).withRequired(required)) // - .collect(Collectors.toList()); - } - - public String getURI() { - return components.toUriString(); - } - - /** - * Returns whether the affordance is pointing to the same path as the given one. - * - * @param path must not be {@literal null}. - * @return - */ - public boolean hasPath(String path) { - - Assert.notNull(path, "Path must not be null!"); - - return getURI().equals(path); - } - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.AffordanceModel#getMediaType() - */ - @Override - public Collection getMediaTypes() { - return Collections.singleton(MediaTypes.HAL_FORMS_JSON); - } - - /** - * Based on the Spring MVC controller's HTTP method, decided whether or not input attributes are required or not. - * - * @param httpMethod - string representation of an HTTP method, e.g. GET, POST, etc. - * @return - */ - private boolean determineRequired(HttpMethod httpMethod) { - return Arrays.asList(HttpMethod.POST, HttpMethod.PUT).contains(httpMethod); - } - - /** - * Look at the inputs for a Spring MVC controller method to decide the {@link Affordance}'s properties. - */ - private List determineAffordanceInputs() { - - return this.affordance.getInputMethodParameters().stream() - .findFirst() - .map(methodParameter -> { - ResolvableType resolvableType = ResolvableType.forMethodParameter(methodParameter); - return PropertyUtils.findProperties(resolvableType); - }) - .orElse(Collections.emptyList()); + if (ENTITY_ALTERING_METHODS.contains(getHttpMethod())) { + + return PropertyUtils.findPropertyNames(getInputType()).stream() + .map(propertyName -> new HalFormsProperty() + .withName(propertyName) + .withRequired(Arrays.asList(HttpMethod.POST, HttpMethod.PUT).contains(getHttpMethod()))) + .collect(Collectors.toList()); + + } else { + return Collections.emptyList(); + } } } diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModelFactory.java index 061bf5cd..d6e996c3 100644 --- a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModelFactory.java +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModelFactory.java @@ -17,13 +17,16 @@ package org.springframework.hateoas.hal.forms; import lombok.Getter; -import org.springframework.hateoas.Affordance; +import java.util.List; + +import org.springframework.core.ResolvableType; import org.springframework.hateoas.AffordanceModel; +import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.QueryParameter; import org.springframework.hateoas.core.AffordanceModelFactory; -import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; -import org.springframework.web.util.UriComponents; /** * Factory for creating {@link HalFormsAffordanceModel}s. @@ -35,13 +38,8 @@ class HalFormsAffordanceModelFactory implements AffordanceModelFactory { private final @Getter MediaType mediaType = MediaTypes.HAL_FORMS_JSON; - /* - * (non-Javadoc) - * @see org.springframework.hateoas.AffordanceModelFactory#getAffordanceModel(org.springframework.hateoas.Affordance, org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation, org.springframework.web.util.UriComponents) - */ @Override - public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, - UriComponents components) { - return new HalFormsAffordanceModel(affordance, components); + public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { + return new HalFormsAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType); } } diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsProperty.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsProperty.java index f5992e03..86f1fd45 100644 --- a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsProperty.java +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsProperty.java @@ -22,8 +22,6 @@ import lombok.NonNull; import lombok.Value; import lombok.experimental.Wither; -import org.springframework.util.Assert; - import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -37,28 +35,26 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; @Value @Wither @AllArgsConstructor(access = AccessLevel.PRIVATE) -@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE) -public class HalFormsProperty { +@NoArgsConstructor(force = true) +class HalFormsProperty { - private @Wither(AccessLevel.PRIVATE) @NonNull String name; + private @NonNull String name; private Boolean readOnly; private String value; private String prompt; private String regex; private boolean templated; - private @JsonInclude(Include.ALWAYS) boolean required; + private @JsonInclude boolean required; private boolean multi; /** * Creates a new {@link HalFormsProperty} with the given name. * - * @param name must not be {@literal null} or empty. + * @param name must not be {@literal null}. * @return */ + public static HalFormsProperty named(String name) { - - Assert.hasText(name, "Property name must not be null or empty!"); - return new HalFormsProperty().withName(name); } } diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsSerializers.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsSerializers.java index c25f2365..5b40fac1 100644 --- a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsSerializers.java +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsSerializers.java @@ -73,7 +73,9 @@ class HalFormsSerializers { .withLinks(value.getLinks()) // .withTemplates(findTemplates(value)); - provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider); + provider + .findValueSerializer(HalFormsDocument.class, property) + .serialize(doc, gen, provider); } @Override @@ -148,7 +150,9 @@ class HalFormsSerializers { .withTemplates(findTemplates(value)); } - provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider); + provider + .findValueSerializer(HalFormsDocument.class, property) + .serialize(doc, gen, provider); } @Override @@ -186,25 +190,27 @@ class HalFormsSerializers { */ private static Map findTemplates(ResourceSupport resource) { - Map templates = new HashMap(); + Map templates = new HashMap<>(); if (resource.hasLink(Link.REL_SELF)) { + + for (Affordance affordance : resource.getLink(Link.REL_SELF).map(Link::getAffordances) .orElse(Collections.emptyList())) { HalFormsAffordanceModel model = affordance.getAffordanceModel(MediaTypes.HAL_FORMS_JSON); - if (!affordance.getHttpMethod().equals(HttpMethod.GET)) { + if (!(model.getHttpMethod() == HttpMethod.GET)) { validate(resource, affordance, model); - HalFormsTemplate template = HalFormsTemplate.forMethod(affordance.getHttpMethod()) // - .withProperties(model.getProperties()); + HalFormsTemplate template = HalFormsTemplate.forMethod(model.getHttpMethod()) // + .withProperties(model.getInputProperties()); /** * First template in HAL-FORMS is "default". */ - templates.put(templates.isEmpty() ? "default" : affordance.getName(), template); + templates.put(templates.isEmpty() ? "default" : model.getName(), template); } } } @@ -222,7 +228,7 @@ class HalFormsSerializers { private static void validate(ResourceSupport resource, Affordance affordance, HalFormsAffordanceModel model) { String affordanceUri = model.getURI(); - String selfLinkUri = resource.getRequiredLink(Link.REL_SELF).getHref(); + String selfLinkUri = resource.getRequiredLink(Link.REL_SELF).expand().getHref(); if (!affordanceUri.equals(selfLinkUri)) { throw new IllegalStateException("Affordance's URI " + affordanceUri + " doesn't match self link " diff --git a/src/main/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsModule.java b/src/main/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsModule.java index 3757821c..cc81534f 100644 --- a/src/main/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsModule.java +++ b/src/main/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsModule.java @@ -24,7 +24,6 @@ import org.springframework.context.support.MessageSourceAccessor; import org.springframework.hateoas.Link; import org.springframework.hateoas.PagedResources; import org.springframework.hateoas.RelProvider; -import org.springframework.hateoas.Resource; import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.Resources; import org.springframework.hateoas.hal.CurieProvider; @@ -74,10 +73,12 @@ public class Jackson2HalFormsModule extends SimpleModule { setMixInAnnotation(Link.class, LinkMixin.class); setMixInAnnotation(ResourceSupport.class, ResourceSupportMixin.class); - setMixInAnnotation(Resource.class, ResourceMixin.class); setMixInAnnotation(Resources.class, ResourcesMixin.class); setMixInAnnotation(PagedResources.class, PagedResourcesMixin.class); setMixInAnnotation(MediaType.class, MediaTypeMixin.class); + + addSerializer(new HalFormsResourceSerializer()); + } @JsonSerialize(using = HalFormsResourceSerializer.class) @@ -105,7 +106,7 @@ public class Jackson2HalFormsModule extends SimpleModule { @JsonSerialize(using = ToStringSerializer.class) @JsonDeserialize(using = MediaTypeDeserializer.class) - static interface MediaTypeMixin {} + interface MediaTypeMixin {} /** * Create new HAL-FORMS serializers based on the context. diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java index f5269ea7..79bfe744 100755 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java @@ -72,7 +72,6 @@ public class ControllerLinkBuilder extends LinkBuilderSupport MODEL_FACTORIES = OrderAwarePluginRegistry .create(factories); - AFFORDANCE_BUILDER = new SpringMvcAffordanceBuilder(MODEL_FACTORIES); } private final TemplateVariables variables; @@ -357,15 +355,14 @@ public class ControllerLinkBuilder extends LinkBuilderSupport findAffordances(MethodInvocation invocation, UriComponents components) { - return AFFORDANCE_BUILDER.create(invocation, DISCOVERER, components); + return SpringMvcAffordanceBuilder.create(invocation, DISCOVERER, components); } @RequiredArgsConstructor @@ -377,7 +374,6 @@ public class ControllerLinkBuilder extends LinkBuilderSupport type, Method method) { String mapping = delegate.getMapping(type, method); - return templates.computeIfAbsent(mapping, UriTemplate::new); } } diff --git a/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordance.java b/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordance.java deleted file mode 100644 index 7964b2be..00000000 --- a/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordance.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2017 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.mvc; - -import lombok.AccessLevel; -import lombok.RequiredArgsConstructor; -import lombok.Value; - -import java.lang.reflect.Method; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.core.MethodParameter; -import org.springframework.hateoas.Affordance; -import org.springframework.hateoas.AffordanceModel; -import org.springframework.hateoas.QueryParameter; -import org.springframework.hateoas.core.MethodParameters; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.util.Assert; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RequestParam; - -/** - * Spring MVC-based representation of an {@link Affordance}. - * - * @author Greg Turnquist - */ -@Value -@RequiredArgsConstructor(access = AccessLevel.PRIVATE) -class SpringMvcAffordance implements Affordance { - - /** - * Request method verb associated with the Spring MVC controller method. - */ - private final HttpMethod httpMethod; - - /** - * Handle on the Spring MVC controller {@link Method}. - */ - private final Method method; - private final Map affordanceModels; - - /** - * Construct a Spring MVC-based {@link Affordance} based on Spring MVC controller method and {@link RequestMethod}. - */ - public SpringMvcAffordance(HttpMethod httpMethod, Method method) { - this(httpMethod, method, new HashMap()); - } - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.Affordance#getName() - */ - @Override - public String getName() { - return this.method.getName(); - } - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.Affordance#getAffordanceModel(org.springframework.http.MediaType) - */ - @Override - @SuppressWarnings("unchecked") - public T getAffordanceModel(MediaType mediaType) { - return (T) this.affordanceModels.get(mediaType); - } - - /** - * Adds the given {@link AffordanceModel} to the {@link Affordance}. - * - * @param affordanceModel must not be {@literal null}. - */ - public void addAffordanceModel(AffordanceModel affordanceModel) { - - Assert.notNull(affordanceModel, "Affordance model must not be null!"); - - for (MediaType mediaType : affordanceModel.getMediaTypes()) { - this.affordanceModels.put(mediaType, affordanceModel); - } - } - - /** - * Get a listing of {@link MethodParameter}s based on Spring MVC's {@link RequestBody}s. - * - * @return - */ - @Override - public List getInputMethodParameters() { - return new MethodParameters(this.method).getParametersWith(RequestBody.class); - } - - /** - * Get a listing of {@link MethodParameter}s based on Spring MVC's {@link RequestParam}s. - * - * @return - */ - @Override - public List getQueryMethodParameters() { - - MethodParameters parameters = new MethodParameters(this.method); - - return parameters.getParametersWith(RequestParam.class).stream() - .map(methodParameter -> methodParameter.getParameterAnnotation(RequestParam.class)) - .map(requestParam -> new QueryParameter(requestParam.name(), requestParam.required(), requestParam.value())) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java b/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java index 9984fc8f..5a7fd7ef 100644 --- a/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java +++ b/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java @@ -15,33 +15,31 @@ */ package org.springframework.hateoas.mvc; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; - -import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.stream.Collectors; +import org.springframework.core.ResolvableType; import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.QueryParameter; import org.springframework.hateoas.core.AffordanceModelFactory; import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; import org.springframework.hateoas.core.MappingDiscoverer; +import org.springframework.hateoas.core.MethodParameters; import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.plugin.core.PluginRegistry; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.util.UriComponents; /** - * Construct {@link SpringMvcAffordance}s using a collection of {@link AffordanceModelFactory}s. + * Extract information needed to assemble an {@link Affordance} from a Spring MVC web method. * * @author Greg Turnquist */ -@RequiredArgsConstructor class SpringMvcAffordanceBuilder { - private final @NonNull PluginRegistry factories; - /** * Use the attributes of the current method call along with a collection of {@link AffordanceModelFactory}'s to create * a set of {@link Affordance}s. @@ -51,21 +49,33 @@ class SpringMvcAffordanceBuilder { * @param components * @return */ - public Collection create(MethodInvocation invocation, MappingDiscoverer discoverer, + public static Collection create(MethodInvocation invocation, MappingDiscoverer discoverer, UriComponents components) { - Method method = invocation.getMethod(); - List affordances = new ArrayList(); + List affordances = new ArrayList<>(); - for (HttpMethod requestMethod : discoverer.getRequestMethod(invocation.getTargetType(), method)) { + for (HttpMethod requestMethod : discoverer.getRequestMethod(invocation.getTargetType(), invocation.getMethod())) { - SpringMvcAffordance affordance = new SpringMvcAffordance(requestMethod, invocation.getMethod()); + String methodName = invocation.getMethod().getName(); - for (AffordanceModelFactory factory : factories) { - affordance.addAffordanceModel(factory.getAffordanceModel(affordance, invocation, components)); - } + Link affordanceLink = new Link(components.toUriString()).withRel(methodName); + + MethodParameters invocationMethodParameters = new MethodParameters(invocation.getMethod()); + + ResolvableType inputType = invocationMethodParameters.getParametersWith(RequestBody.class).stream() + .findFirst() + .map(ResolvableType::forMethodParameter) + .orElse(ResolvableType.NONE); + + List queryMethodParameters = invocationMethodParameters.getParametersWith(RequestParam.class).stream() + .map(methodParameter -> methodParameter.getParameterAnnotation(RequestParam.class)) + .map(requestParam -> new QueryParameter(requestParam.name(), requestParam.value(), requestParam.required())) + .collect(Collectors.toList()); + + ResolvableType outputType = ResolvableType.forMethodReturnType(invocation.getMethod()); + + affordances.add(new Affordance(methodName, affordanceLink, requestMethod, inputType, queryMethodParameters, outputType)); - affordances.add(affordance); } return affordances; diff --git a/src/main/java/org/springframework/hateoas/mvc/TypeConstrainedMappingJackson2HttpMessageConverter.java b/src/main/java/org/springframework/hateoas/mvc/TypeConstrainedMappingJackson2HttpMessageConverter.java index 1f1e9ba4..39ef1ef5 100644 --- a/src/main/java/org/springframework/hateoas/mvc/TypeConstrainedMappingJackson2HttpMessageConverter.java +++ b/src/main/java/org/springframework/hateoas/mvc/TypeConstrainedMappingJackson2HttpMessageConverter.java @@ -16,6 +16,7 @@ package org.springframework.hateoas.mvc; import java.lang.reflect.Type; +import java.util.List; import org.springframework.http.MediaType; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; @@ -45,6 +46,20 @@ public class TypeConstrainedMappingJackson2HttpMessageConverter extends MappingJ this.type = type; } + /** + * Convenience constructor to supply all parameters at once. + * + * @param type + * @param supportedMediaTypes + * @param objectMapper + */ + public TypeConstrainedMappingJackson2HttpMessageConverter(Class type, List supportedMediaTypes, ObjectMapper objectMapper) { + + this(type); + setSupportedMediaTypes(supportedMediaTypes); + setObjectMapper(objectMapper); + } + /* * (non-Javadoc) * @see org.springframework.http.converter.json.MappingJackson2HttpMessageConverter#canRead(java.lang.Class, org.springframework.http.MediaType) diff --git a/src/main/java/org/springframework/hateoas/support/PropertyUtils.java b/src/main/java/org/springframework/hateoas/support/PropertyUtils.java index 8bbba4d7..c4bd06ce 100644 --- a/src/main/java/org/springframework/hateoas/support/PropertyUtils.java +++ b/src/main/java/org/springframework/hateoas/support/PropertyUtils.java @@ -22,31 +22,34 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.springframework.beans.BeanUtils; -import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.hateoas.Resource; -import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; /** * @author Greg Turnquist */ public class PropertyUtils { - private final static HashSet FIELDS_TO_IGNORE = new HashSet() {{ - add("class"); - add("links"); - }}; + private final static HashSet FIELDS_TO_IGNORE = new HashSet<>(); + + static { + FIELDS_TO_IGNORE.add("class"); + FIELDS_TO_IGNORE.add("links"); + }; public static Map findProperties(Object object) { @@ -54,36 +57,30 @@ public class PropertyUtils { return findProperties(((Resource) object).getContent()); } - return Arrays.asList(BeanUtils.getPropertyDescriptors(object.getClass())).stream() - .filter(descriptor -> !FIELDS_TO_IGNORE.contains(descriptor.getName())) - .filter(descriptor -> hasJsonIgnoreOnTheField(object.getClass(), descriptor)) - .filter(PropertyUtils::hasJsonIgnoreOnTheReader) - .collect(Collectors.toMap( - FeatureDescriptor::getName, - descriptor -> { + return getPropertyDescriptors(object.getClass()) + .collect(HashMap::new, + (hashMap, descriptor) -> { try { - return descriptor.getReadMethod().invoke(object); + hashMap.put(descriptor.getName(), descriptor.getReadMethod().invoke(object)); } catch (IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } - })); + }, + HashMap::putAll); } - - public static List findProperties(ResolvableType resolvableType) { + + public static List findPropertyNames(ResolvableType resolvableType) { if (resolvableType.getRawClass().equals(Resource.class)) { - return findProperties(resolvableType.resolveGeneric(0)); + return findPropertyNames(resolvableType.resolveGeneric(0)); } else { - return findProperties(resolvableType.getRawClass()); + return findPropertyNames(resolvableType.getRawClass()); } } - public static List findProperties(Class clazz) { + public static List findPropertyNames(Class clazz) { - return Arrays.asList(BeanUtils.getPropertyDescriptors(clazz)).stream() - .filter(descriptor -> !FIELDS_TO_IGNORE.contains(descriptor.getName())) - .filter(descriptor -> hasJsonIgnoreOnTheField(clazz, descriptor)) - .filter(PropertyUtils::hasJsonIgnoreOnTheReader) + return getPropertyDescriptors(clazz) .map(FeatureDescriptor::getName) .collect(Collectors.toList()); } @@ -92,13 +89,13 @@ public class PropertyUtils { Object obj = BeanUtils.instantiateClass(clazz); - properties.entrySet().stream().forEach(entry -> { - Optional possibleProperty = Optional.ofNullable(BeanUtils.getPropertyDescriptor(clazz, entry.getKey())); + properties.forEach((key, value) -> { + Optional possibleProperty = Optional.ofNullable(BeanUtils.getPropertyDescriptor(clazz, key)); possibleProperty.ifPresent(property -> { try { Method writeMethod = property.getWriteMethod(); ReflectionUtils.makeAccessible(writeMethod); - writeMethod.invoke(obj, entry.getValue()); + writeMethod.invoke(obj, value); } catch (IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } @@ -108,29 +105,43 @@ public class PropertyUtils { return obj; } + /** + * Take a {@link Class} and find all properties that are NOT to be ignored, and return them as a {@link Stream}. + * + * @param clazz + * @return + */ + private static Stream getPropertyDescriptors(Class clazz) { + + return Arrays.stream(BeanUtils.getPropertyDescriptors(clazz)) + .filter(descriptor -> !FIELDS_TO_IGNORE.contains(descriptor.getName())) + .filter(descriptor -> !descriptorToBeIgnoredByJackson(clazz, descriptor)) + .filter(descriptor -> !toBeIgnoredByJackson(clazz, descriptor.getName())) + .filter(descriptor -> !readerIsNotToBeIgnoredByJackson(descriptor)); + } + /** * Check if a given {@link PropertyDescriptor} has {@link JsonIgnore} applied to the field declaration. * - * @param object + * @param clazz * @param descriptor * @return */ - private static boolean hasJsonIgnoreOnTheField(Class clazz, PropertyDescriptor descriptor) { + private static boolean descriptorToBeIgnoredByJackson(Class clazz, PropertyDescriptor descriptor) { Field descriptorField = ReflectionUtils.findField(clazz, descriptor.getName()); - return isToBeIgnored(AnnotationUtils.getAnnotations(descriptorField)); + return toBeIgnoredByJackson(AnnotationUtils.getAnnotations(descriptorField)); } /** * Check if a given {@link PropertyDescriptor} has {@link JsonIgnore} on the getter. * - * @param object * @param descriptor * @return */ - private static boolean hasJsonIgnoreOnTheReader(PropertyDescriptor descriptor) { - return isToBeIgnored(AnnotationUtils.getAnnotations(descriptor.getReadMethod())); + private static boolean readerIsNotToBeIgnoredByJackson(PropertyDescriptor descriptor) { + return toBeIgnoredByJackson(AnnotationUtils.getAnnotations(descriptor.getReadMethod())); } /** @@ -139,17 +150,40 @@ public class PropertyUtils { * @param annotations * @return */ - private static boolean isToBeIgnored(Annotation[] annotations) { + private static boolean toBeIgnoredByJackson(Annotation[] annotations) { if (annotations != null) { for (Annotation annotation : annotations) { if (annotation.annotationType().equals(JsonIgnore.class)) { - return !(Boolean) AnnotationUtils.getAnnotationAttributes(annotation).get("value"); + return (Boolean) AnnotationUtils.getAnnotationAttributes(annotation).get("value"); } } } - return true; + return false; + } + + /** + * Check if a field name is to be ignored due to {@link JsonIgnoreProperties}. + * + * @param clazz + * @param field + * @return + */ + private static boolean toBeIgnoredByJackson(Class clazz, String field) { + + for (Annotation annotation : AnnotationUtils.getAnnotations(clazz)) { + if (annotation.annotationType().equals(JsonIgnoreProperties.class)) { + String[] namesOfPropertiesToIgnore = (String[]) AnnotationUtils.getAnnotationAttributes(annotation).get("value"); + for (String propertyToIgnore : namesOfPropertiesToIgnore) { + if (propertyToIgnore.equalsIgnoreCase(field)) { + return true; + } + } + } + } + + return false; } } diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java index 2d99c900..887484aa 100755 --- a/src/test/java/org/springframework/hateoas/LinkUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinkUnitTest.java @@ -20,11 +20,15 @@ import static org.assertj.core.api.SoftAssertions.*; import java.io.IOException; import java.io.ObjectOutputStream; +import java.util.Collections; import java.util.List; +import java.util.Optional; import org.apache.commons.io.output.ByteArrayOutputStream; import org.junit.Test; import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.hateoas.support.Employee; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -37,6 +41,8 @@ import org.springframework.http.MediaType; */ public class LinkUnitTest { + private static final Affordance TEST_AFFORDANCE = new Affordance(null, null, HttpMethod.GET, null, Collections.emptyList(), null); + @Test public void linkWithHrefOnlyBecomesSelfLink() { @@ -259,8 +265,8 @@ public class LinkUnitTest { public void linkWithAffordancesShouldWorkProperly() { Link originalLink = new Link("/foo"); - Link linkWithAffordance = originalLink.andAffordance(new TestAffordance()); - Link linkWithTwoAffordances = linkWithAffordance.andAffordance(new TestAffordance()); + Link linkWithAffordance = originalLink.andAffordance(TEST_AFFORDANCE); + Link linkWithTwoAffordances = linkWithAffordance.andAffordance(TEST_AFFORDANCE); assertSoftly(softly -> { @@ -300,43 +306,67 @@ public class LinkUnitTest { assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> link.hasRel("")); } - static class TestAffordance implements Affordance { + @Test + public void affordanceConvenienceMethodChainsExistingLink() { - /* - * (non-Javadoc) - * @see org.springframework.hateoas.Affordance#getAffordanceModel(org.springframework.http.MediaType) - */ - @Override - public T getAffordanceModel(MediaType mediaType) { - return null; - } + Link link = new Link("/").andAffordance("name", HttpMethod.POST, ResolvableType.forClass(Employee.class), Collections.emptyList(), ResolvableType.forClass(Employee.class)); - /* - * (non-Javadoc) - * @see org.springframework.hateoas.Affordance#getHttpMethod() - */ - @Override - public HttpMethod getHttpMethod() { - return HttpMethod.PATCH; - } - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.Affordance#getName() - */ - @Override - public String getName() { - return null; - } - - @Override - public List getInputMethodParameters() { - return null; - } - - @Override - public List getQueryMethodParameters() { - return null; - } + assertThat(link.getHref()).isEqualTo("/"); + assertThat(link.getRel()).isEqualTo(Link.REL_SELF); + assertThat(link.getAffordances()).hasSize(1); + assertThat(link.getAffordances().get(0).getAffordanceModels()).hasSize(2); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getName()).isEqualTo("name"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getOutputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getName()).isEqualTo("name"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getOutputType().resolve()).isEqualTo(Employee.class); } + + @Test + public void affordanceConvenienceMethodDefaultsNameBasedOnHttpVerb() { + + Link link = new Link("/").andAffordance(HttpMethod.POST, ResolvableType.forClass(Employee.class), Collections.emptyList(), ResolvableType.forClass(Employee.class)); + + assertThat(link.getHref()).isEqualTo("/"); + assertThat(link.getRel()).isEqualTo(Link.REL_SELF); + assertThat(link.getAffordances()).hasSize(1); + assertThat(link.getAffordances().get(0).getAffordanceModels()).hasSize(2); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getName()).isEqualTo("postEmployee"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getOutputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getName()).isEqualTo("postEmployee"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getOutputType().resolve()).isEqualTo(Employee.class); + } + + @Test + public void affordanceConvenienceMethodHandlesBareClasses() { + + Link link = new Link("/").andAffordance(HttpMethod.POST, Employee.class, Collections.emptyList(), Employee.class); + + assertThat(link.getHref()).isEqualTo("/"); + assertThat(link.getRel()).isEqualTo(Link.REL_SELF); + assertThat(link.getAffordances()).hasSize(1); + assertThat(link.getAffordances().get(0).getAffordanceModels()).hasSize(2); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getName()).isEqualTo("postEmployee"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getOutputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getName()).isEqualTo("postEmployee"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getOutputType().resolve()).isEqualTo(Employee.class); + } + } diff --git a/src/test/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilderUnitTest.java index be3ebdeb..fb9ce723 100644 --- a/src/test/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilderUnitTest.java @@ -18,17 +18,19 @@ package org.springframework.hateoas.mvc; import static org.assertj.core.api.Assertions.*; import java.util.Arrays; +import java.util.List; import org.junit.Test; +import org.springframework.core.ResolvableType; import org.springframework.core.annotation.Order; -import org.springframework.hateoas.Affordance; import org.springframework.hateoas.AffordanceModel; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.QueryParameter; import org.springframework.hateoas.core.AffordanceModelFactory; -import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.plugin.core.OrderAwarePluginRegistry; import org.springframework.plugin.core.PluginRegistry; -import org.springframework.web.util.UriComponents; /** * @author Greg Turnquist @@ -36,11 +38,6 @@ import org.springframework.web.util.UriComponents; */ public class SpringMvcAffordanceBuilderUnitTest { - @Test(expected = IllegalArgumentException.class) - public void rejectsNullPluginRegistry() { - new SpringMvcAffordanceBuilder(null); - } - @Test public void favorsCustomLinkDiscovererOverDefault() { @@ -57,8 +54,7 @@ public class SpringMvcAffordanceBuilderUnitTest { static class LowPriorityModelFactory implements AffordanceModelFactory { @Override - public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, - UriComponents components) { + public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { return null; } @@ -72,8 +68,7 @@ public class SpringMvcAffordanceBuilderUnitTest { static class HighPriorityModelFactory implements AffordanceModelFactory { @Override - public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, - UriComponents components) { + public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { return null; } diff --git a/src/test/java/org/springframework/hateoas/support/PropertyUtilsTest.java b/src/test/java/org/springframework/hateoas/support/PropertyUtilsTest.java index 82d675c7..25dc35a2 100644 --- a/src/test/java/org/springframework/hateoas/support/PropertyUtilsTest.java +++ b/src/test/java/org/springframework/hateoas/support/PropertyUtilsTest.java @@ -35,6 +35,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; /** * @author Greg Turnquist @@ -79,16 +80,16 @@ public class PropertyUtilsTest { .map(methodParameter -> ResolvableType.forMethodParameter(methodParameter.getMethod(), methodParameter.getParameterIndex())) .orElseThrow(() -> new RuntimeException("Didn't find a parameter annotated with @RequestBody!")); - List properties = PropertyUtils.findProperties(resolvableType); + List propertyNames = PropertyUtils.findPropertyNames(resolvableType); - assertThat(properties).hasSize(2); - assertThat(properties).contains("name", "role"); + assertThat(propertyNames).hasSize(2); + assertThat(propertyNames).contains("name", "role"); } @Test public void objectWithIgnorableAttributes() { - EmployeeWithCustomizedReaders employee = new EmployeeWithCustomizedReaders("Frodo", "Baggins", "ring bearer", "password", "fbaggins"); + EmployeeWithCustomizedReaders employee = new EmployeeWithCustomizedReaders("Frodo", "Baggins", "ring bearer", "password", "fbaggins", "ignore this one"); Map properties = PropertyUtils.findProperties(employee); @@ -103,8 +104,23 @@ public class PropertyUtilsTest { new SimpleEntry<>("usernameAndLastName", "fbaggins+++Baggins")); } + @Test + public void objectWithNullReturningGetter() { + + EmployeeWithNullReturningGetter employee = new EmployeeWithNullReturningGetter("Frodo"); + + Map properties = PropertyUtils.findProperties(employee); + + assertThat(properties).hasSize(2); + assertThat(properties.keySet()).containsExactlyInAnyOrder("name", "father"); + assertThat(properties.entrySet()).containsExactlyInAnyOrder( + new SimpleEntry<>("name", "Frodo"), + new SimpleEntry<>("father", null)); + } + @Data @AllArgsConstructor + @JsonIgnoreProperties({"ignoreThisProperty"}) static class EmployeeWithCustomizedReaders { private String firstName; @@ -112,6 +128,7 @@ public class PropertyUtilsTest { private String role; @JsonIgnore private String password; @JsonIgnore(false) private String username; + private String ignoreThisProperty; public String getFullName() { return this.firstName + " " + this.lastName; @@ -128,6 +145,17 @@ public class PropertyUtilsTest { } } + @Data + static class EmployeeWithNullReturningGetter { + + private String name; + private String father; + + EmployeeWithNullReturningGetter(String name) { + this.name = name; + } + } + @RestController static class TestController { diff --git a/src/test/kotlin/org/springframework/hateoas/AffordanceBuilderDslUnitTest.kt b/src/test/kotlin/org/springframework/hateoas/AffordanceBuilderDslUnitTest.kt index 328781d5..21567901 100644 --- a/src/test/kotlin/org/springframework/hateoas/AffordanceBuilderDslUnitTest.kt +++ b/src/test/kotlin/org/springframework/hateoas/AffordanceBuilderDslUnitTest.kt @@ -37,8 +37,10 @@ class AffordanceBuilderDslUnitTest : TestUtils() { val delete = afford { delete("15") } - assertThat(delete.httpMethod).isEqualTo(HttpMethod.DELETE) - assertThat(delete.name).isEqualTo("delete") + val affordanceModel = delete.getAffordanceModel(MediaTypes.HAL_FORMS_JSON) + + assertThat(affordanceModel.httpMethod).isEqualTo(HttpMethod.DELETE) + assertThat(affordanceModel.name).isEqualTo("delete") } /** @@ -58,20 +60,19 @@ class AffordanceBuilderDslUnitTest : TestUtils() { assertThat(selfWithAffordances.href).isEqualTo("http://localhost/customers/15") assertThat(selfWithAffordances.affordances).hasSize(3) - assertThat(selfWithAffordances.affordances[0].httpMethod).isEqualTo(HttpMethod.GET) - assertThat(selfWithAffordances.affordances[0].name).isEqualTo("findById") + assertThat(selfWithAffordances.affordances[0].getAffordanceModel(MediaTypes.HAL_FORMS_JSON).httpMethod).isEqualTo(HttpMethod.GET) + assertThat(selfWithAffordances.affordances[0].getAffordanceModel(MediaTypes.HAL_FORMS_JSON).name).isEqualTo("findById") - assertThat(selfWithAffordances.affordances[1].httpMethod).isEqualTo(HttpMethod.PUT) - assertThat(selfWithAffordances.affordances[1].name).isEqualTo("update") + assertThat(selfWithAffordances.affordances[1].getAffordanceModel(MediaTypes.HAL_FORMS_JSON).httpMethod).isEqualTo(HttpMethod.PUT) + assertThat(selfWithAffordances.affordances[1].getAffordanceModel(MediaTypes.HAL_FORMS_JSON).name).isEqualTo("update") - assertThat(selfWithAffordances.affordances[2].httpMethod).isEqualTo(HttpMethod.DELETE) - assertThat(selfWithAffordances.affordances[2].name).isEqualTo("delete") + assertThat(selfWithAffordances.affordances[2].getAffordanceModel(MediaTypes.HAL_FORMS_JSON).httpMethod).isEqualTo(HttpMethod.DELETE) + assertThat(selfWithAffordances.affordances[2].getAffordanceModel(MediaTypes.HAL_FORMS_JSON).name).isEqualTo("delete") assertThat(selfWithAffordances.hashCode()).isNotEqualTo(self.hashCode()) assertThat(selfWithAffordances).isNotEqualTo(self) } - data class Customer(val id: String, val name: String) data class CustomerDTO(val name: String) open class CustomerResource(val id: String, val name: String) : ResourceSupport() open class ProductResource(val id: String) : ResourceSupport()