diff --git a/src/main/java/org/springframework/hateoas/MediaTypes.java b/src/main/java/org/springframework/hateoas/MediaTypes.java index 37bb2994..af1e8751 100644 --- a/src/main/java/org/springframework/hateoas/MediaTypes.java +++ b/src/main/java/org/springframework/hateoas/MediaTypes.java @@ -76,4 +76,14 @@ public class MediaTypes { * Public constant media type for {@code application/vnd.collection+json}. */ public static final MediaType COLLECTION_JSON = MediaType.valueOf(COLLECTION_JSON_VALUE); + + /** + * A String equivalent of {@link MediaTypes#UBER_JSON_VALUE}. + */ + public static final String UBER_JSON_VALUE = "application/vnd.amundsen-uber+json"; + + /** + * Public constant media type for {@code application/vnd.amundsen-uber+json}. + */ + public static final MediaType UBER_JSON = MediaType.parseMediaType(UBER_JSON_VALUE); } diff --git a/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java b/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java index 4098ae80..9dfda066 100644 --- a/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java +++ b/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java @@ -43,6 +43,8 @@ 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.hateoas.uber.Jackson2UberModule; +import org.springframework.hateoas.uber.Jackson2UberModule.UberHandlerInstantiator; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; @@ -96,7 +98,7 @@ public class ConverterRegisteringWebMvcConfigurer implements WebMvcConfigurer, B if (converters.stream() .filter(MappingJackson2HttpMessageConverter.class::isInstance) .map(AbstractJackson2HttpMessageConverter.class::cast) - .map(converter -> converter.getObjectMapper()) + .map(AbstractJackson2HttpMessageConverter::getObjectMapper) .anyMatch(Jackson2HalModule::isAlreadyRegisteredIn)) { return; @@ -124,8 +126,29 @@ public class ConverterRegisteringWebMvcConfigurer implements WebMvcConfigurer, B if (hypermediaTypes.contains(HypermediaType.COLLECTION_JSON)) { converters.add(0, createCollectionJsonConverter(objectMapper, linkRelationMessageSource)); } + + if (hypermediaTypes.contains(HypermediaType.UBER)) { + converters.add(0, createUberJsonConverter(objectMapper)); + } } + /** + * @param objectMapper + * @return + */ + protected MappingJackson2HttpMessageConverter createUberJsonConverter(ObjectMapper objectMapper) { + + ObjectMapper mapper = objectMapper.copy(); + + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.registerModule(new Jackson2UberModule()); + mapper.setHandlerInstantiator(new UberHandlerInstantiator()); + + return new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class, Arrays.asList(UBER_JSON), mapper); + } + + /** * @param objectMapper * @param linkRelationMessageSource diff --git a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java index c947b248..b19e090c 100644 --- a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java +++ b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java @@ -86,7 +86,14 @@ public @interface EnableHypermediaSupport { * * @see http://amundsen.com/media-types/collection/format/ */ - COLLECTION_JSON; + COLLECTION_JSON, + + /** + * UBER Hypermedia + * + * @see http://uberhypermedia.org/ + */ + UBER; private static Set HAL_BASED_MEDIATYPES = EnumSet.of(HAL, HAL_FORMS); diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java index bfbd6b73..006e12cd 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java @@ -37,6 +37,7 @@ import org.springframework.hateoas.collectionjson.CollectionJsonLinkDiscoverer; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; import org.springframework.hateoas.hal.HalLinkDiscoverer; import org.springframework.hateoas.hal.forms.HalFormsLinkDiscoverer; +import org.springframework.hateoas.uber.UberLinkDiscoverer; import org.springframework.util.ClassUtils; /** @@ -96,6 +97,9 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe case COLLECTION_JSON: definition = new RootBeanDefinition(CollectionJsonLinkDiscoverer.class); break; + case UBER: + definition = new RootBeanDefinition(UberLinkDiscoverer.class); + break; default: throw new IllegalStateException(String.format("Unsupported hypermedia type %s!", type)); } diff --git a/src/main/java/org/springframework/hateoas/support/JacksonHelper.java b/src/main/java/org/springframework/hateoas/support/JacksonHelper.java index e1a0afe4..fddb9706 100644 --- a/src/main/java/org/springframework/hateoas/support/JacksonHelper.java +++ b/src/main/java/org/springframework/hateoas/support/JacksonHelper.java @@ -15,6 +15,9 @@ */ package org.springframework.hateoas.support; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.Resources; + import com.fasterxml.jackson.databind.JavaType; /** @@ -36,4 +39,18 @@ public final class JacksonHelper { return contentType; } } + + /** + * Is this a {@literal Resources>}? + * + * @param type + * @return + */ + public static boolean isResourcesOfResource(JavaType type) { + + return + Resources.class.isAssignableFrom(type.getRawClass()) + && + Resource.class.isAssignableFrom(type.containedType(0).getRawClass()); + } } diff --git a/src/main/java/org/springframework/hateoas/support/PropertyUtils.java b/src/main/java/org/springframework/hateoas/support/PropertyUtils.java index c4bd06ce..31f31dd6 100644 --- a/src/main/java/org/springframework/hateoas/support/PropertyUtils.java +++ b/src/main/java/org/springframework/hateoas/support/PropertyUtils.java @@ -61,7 +61,9 @@ public class PropertyUtils { .collect(HashMap::new, (hashMap, descriptor) -> { try { - hashMap.put(descriptor.getName(), descriptor.getReadMethod().invoke(object)); + Method readMethod = descriptor.getReadMethod(); + ReflectionUtils.makeAccessible(readMethod); + hashMap.put(descriptor.getName(), readMethod.invoke(object)); } catch (IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } @@ -85,7 +87,8 @@ public class PropertyUtils { .collect(Collectors.toList()); } - public static Object createObjectFromProperties(Class clazz, Map properties) { + @SuppressWarnings("unchecked") + public static T createObjectFromProperties(Class clazz, Map properties) { Object obj = BeanUtils.instantiateClass(clazz); @@ -102,7 +105,7 @@ public class PropertyUtils { }); }); - return obj; + return (T) obj; } /** diff --git a/src/main/java/org/springframework/hateoas/uber/Jackson2UberModule.java b/src/main/java/org/springframework/hateoas/uber/Jackson2UberModule.java new file mode 100644 index 00000000..d93dd98f --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/Jackson2UberModule.java @@ -0,0 +1,741 @@ +/* + * Copyright 2017-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. + * 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.uber; + +import static org.springframework.hateoas.PagedResources.*; +import static org.springframework.hateoas.support.JacksonHelper.*; +import static org.springframework.hateoas.uber.UberData.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.beans.BeanUtils; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.PagedResources; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.support.JacksonHelper; +import org.springframework.hateoas.support.PropertyUtils; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.cfg.HandlerInstantiator; +import com.fasterxml.jackson.databind.cfg.MapperConfig; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; +import com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.introspect.Annotated; +import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; +import com.fasterxml.jackson.databind.jsontype.TypeResolverBuilder; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.ContainerSerializer; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.type.TypeFactory; + +/** + * Jackson {@link SimpleModule} for {@literal UBER+JSON} serializers and deserializers. + * + * @author Greg Turnquist + * @since 1.0 + */ +public class Jackson2UberModule extends SimpleModule { + + public Jackson2UberModule() { + + super("uber-module", new Version(1, 0, 0, null, "org.springframework.hateoas", "spring-hateoas")); + + setMixInAnnotation(ResourceSupport.class, ResourceSupportMixin.class); + setMixInAnnotation(Resource.class, ResourceMixin.class); + setMixInAnnotation(Resources.class, ResourcesMixin.class); + setMixInAnnotation(PagedResources.class, PagedResourcesMixin.class); + + addSerializer(new UberPagedResourcesSerializer()); + addSerializer(new UberResourcesSerializer()); + addSerializer(new UberResourceSerializer()); + addSerializer(new UberResourceSupportSerializer()); + } + + /** + * Custom {@link JsonSerializer} to render {@link ResourceSupport} into {@literal UBER+JSON}. + */ + static class UberResourceSupportSerializer extends ContainerSerializer implements ContextualSerializer { + + private final BeanProperty property; + + UberResourceSupportSerializer(BeanProperty property) { + + super(ResourceSupport.class, false); + this.property = property; + } + + UberResourceSupportSerializer() { + this(null); + } + + @Override + public void serialize(ResourceSupport value, JsonGenerator gen, SerializerProvider provider) throws IOException { + + UberDocument doc = new UberDocument() + .withUber(new Uber() + .withVersion("1.0") + .withData(extractLinksAndContent(value))); + + provider + .findValueSerializer(UberDocument.class, property) + .serialize(doc, gen, provider); + } + + @Override + public JavaType getContentType() { + return null; + } + + @Override + public JsonSerializer getContentSerializer() { + return null; + } + + @Override + public boolean hasSingleElement(ResourceSupport value) { + return false; + } + + @Override + protected ContainerSerializer _withValueTypeSerializer(TypeSerializer vts) { + return null; + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { + return new UberResourceSupportSerializer(property); + } + } + + /** + * Custom {@link JsonSerializer} to render {@link Resource} into {@literal UBER+JSON}. + */ + static class UberResourceSerializer extends ContainerSerializer> implements ContextualSerializer { + + private final BeanProperty property; + + UberResourceSerializer(BeanProperty property) { + + super(Resource.class, false); + this.property = property; + } + + UberResourceSerializer() { + this(null); + } + + @Override + public void serialize(Resource value, JsonGenerator gen, SerializerProvider provider) throws IOException { + + UberDocument doc = new UberDocument() + .withUber(new Uber() + .withVersion("1.0") + .withData(extractLinksAndContent(value))); + + provider + .findValueSerializer(UberDocument.class, property) + .serialize(doc, gen, provider); + } + + @Override + public JavaType getContentType() { + return null; + } + + @Override + public JsonSerializer getContentSerializer() { + return null; + } + + @Override + public boolean hasSingleElement(Resource value) { + return false; + } + + @Override + protected ContainerSerializer _withValueTypeSerializer(TypeSerializer vts) { + return null; + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { + return new UberResourceSerializer(property); + } + } + + /** + * Custom {@link JsonSerializer} to render {@link Resources} into {@literal UBER+JSON}. + */ + static class UberResourcesSerializer extends ContainerSerializer> implements ContextualSerializer { + + private BeanProperty property; + + UberResourcesSerializer(BeanProperty property) { + + super(Resources.class, false); + this.property = property; + } + + UberResourcesSerializer() { + this(null); + } + + @Override + public void serialize(Resources value, JsonGenerator gen, SerializerProvider provider) throws IOException { + + UberDocument doc = new UberDocument() + .withUber(new Uber() + .withVersion("1.0") + .withData(extractLinksAndContent(value))); + + provider + .findValueSerializer(UberDocument.class, property) + .serialize(doc, gen, provider); + } + + @Override + public JavaType getContentType() { + return null; + } + + @Override + public JsonSerializer getContentSerializer() { + return null; + } + + @Override + public boolean hasSingleElement(Resources value) { + return value.getContent().size() == 1; + } + + @Override + protected ContainerSerializer _withValueTypeSerializer(TypeSerializer vts) { + return null; + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { + return new UberResourcesSerializer(property); + } + } + + /** + * Custom {@link JsonSerializer} to render {@link PagedResources} into {@literal UBER+JSON}. + */ + static class UberPagedResourcesSerializer extends ContainerSerializer> implements ContextualSerializer { + + private BeanProperty property; + + UberPagedResourcesSerializer(BeanProperty property) { + + super(PagedResources.class, false); + this.property = property; + } + + UberPagedResourcesSerializer() { + this(null); + } + + @Override + public void serialize(PagedResources value, JsonGenerator gen, SerializerProvider provider) throws IOException { + + UberDocument doc = new UberDocument() + .withUber(new Uber() + .withVersion("1.0") + .withData(extractLinksAndContent(value))); + + provider + .findValueSerializer(UberDocument.class, property) + .serialize(doc, gen, provider); + } + + @Override + public JavaType getContentType() { + return null; + } + + @Override + public JsonSerializer getContentSerializer() { + return null; + } + + @Override + public boolean hasSingleElement(PagedResources value) { + return value.getContent().size() == 1; + } + + @Override + protected ContainerSerializer _withValueTypeSerializer(TypeSerializer vts) { + return null; + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { + return new UberPagedResourcesSerializer(property); + } + } + + /** + * Custom {@link StdSerializer} to translate {@link UberAction} into the proper JSON representation. + */ + static class UberActionSerializer extends StdSerializer { + + UberActionSerializer() { + super(UberAction.class); + } + + @Override + public void serialize(UberAction value, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeString(value.toString()); + } + } + + /** + * Custom {@link StdDeserializer} to deserialize {@link ResourceSupport}. + */ + static class UberResourceSupportDeserializer extends ContainerDeserializerBase implements ContextualDeserializer { + + private JavaType contentType; + + UberResourceSupportDeserializer(JavaType contentType) { + + super(contentType); + this.contentType = contentType; + } + + UberResourceSupportDeserializer() { + this(TypeFactory.defaultInstance().constructSimpleType(UberDocument.class, new JavaType[0])); + } + + @Override + public ResourceSupport deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + + UberDocument doc = p.getCodec().readValue(p, UberDocument.class); + + return doc.getUber().getData().stream() + .filter(uberData -> !StringUtils.isEmpty(uberData.getName())) + .findFirst() + .map(uberData -> { + + Map properties = uberData.getData().stream() + .collect(Collectors.toMap(UberData::getName, UberData::getValue)); + + ResourceSupport obj = (ResourceSupport) PropertyUtils.createObjectFromProperties(this.getContentType().getRawClass(), properties); + + obj.add(doc.getUber().getLinks()); + + return obj; + }) + .orElseGet(() -> { + + ResourceSupport resourceSupport = new ResourceSupport(); + resourceSupport.add(doc.getUber().getLinks()); + + return resourceSupport; + }); + } + + @Override + public JavaType getContentType() { + return this.contentType; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException { + + if (property != null) { + JavaType vc = property.getType().getContentType(); + return new UberResourceSupportDeserializer(vc); + } else { + return new UberResourceSupportDeserializer(ctxt.getContextualType()); + } + } + + /** + * Accesor for deserializer use for deserializing content values. + */ + @Override + public JsonDeserializer getContentDeserializer() { + return null; + } + } + + /** + * Custom {@link StdDeserializer} to deserialize {@link Resource}. + */ + static class UberResourceDeserializer extends ContainerDeserializerBase> implements ContextualDeserializer { + + private JavaType contentType; + + UberResourceDeserializer(JavaType contentType) { + + super(contentType); + this.contentType = contentType; + } + + UberResourceDeserializer() { + this(TypeFactory.defaultInstance().constructSimpleType(UberDocument.class, new JavaType[0])); + } + + @Override + public Resource deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + + UberDocument doc = p.getCodec().readValue(p, UberDocument.class); + + return doc.getUber().getData().stream() + .filter(uberData -> !StringUtils.isEmpty(uberData.getName())) + .findFirst() + .map(uberData -> { + + List links = doc.getUber().getLinks(); + + // Primitive type + if (uberData.getData().size() == 1 && uberData.getData().get(0).getName() == null) { + Object scalarValue = uberData.getData().get(0).getValue(); + return new Resource<>(scalarValue, links); + } + + Map properties = uberData.getData().stream() + .collect(Collectors.toMap(UberData::getName, UberData::getValue)); + + JavaType rootType = JacksonHelper.findRootType(this.contentType); + + Object value = PropertyUtils.createObjectFromProperties(rootType.getRawClass(), properties); + + return new Resource<>(value, links); + }) + .orElseThrow(() -> new IllegalStateException("No data entry containing a 'value' was found in this document!")); + } + + @Override + public JavaType getContentType() { + return this.contentType; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException { + + if (property != null) { + JavaType vc = property.getType().getContentType(); + return new UberResourceDeserializer(vc); + } else { + return new UberResourceDeserializer(ctxt.getContextualType()); + } + } + + /** + * Accesor for deserializer use for deserializing content values. + */ + @Override + public JsonDeserializer getContentDeserializer() { + return null; + } + } + + /** + * Custom {@link StdDeserializer} to deserialize {@link Resources}. + */ + static class UberResourcesDeserializer extends ContainerDeserializerBase> implements ContextualDeserializer { + + private JavaType contentType; + + UberResourcesDeserializer(JavaType contentType) { + + super(contentType); + this.contentType = contentType; + } + + UberResourcesDeserializer() { + this(TypeFactory.defaultInstance().constructSimpleType(UberDocument.class, new JavaType[0])); + } + + @Override + public Resources deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + + JavaType rootType = JacksonHelper.findRootType(this.contentType); + + UberDocument doc = p.getCodec().readValue(p, UberDocument.class); + + return extractResources(doc, rootType, this.contentType); + } + + /** + * Accessor for declared type of contained value elements; either exact + * type, or one of its supertypes. + */ + @Override + public JavaType getContentType() { + return this.contentType; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException { + + if (property != null) { + JavaType vc = property.getType().getContentType(); + return new UberResourcesDeserializer(vc); + } else { + return new UberResourcesDeserializer(ctxt.getContextualType()); + } + } + + /** + * Accesor for deserializer use for deserializing content values. + */ + @Override + public JsonDeserializer getContentDeserializer() { + return null; + } + } + + /** + * Custom {@link StdDeserializer} to deserialize {@link PagedResources}. + */ + static class UberPagedResourcesDeserializer extends ContainerDeserializerBase> implements ContextualDeserializer { + + private JavaType contentType; + + UberPagedResourcesDeserializer(JavaType contentType) { + + super(contentType); + this.contentType = contentType; + } + + UberPagedResourcesDeserializer() { + this(TypeFactory.defaultInstance().constructSimpleType(UberDocument.class, new JavaType[0])); + } + + @Override + public PagedResources deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + + JavaType rootType = JacksonHelper.findRootType(this.contentType); + + UberDocument doc = p.getCodec().readValue(p, UberDocument.class); + + Resources resources = extractResources(doc, rootType, this.contentType); + PageMetadata pageMetadata = extractPagingMetadata(doc); + + return new PagedResources<>(resources.getContent(), pageMetadata, resources.getLinks()); + } + + /** + * Accessor for declared type of contained value elements; either exact + * type, or one of its supertypes. + */ + @Override + public JavaType getContentType() { + return this.contentType; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException { + + if (property != null) { + JavaType vc = property.getType().getContentType(); + return new UberPagedResourcesDeserializer(vc); + } else { + return new UberPagedResourcesDeserializer(ctxt.getContextualType()); + } + } + + /** + * Accesor for deserializer use for deserializing content values. + */ + @Override + public JsonDeserializer getContentDeserializer() { + return null; + } + } + + /** + * Convert an {@link UberDocument} into a {@link Resources}. + * + * @param doc + * @param rootType + * @param contentType + * @return + */ + private static Resources extractResources(UberDocument doc, JavaType rootType, JavaType contentType) { + + List content = new ArrayList<>(); + + for (UberData uberData : doc.getUber().getData()) { + + if (uberData.getName() != null && uberData.getName().equals("page")) { + continue; + } + + if (uberData.getLinks().isEmpty()) { + + List resourceLinks = new ArrayList<>(); + Resource resource = null; + + for (UberData item : uberData.getData()) { + if (item.getRel() != null) { + item.getRel().forEach(rel -> resourceLinks.add(new Link(item.getUrl(), rel))); + } else { + + // Primitive type + if (item.getData().size() == 1 && item.getData().get(0).getName() == null) { + + Object scalarValue = item.getData().get(0).getValue(); + resource = new Resource<>(scalarValue, uberData.getLinks()); + + } else { + + Map properties = item.getData().stream() + .collect(Collectors.toMap(UberData::getName, UberData::getValue)); + Object obj = PropertyUtils.createObjectFromProperties(rootType.getRawClass(), properties); + resource = new Resource<>(obj, uberData.getLinks()); + } + } + } + + if (resource != null) { + resource.add(resourceLinks); + content.add(resource); + } else { + throw new RuntimeException("No content!"); + } + } + } + + if (isResourcesOfResource(contentType)) { + /* + * Either return a Resources>... + */ + return new Resources<>(content, doc.getUber().getLinks()); + } else { + /* + * ...or return a Resources + */ + + List resourceLessContent = content.stream() + .map(item -> (Resource) item) + .map(Resource::getContent) + .collect(Collectors.toList()); + + return new Resources<>(resourceLessContent, doc.getUber().getLinks()); + } + } + + private static PageMetadata extractPagingMetadata(UberDocument doc) { + + return doc.getUber().getData().stream() + .filter(uberData -> uberData.getName() != null && uberData.getName().equals("page")) + .findFirst() + .map(uberData -> { + int size = 0; + int number = 0; + int totalElements = 0; + int totalPages = 0; + + for (UberData data : uberData.getData()) { + if (data.getName().equals("size")) { + size = (int) data.getValue(); + } + + if (data.getName().equals("number")) { + number = (int) data.getValue(); + } + + if (data.getName().equals("totalElements")) { + totalElements = (int) data.getValue(); + } + + if (data.getName().equals("totalPages")) { + totalPages = (int) data.getValue(); + } + } + + return new PageMetadata(size, number, totalElements, totalPages); + }) + .orElse(null); + } + + /** + * Customer deserializer to handle {@link UberAction}. + */ + static class UberActionDeserializer extends StdDeserializer { + + UberActionDeserializer() { + super(UberAction.class); + } + + @Override + public UberAction deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return UberAction.valueOf(p.getText().toUpperCase()); + } + } + + public static class UberHandlerInstantiator extends HandlerInstantiator { + + private final Map, Object> serializers = new HashMap<>(); + + public UberHandlerInstantiator() { + + this.serializers.put(UberResourceSupportSerializer.class, new UberResourceSupportSerializer()); + this.serializers.put(UberResourceSerializer.class, new UberResourceSerializer()); + this.serializers.put(UberResourcesSerializer.class, new UberResourcesSerializer()); + this.serializers.put(UberPagedResourcesSerializer.class, new UberPagedResourcesSerializer()); + } + + @Override + public JsonDeserializer deserializerInstance(DeserializationConfig config, Annotated annotated, Class deserClass) { + return (JsonDeserializer) findInstance(deserClass); + } + + @Override + public KeyDeserializer keyDeserializerInstance(DeserializationConfig config, Annotated annotated, Class keyDeserClass) { + return (KeyDeserializer) findInstance(keyDeserClass); + } + + @Override + public JsonSerializer serializerInstance(SerializationConfig config, Annotated annotated, Class serClass) { + return (JsonSerializer) findInstance(serClass); + } + + @Override + public TypeResolverBuilder typeResolverBuilderInstance(MapperConfig config, Annotated annotated, Class builderClass) { + return (TypeResolverBuilder) findInstance(builderClass); + } + + @Override + public TypeIdResolver typeIdResolverInstance(MapperConfig config, Annotated annotated, Class resolverClass) { + return (TypeIdResolver) findInstance(resolverClass); + } + + private Object findInstance(Class type) { + + Object result = this.serializers.get(type); + return result != null ? result : BeanUtils.instantiateClass(type); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/PagedResourcesMixin.java b/src/main/java/org/springframework/hateoas/uber/PagedResourcesMixin.java new file mode 100644 index 00000000..8ae58c9b --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/PagedResourcesMixin.java @@ -0,0 +1,32 @@ +/* + * 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. + * 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.uber; + +import org.springframework.hateoas.PagedResources; +import org.springframework.hateoas.uber.Jackson2UberModule.UberPagedResourcesDeserializer; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * Jackson 2 mixin to handle {@link PagedResources}. + * + * @author Greg Turnquist + * @since 1.0 + */ +@JsonDeserialize(using = UberPagedResourcesDeserializer.class) +abstract class PagedResourcesMixin extends PagedResources { + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/ResourceMixin.java b/src/main/java/org/springframework/hateoas/uber/ResourceMixin.java new file mode 100644 index 00000000..476895b8 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/ResourceMixin.java @@ -0,0 +1,32 @@ +/* + * 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. + * 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.uber; + +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.uber.Jackson2UberModule.UberResourceDeserializer; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * Jackson 2 mixin to handle {@link Resource}. + * + * @author Greg Turnquist + * @since 1.0 + */ +@JsonDeserialize(using = UberResourceDeserializer.class) +abstract class ResourceMixin extends Resource { + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/ResourceSupportMixin.java b/src/main/java/org/springframework/hateoas/uber/ResourceSupportMixin.java new file mode 100644 index 00000000..21851e9a --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/ResourceSupportMixin.java @@ -0,0 +1,32 @@ +/* + * 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. + * 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.uber; + +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.uber.Jackson2UberModule.UberResourceSupportDeserializer; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * Jackson 2 mixin to handle {@link ResourceSupport}. + * + * @author Greg Turnquist + * @since 1.0 + */ +@JsonDeserialize(using = UberResourceSupportDeserializer.class) +abstract class ResourceSupportMixin extends ResourceSupport { + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/ResourcesMixin.java b/src/main/java/org/springframework/hateoas/uber/ResourcesMixin.java new file mode 100644 index 00000000..a1ab3a2e --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/ResourcesMixin.java @@ -0,0 +1,32 @@ +/* + * 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. + * 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.uber; + +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.uber.Jackson2UberModule.UberResourcesDeserializer; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * Jackson 2 mixin to handle {@link Resources}. + * + * @author Greg Turnquist + * @since 1.0 + */ +@JsonDeserialize(using = UberResourcesDeserializer.class) +abstract class ResourcesMixin extends Resources { + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/Uber.java b/src/main/java/org/springframework/hateoas/uber/Uber.java new file mode 100644 index 00000000..77560d61 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/Uber.java @@ -0,0 +1,74 @@ +/* + * 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. + * 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.uber; + +import lombok.AccessLevel; +import lombok.Value; +import lombok.experimental.Wither; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.hateoas.Link; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Enclosing collection in an UBER representation. + * + * @author Greg Turnquist + * @since 1.0 + */ +@Value +@Wither(AccessLevel.PACKAGE) +@JsonInclude(Include.NON_NULL) +class Uber { + + private String version; + private List data; + private UberError error; + + @JsonCreator + Uber(@JsonProperty("version") String version, @JsonProperty("data") List data, + @JsonProperty("error") UberError error) { + + this.version = version; + this.data = data; + this.error = error; + } + + Uber() { + this("1.0", null, null); + } + + /** + * Extract rel and url from every {@link UberData} entry. + * + * @return + */ + @JsonIgnore + List getLinks() { + + return this.data.stream() + .flatMap(uberData -> uberData.getLinks().stream()) + .collect(Collectors.toList()); + } + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/UberAction.java b/src/main/java/org/springframework/hateoas/uber/UberAction.java new file mode 100644 index 00000000..dda0e6ff --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/UberAction.java @@ -0,0 +1,107 @@ +/* + * Copyright 2014-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.uber; + +import static org.springframework.hateoas.uber.Jackson2UberModule.*; + +import java.util.Arrays; + +import org.springframework.http.HttpMethod; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +/** + * Embodies possible actions for an UBER representation, mapped onto {@link HttpMethod}s. + * + * @author Dietrich Schulten + * @author Greg Turnquist + * @since 1.0 + */ +@JsonSerialize(using = UberActionSerializer.class) +@JsonDeserialize(using = UberActionDeserializer.class) +enum UberAction { + + /** + * POST + */ + APPEND(HttpMethod.POST), + + /** + * PATCH + */ + PARTIAL(HttpMethod.PATCH), + + /** + * GET + */ + READ(HttpMethod.GET), + + /** + * DELETE + */ + REMOVE(HttpMethod.DELETE), + + /** + * PUT + */ + REPLACE(HttpMethod.PUT); + + private final HttpMethod httpMethod; + + UberAction(HttpMethod method) { + this.httpMethod = method; + } + + /** + * Look up the related Spring Web {@link HttpMethod}. + * + * @return + */ + HttpMethod getMethod() { + return this.httpMethod; + } + + @Override + public String toString() { + return this.name().toLowerCase(); + } + + + /** + * Convert an {@link HttpMethod} into an {@link UberAction}. + * @param method + * @return + */ + static UberAction fromMethod(HttpMethod method) { + + return Arrays.stream(UberAction.values()) + .filter(action -> action.httpMethod == method) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported method: " + method)); + } + + /** + * Maps given request method to uber action. GET will be mapped as {@literal null} since it is the default. + * + * @param method to map + * @return action, or null for GET + */ + static UberAction forRequestMethod(HttpMethod method) { + return HttpMethod.GET == method ? null : fromMethod(method); + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/UberAffordanceModel.java b/src/main/java/org/springframework/hateoas/uber/UberAffordanceModel.java new file mode 100644 index 00000000..ace0a1d7 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/UberAffordanceModel.java @@ -0,0 +1,95 @@ +/* + * 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. + * 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.uber; + +import lombok.Getter; + +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.AffordanceModel; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.QueryParameter; +import org.springframework.hateoas.support.PropertyUtils; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; + +/** + * {@link AffordanceModel} for {@literal UBER+JSON}. + * + * @author Greg Turnquist + * @since 1.0 + */ +class UberAffordanceModel extends AffordanceModel { + + private static final Set ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH); + + private final @Getter Collection mediaTypes = Collections.singleton(MediaTypes.UBER_JSON); + + private final @Getter List inputProperties; + private final @Getter List queryProperties; + + UberAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { + super(name, link, httpMethod, inputType, queryMethodParameters, outputType); + + this.inputProperties = determineAffordanceInputs(); + this.queryProperties = determineQueryProperties(); + } + + private List determineAffordanceInputs() { + + if (ENTITY_ALTERING_METHODS.contains(getHttpMethod())) { + + return PropertyUtils.findPropertyNames(getInputType()).stream() + .map(propertyName -> new UberData() + .withName(propertyName) + .withValue("")) + .collect(Collectors.toList()); + } else { + return Collections.emptyList(); + } + } + + /** + * Transform GET-based query parameters (e.g. {@literal &query}) into a list of {@link UberData} objects. + */ + private List determineQueryProperties() { + + if (!getHttpMethod().equals(HttpMethod.GET)) { + return Collections.emptyList(); + } + + if (getHttpMethod().equals(HttpMethod.GET)) { + return getQueryMethodParameters().stream() + .map(queryParameter -> new UberData() + .withName(queryParameter.getName()) + .withValue("")) + .collect(Collectors.toList()); + } else { + return Collections.emptyList(); + } + } + + UberAction getAction() { + return UberAction.forRequestMethod(getHttpMethod()); + } +} diff --git a/src/main/java/org/springframework/hateoas/uber/UberAffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/uber/UberAffordanceModelFactory.java new file mode 100644 index 00000000..ede551be --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/UberAffordanceModelFactory.java @@ -0,0 +1,45 @@ +/* + * 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. + * 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.uber; + +import lombok.Getter; + +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.http.HttpMethod; +import org.springframework.http.MediaType; + +/** + * {@link AffordanceModelFactory} for {@literal UBER+JSON}. + * + * @author Greg Turnquist + * @since 1.0 + */ +public class UberAffordanceModelFactory implements AffordanceModelFactory { + + private final @Getter MediaType mediaType = MediaTypes.UBER_JSON; + + @Override + public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) { + return new UberAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType); + } +} diff --git a/src/main/java/org/springframework/hateoas/uber/UberData.java b/src/main/java/org/springframework/hateoas/uber/UberData.java new file mode 100644 index 00000000..0a92cd14 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/UberData.java @@ -0,0 +1,456 @@ +/* + * 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. + * 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.uber; + +import static com.fasterxml.jackson.annotation.JsonInclude.*; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.Value; +import lombok.experimental.Wither; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.PagedResources; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.support.PropertyUtils; +import org.springframework.http.HttpMethod; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Core element containing either a {@link Link} or a single property inside an {@link UberDocument}. + * + * @author Greg Turnquist + * @since 1.0 + */ +@Value +@Wither(AccessLevel.PACKAGE) +@JsonInclude(Include.NON_NULL) +class UberData { + + private String id; + private String name; + private String label; + private List rel; + private String url; + private UberAction action; + private boolean transclude; + private String model; + private List sending; + private List accepting; + private Object value; + private List data; + + @JsonCreator + UberData(@JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("label") String label, @JsonProperty("rel") List rel, + @JsonProperty("url") String url, @JsonProperty("action") UberAction action, + @JsonProperty("transclude") boolean transclude, @JsonProperty("model") String model, + @JsonProperty("sending") List sending, @JsonProperty("accepting") List accepting, + @JsonProperty("value") Object value, @JsonProperty("data") List data) { + + this.id = id; + this.name = name; + this.label = label; + this.rel = rel; + this.url = url; + this.action = action; + this.transclude = transclude; + this.model = model; + this.sending = sending; + this.accepting = accepting; + this.value = value; + this.data = data; + } + + UberData() { + this(null, null, null, null, null, UberAction.READ, false, null, null, null, null, null); + } + + /** + * Don't render if it's {@link UberAction#READ}. + */ + public UberAction getAction() { + + if (this.action == UberAction.READ) { + return null; + } + return this.action; + } + + /* + * Don't render if {@literal null}. + */ + public List getRel() { + + if (this.rel == null || this.rel.isEmpty()) { + return null; + } + return this.rel; + } + + /* + * Don't render if {@literal null}. + */ + public List getData() { + + if (this.data == null || this.data.isEmpty()) { + return null; + } + return this.data; + } + + /* + * Use a {@link Boolean} to support returning {@literal null}, and if it is {@literal null}, don't render. + */ + public Boolean isTemplated() { + + return Optional.ofNullable(this.url) + .map(s -> s.contains("{?") ? true : null) + .orElse(null); + } + + public void setTemplated(boolean __) { + // Ignore since "templated" is a virtual property + } + + /* + * Use a {@link Boolean} to support returning {@literal null}, and if it is {@literal null}, don't render. + */ + public Boolean isTransclude() { + return this.transclude ? this.transclude : null; + } + + /** + * Fetch all the links found in this {@link UberData}. + */ + @JsonIgnore + public List getLinks() { + + return Optional.ofNullable(this.rel) + .map(rels -> rels.stream() + .map(rel -> new Link(this.url, rel)) + .collect(Collectors.toList())) + .orElse(Collections.emptyList()); + } + + /** + * Simple scalar types that can be encoded by value, not type. + */ + private final static HashSet> PRIMITIVE_TYPES = new HashSet<>(Arrays.asList( + String.class + )); + + /** + * Set of all Spring HATEOAS resource types. + */ + private static final HashSet> RESOURCE_TYPES = new HashSet<>(Arrays.asList( + ResourceSupport.class, + Resource.class, + Resources.class, + PagedResources.class + )); + + /** + * Convert a {@link ResourceSupport} into a list of {@link UberData}s, containing links and content. + * + * @param resource + * @return + */ + static List extractLinksAndContent(ResourceSupport resource) { + + List data = extractLinks(resource); + + extractContent(resource).ifPresent(data::add); + + return data; + } + + /** + * Convert a {@link Resource} into a list of {@link UberData}s, containing links and content. + * + * @param resource + * @return + */ + static List extractLinksAndContent(Resource resource) { + + List data = extractLinks(resource); + + extractContent(resource.getContent()).ifPresent(data::add); + + return data; + } + + /** + * Convert {@link Resources} into a list of {@link UberData}, with each item nested in a sub-UberData. + * + * @param resources + * @return + */ + static List extractLinksAndContent(Resources resources) { + + List data = extractLinks(resources); + + data.addAll(resources.getContent().stream() + .map(UberData::doExtractLinksAndContent) + .map(uberData -> new UberData().withData(uberData)) + .collect(Collectors.toList())); + + return data; + } + + static List extractLinksAndContent(PagedResources resources) { + + List collectionOfResources = extractLinksAndContent((Resources) resources); + + if (resources.getMetadata() != null ) { + + collectionOfResources.add(new UberData() + .withName("page") + .withData(Arrays.asList( + new UberData() + .withName("number") + .withValue(resources.getMetadata().getNumber()), + new UberData() + .withName("size") + .withValue(resources.getMetadata().getSize()), + new UberData() + .withName("totalElements") + .withValue(resources.getMetadata().getTotalElements()), + new UberData() + .withName("totalPages") + .withValue(resources.getMetadata().getTotalPages())))); + } + + return collectionOfResources; + } + + /** + * Convert a {@link List} of {@link Link}s into a list of {@link UberData}. + * + * @param links + * @return + */ + private static List extractLinks(List links) { + + return urlRelMap(links).entrySet().stream() + .map(entry -> new UberData() + .withUrl(entry.getKey()) + .withRel(entry.getValue().getRels())) + .collect(Collectors.toList()); + } + + /** + * Extract all the direct {@link Link}s and {@link Affordance}-based links from a {@link ResourceSupport}. + * + * @param resource + * @return + */ + private static List extractLinks(ResourceSupport resource) { + + List data = new ArrayList<>(); + + List links = extractLinks(resource.getLinks()); + List affordanceBasedLinks = extractAffordances(resource.getLinks()); + + if (affordanceBasedLinks.isEmpty()) { + data.addAll(links); + } else { + data.addAll(mergeDeclaredLinksIntoAffordanceLinks(affordanceBasedLinks, links)); + } + + return data; + } + + /** + * Convert an object's properties into an {@link UberData}. + * + * @param content + * @return + */ + private static Optional extractContent(Object content) { + + if (!RESOURCE_TYPES.contains(content.getClass())) { + + return Optional.of(new UberData() + .withName(StringUtils.uncapitalize(content.getClass().getSimpleName())) + .withData(extractProperties(content))); + } + + return Optional.empty(); + } + + /** + * Extract links and content from an object of any type. + */ + private static List doExtractLinksAndContent(Object item) { + + if (item instanceof Resource) { + return extractLinksAndContent((Resource) item); + } + + if (item instanceof ResourceSupport) { + return extractLinksAndContent((ResourceSupport) item); + } + + return extractLinksAndContent(new Resource<>(item)); + } + + /** + * Turn a {@list List} of {@link Link}s into a {@link Map}, where you can see ALL the rels of a given + * link. + * + * @param links + * @return a map with links mapping onto a {@link List} of rels + */ + private static Map urlRelMap(List links) { + + Map urlRelMap = new LinkedHashMap<>(); + + links.forEach(link -> { + LinkAndRels linkAndRels = urlRelMap.computeIfAbsent(link.getHref(), s -> new LinkAndRels()); + linkAndRels.setLink(link); + linkAndRels.getRels().add(link.getRel()); + }); + + return urlRelMap; + } + + /** + * Find all the {@link Affordance}s for a set of {@link Link}s, and convert them into {@link UberData}. + * + * @param links + * @return + */ + private static List extractAffordances(List links) { + + return links.stream() + .flatMap(link -> link.getAffordances().stream()) + .map(affordance -> (UberAffordanceModel) affordance.getAffordanceModel(MediaTypes.UBER_JSON)) + .map(model -> { + + if (model.getHttpMethod().equals(HttpMethod.GET)) { + + String suffix = model.getQueryProperties().stream() + .map(UberData::getName) + .collect(Collectors.joining(",")); + + if (!model.getQueryMethodParameters().isEmpty()) { + suffix = "{?" + suffix + "}"; + } + + return new UberData() + .withName(model.getName()) + .withRel(Arrays.asList(model.getName())) + .withUrl(model.getLink().expand().getHref() + suffix) + .withAction(model.getAction()); + + } else { + + return new UberData() + .withName(model.getName()) + .withRel(Arrays.asList(model.getName())) + .withUrl(model.getLink().expand().getHref()) + .withModel(model.getInputProperties().stream() + .map(UberData::getName) + .map(property -> property + "={" + property + "}") + .collect(Collectors.joining("&"))) + .withAction(model.getAction()); + } + }) + .collect(Collectors.toList()); + } + + /** + * Take a list of {@link Affordance}-based {@link Link}s, and overlay them with intersecting, declared {@link Link}s. + * + * @param affordanceBasedLinks + * @param links + * @return + */ + private static List mergeDeclaredLinksIntoAffordanceLinks(List affordanceBasedLinks, List links) { + + return affordanceBasedLinks.stream() + .flatMap(affordance -> links.stream() + .filter(link -> link.getUrl().equals(affordance.getUrl())) + .map(link -> { + + if (link.getAction() == affordance.getAction()) { + + List rels = new ArrayList<>(link.getRel()); + + rels.addAll(affordance.getRel()); + + return affordance + .withName(rels.get(0)) + .withRel(rels); + } else { + return affordance; + } + })) + .collect(Collectors.toList()); + } + + /** + * Transform the payload of a {@link Resource} into {@link UberData}. + * + * @param obj + * @return + */ + private static List extractProperties(Object obj) { + + if (PRIMITIVE_TYPES.contains(obj.getClass())) { + return Arrays.asList(new UberData() + .withValue(obj)); + } + + return PropertyUtils.findProperties(obj).entrySet().stream() + .map(entry -> new UberData() + .withName(entry.getKey()) + .withValue(entry.getValue())) + .collect(Collectors.toList()); + } + + + /** + * Holds both a {@link Link} and related {@literal rels}. + * + */ + @Data + private static class LinkAndRels { + + private Link link; + private List rels = new ArrayList<>(); + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/UberDocument.java b/src/main/java/org/springframework/hateoas/uber/UberDocument.java new file mode 100644 index 00000000..2d42a2fc --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/UberDocument.java @@ -0,0 +1,76 @@ +/* + * 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. + * 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.uber; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.experimental.Wither; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Top-level element in an UBER representation. + * + * @author Greg Turnquist + * @since 1.0 + */ +@Value +@Wither(AccessLevel.PACKAGE) +@RequiredArgsConstructor(access = AccessLevel.PACKAGE) +class UberDocument { + + private Uber uber; + + @JsonCreator + UberDocument(@JsonProperty("version") String version, @JsonProperty("data") List data, + @JsonProperty("error") UberError error) { + this.uber = new Uber(version, data, error); + } + + UberDocument() { + this("1.0", null, null); + } + + /** + * Transform an object into a {@link UberDocument}. + * + * @param object + * @return + */ + static UberDocument toUberDocument(final Object object) { + + if (object == null) { + return null; + } + + if (object instanceof UberDocument) { + return (UberDocument) object; + } + + if (object instanceof Iterable) { + + } else if (object instanceof Map) { + + } + + throw new IllegalArgumentException("Don't know how to handle type : " + object.getClass()); + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/UberError.java b/src/main/java/org/springframework/hateoas/uber/UberError.java new file mode 100644 index 00000000..c092d21d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/UberError.java @@ -0,0 +1,47 @@ +/* + * 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. + * 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.uber; + +import lombok.AccessLevel; +import lombok.Value; +import lombok.experimental.Wither; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * UBER representation of an error. + * + * @author Greg Turnquist + * @since 1.0 + */ +@Value +@Wither(AccessLevel.PACKAGE) +class UberError { + + private List data; + + @JsonCreator + UberError(@JsonProperty("data") List data) { + this.data = data; + } + + UberError() { + this(null); + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/uber/UberLinkDiscoverer.java b/src/main/java/org/springframework/hateoas/uber/UberLinkDiscoverer.java new file mode 100644 index 00000000..f3bb0d88 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/uber/UberLinkDiscoverer.java @@ -0,0 +1,117 @@ +/* + * Copyright 2014-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. + * 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.uber; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkDiscoverer; +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.MediaType; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Find links by rel in an UBER representation. + * + * TODO: Pending https://github.com/json-path/JsonPath/issues/429, replace deserializing solution with JsonPath-based expression "$.uber.data[?(@.rel.indexOf('%s') != -1)].url" + * + * @author Greg Turnquist + * @since 1.0 + */ +public class UberLinkDiscoverer implements LinkDiscoverer { + + private final ObjectMapper mapper; + + UberLinkDiscoverer() { + + this.mapper = new ObjectMapper(); + this.mapper.registerModules(new Jackson2UberModule()); + } + + @Override + public Link findLinkWithRel(String rel, String representation) { + + return getLinks(representation).stream() + .filter(link -> link.getRel().equals(rel)) + .findFirst() + .orElse(null); + } + + @Override + public Link findLinkWithRel(String rel, InputStream representation) { + + return getLinks(representation).stream() + .filter(link -> link.getRel().equals(rel)) + .findFirst() + .orElse(null); + } + + @Override + public List findLinksWithRel(String rel, String representation) { + + return getLinks(representation).stream() + .filter(link -> link.getRel().equals(rel)) + .collect(Collectors.toList()); + } + + @Override + public List findLinksWithRel(String rel, InputStream representation) { + + return getLinks(representation).stream() + .filter(link -> link.getRel().equals(rel)) + .collect(Collectors.toList()); + } + + @Override + public boolean supports(MediaType delimiter) { + return delimiter.isCompatibleWith(MediaTypes.UBER_JSON); + } + + /** + * Deserialize the entire document to find links. + * + * @param json + * @return + */ + private List getLinks(String json) { + + try { + return this.mapper.readValue(json, UberDocument.class).getUber().getLinks(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Deserialize the entire document to find links. + * + * @param stream + * @return + */ + private List getLinks(InputStream stream) { + + try { + return this.mapper.readValue(stream, UberDocument.class).getUber().getLinks(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index fda8327a..bd21abff 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -1 +1 @@ -org.springframework.hateoas.core.AffordanceModelFactory=org.springframework.hateoas.hal.forms.HalFormsAffordanceModelFactory,org.springframework.hateoas.collectionjson.CollectionJsonAffordanceModelFactory +org.springframework.hateoas.core.AffordanceModelFactory=org.springframework.hateoas.hal.forms.HalFormsAffordanceModelFactory,org.springframework.hateoas.collectionjson.CollectionJsonAffordanceModelFactory,org.springframework.hateoas.uber.UberAffordanceModelFactory diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java index 887484aa..32cfb4b0 100755 --- a/src/test/java/org/springframework/hateoas/LinkUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinkUnitTest.java @@ -314,17 +314,25 @@ public class LinkUnitTest { 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).getAffordanceModels()).hasSize(3); + 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); + + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getName()).isEqualTo("name"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getOutputType().resolve()).isEqualTo(Employee.class); } @Test @@ -335,17 +343,25 @@ public class LinkUnitTest { 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).getAffordanceModels()).hasSize(3); + 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); + + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getName()).isEqualTo("postEmployee"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getOutputType().resolve()).isEqualTo(Employee.class); } @Test @@ -356,17 +372,24 @@ public class LinkUnitTest { 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).getAffordanceModels()).hasSize(3); 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); + + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getName()).isEqualTo("postEmployee"); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getInputType().resolve()).isEqualTo(Employee.class); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getQueryMethodParameters()).hasSize(0); + assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getOutputType().resolve()).isEqualTo(Employee.class); } } diff --git a/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java b/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java index 908984f8..c1e37e33 100755 --- a/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java @@ -46,6 +46,7 @@ import org.springframework.hateoas.hal.HalLinkDiscoverer; import org.springframework.hateoas.hal.forms.HalFormsConfiguration; import org.springframework.hateoas.hal.forms.HalFormsLinkDiscoverer; import org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; +import org.springframework.hateoas.uber.UberLinkDiscoverer; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; @@ -87,10 +88,16 @@ public class EnableHypermediaSupportIntegrationTest { assertHalFormsSetupForConfigClass(HalFormsConfig.class); } + @Test public void bootstrapJsonCollectionConfiguration() { assertCollectionJsonSetupForConfigClass(CollectionJsonConfig.class); } + @Test + public void bootstrapUberConfiguration() { + assertUberSetupForConfigClass(UberConfig.class); + } + @Test public void registersHalLinkDiscoverers() { @@ -133,6 +140,19 @@ public class EnableHypermediaSupportIntegrationTest { }); } + @Test + public void registersUberLinkDiscoverers() { + + withContext(UberConfig.class, context -> { + + LinkDiscoverers discoverers = context.getBean(LinkDiscoverers.class); + + assertThat(discoverers).isNotNull(); + assertThat(discoverers.getLinkDiscovererFor(MediaTypes.UBER_JSON)).isInstanceOf(UberLinkDiscoverer.class); + assertRelProvidersSetUp(context); + }); + } + @Test public void bootstrapsHalConfigurationForSubclass() { assertHalSetupForConfigClass(ExtendedHalConfig.class); @@ -148,6 +168,11 @@ public class EnableHypermediaSupportIntegrationTest { assertCollectionJsonSetupForConfigClass(ExtendedCollectionJsonConfig.class); } + @Test + public void bootstrapsUberConfigurationForSubclass() { + assertUberSetupForConfigClass(ExtendedUberConfig.class); + } + /** * @see #134, #219 */ @@ -247,11 +272,46 @@ public class EnableHypermediaSupportIntegrationTest { }); } + @Test + @SuppressWarnings("unchecked") + public void uberSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { + + withContext(UberConfig.class, context -> { + + RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); + + assertThat(adapter.getMessageConverters().get(0).getSupportedMediaTypes()) + .hasSize(1) + .contains(MediaTypes.UBER_JSON); + + boolean found = false; + + for (HandlerMethodArgumentResolver resolver : getResolvers(adapter)) { + + if (resolver instanceof AbstractMessageConverterMethodArgumentResolver) { + + found = true; + + AbstractMessageConverterMethodArgumentResolver processor = (AbstractMessageConverterMethodArgumentResolver) resolver; + List> converters = (List>) ReflectionTestUtils + .getField(processor, "messageConverters"); + + assertThat(converters.get(0)).isInstanceOf(TypeConstrainedMappingJackson2HttpMessageConverter.class); + assertThat(converters.get(0).getSupportedMediaTypes()) + .hasSize(1) + .contains(MediaTypes.UBER_JSON); + } + } + + assertThat(found).isTrue(); + }); + } + /** * @see #293 */ @Test - public void registersHttpMessageConvertersForRestTemplate() { + public void registersHalHttpMessageConvertersForRestTemplate() { withContext(HalConfig.class, context -> { @@ -323,6 +383,18 @@ public class EnableHypermediaSupportIntegrationTest { }); } + @Test + public void registersUberHttpMessageConvertersForRestTemplate() { + + withContext(UberConfig.class, context -> { + RestTemplate template = context.getBean(RestTemplate.class); + + assertThat(template.getMessageConverters().get(0).getSupportedMediaTypes()) + .hasSize(1) + .contains(MediaTypes.UBER_JSON); + }); + } + /** * @see #341 */ @@ -362,6 +434,17 @@ public class EnableHypermediaSupportIntegrationTest { }); } + @Test + public void configuresDefaultObjectMapperForUberToIgnoreUnknownProperties() { + + withContext(UberConfig.class, context -> { + + assertObjectMapper(context, MediaTypes.UBER_JSON, mapper -> { + assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); + }); + }); + } + @Test public void verifyDefaultHalConfigurationRendersSingleItemAsSingleItem() throws JsonProcessingException { @@ -389,7 +472,7 @@ public class EnableHypermediaSupportIntegrationTest { } @Test - public void verifyRenderSingleLinkAsArrayViaOverridingBean() throws JsonProcessingException { + public void verifyRenderSingleLinkAsArrayViaOverridingBean() { withContext(RenderLinkAsSingleLinksConfig.class, context -> { @@ -468,6 +551,18 @@ public class EnableHypermediaSupportIntegrationTest { }); } + private static void assertUberSetupForConfigClass(Class configClass) { + + withContext(configClass, context -> { + + assertEntityLinksSetUp(context); + assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(UberLinkDiscoverer.class); + + RequestMappingHandlerAdapter rmha = context.getBean(RequestMappingHandlerAdapter.class); + assertThat(rmha.getMessageConverters().get(0)).isInstanceOf(MappingJackson2HttpMessageConverter.class); + }); + } + /** * Method to mitigate API changes between Spring 3.2 and 4.0. * @@ -581,4 +676,26 @@ public class EnableHypermediaSupportIntegrationTest { static class AlternateDelegateConfig { } + + @EnableWebMvc + @Configuration + @Import(DelegateUberHypermediaConfig.class) + static class UberConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + } + + @Configuration + static class ExtendedUberConfig extends UberConfig { + + } + + @Configuration + @EnableHypermediaSupport(type = HypermediaType.UBER) + static class DelegateUberHypermediaConfig { + + } } diff --git a/src/test/java/org/springframework/hateoas/mvc/MultiMediatypeWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/mvc/MultiMediatypeWebMvcIntegrationTest.java index 7327a602..9948d37f 100644 --- a/src/test/java/org/springframework/hateoas/mvc/MultiMediatypeWebMvcIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/MultiMediatypeWebMvcIntegrationTest.java @@ -19,6 +19,7 @@ import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.collection.IsCollectionWithSize.*; import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; @@ -270,6 +271,176 @@ public class MultiMediatypeWebMvcIntegrationTest { .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false))); } + @Test + public void singleEmployeeUber() throws Exception { + + this.mockMvc.perform(get("/employees/0").accept(MediaTypes.UBER_JSON)) // + .andDo(print()) + .andExpect(status().isOk()) // + + .andExpect(jsonPath("$.uber.version", is("1.0"))) + + .andExpect(jsonPath("$.uber.data.*", hasSize(5))) + .andExpect(jsonPath("$.uber.data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[1]", is("findOne"))) + .andExpect(jsonPath("$.uber.data[0].url", is("http://localhost/employees/0"))) + + .andExpect(jsonPath("$.uber.data[1].name", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[1].rel[0]", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[1].url", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$.uber.data[1].action", is("replace"))) + .andExpect(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].name", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].rel[0]", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].url", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$.uber.data[2].action", is("partial"))) + .andExpect(jsonPath("$.uber.data[2].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[3].name", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].rel[0]", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[3].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[4].name", is("employee"))) + .andExpect(jsonPath("$.uber.data[4].data.*", hasSize(2))) + .andExpect(jsonPath("$.uber.data[4].data[0].name", is("role"))) + .andExpect(jsonPath("$.uber.data[4].data[0].value", is("ring bearer"))) + .andExpect(jsonPath("$.uber.data[4].data[1].name", is("name"))) + .andExpect(jsonPath("$.uber.data[4].data[1].value", is("Frodo Baggins"))) + ; + } + + @Test + public void collectionOfEmployeesUber() throws Exception { + + this.mockMvc.perform(get("/employees").accept(MediaTypes.UBER_JSON)) // + .andDo(print()) + .andExpect(status().isOk()) // + + .andExpect(jsonPath("$.uber.version", is("1.0"))) + + .andExpect(jsonPath("$.uber.data.*", hasSize(4))) + + .andExpect(jsonPath("$.uber.data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[0].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[1].name", is("newEmployee"))) + .andExpect(jsonPath("$.uber.data[1].rel[0]", is("newEmployee"))) + .andExpect(jsonPath("$.uber.data[1].url", is("http://localhost/employees"))) + .andExpect(jsonPath("$.uber.data[1].action", is("append"))) + .andExpect(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[2].data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[2].data[0].rel[1]", is("findOne"))) + .andExpect(jsonPath("$.uber.data[2].data[0].url", is("http://localhost/employees/0"))) + + .andExpect(jsonPath("$.uber.data[2].data[1].name", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].data[1].rel[0]", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].data[1].url", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$.uber.data[2].data[1].action", is("replace"))) + .andExpect(jsonPath("$.uber.data[2].data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].data[2].name", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].data[2].rel[0]", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].data[2].url", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$.uber.data[2].data[2].action", is("partial"))) + .andExpect(jsonPath("$.uber.data[2].data[2].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].data[3].rel[0]", is("employees"))) + .andExpect(jsonPath("$.uber.data[2].data[3].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[2].data[3].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[2].data[4].name", is("employee"))) + .andExpect(jsonPath("$.uber.data[2].data[4].data[0].name", is("role"))) + .andExpect(jsonPath("$.uber.data[2].data[4].data[0].value", is("ring bearer"))) + .andExpect(jsonPath("$.uber.data[2].data[4].data[1].name", is("name"))) + .andExpect(jsonPath("$.uber.data[2].data[4].data[1].value", is("Frodo Baggins"))) + + .andExpect(jsonPath("$.uber.data[3].data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[3].data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[3].data[0].rel[1]", is("findOne"))) + .andExpect(jsonPath("$.uber.data[3].data[0].url", is("http://localhost/employees/1"))) + + .andExpect(jsonPath("$.uber.data[3].data[1].name", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[3].data[1].rel[0]", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[3].data[1].url", is("http://localhost/employees/1"))) + .andExpect(jsonPath("$.uber.data[3].data[1].action", is("replace"))) + .andExpect(jsonPath("$.uber.data[3].data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[3].data[2].name", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[3].data[2].rel[0]", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[3].data[2].url", is("http://localhost/employees/1"))) + .andExpect(jsonPath("$.uber.data[3].data[2].action", is("partial"))) + .andExpect(jsonPath("$.uber.data[3].data[2].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[3].data[3].rel[0]", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].data[3].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[3].data[3].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[3].data[4].name", is("employee"))) + .andExpect(jsonPath("$.uber.data[3].data[4].data[0].name", is("role"))) + .andExpect(jsonPath("$.uber.data[3].data[4].data[0].value", is("burglar"))) + .andExpect(jsonPath("$.uber.data[3].data[4].data[1].name", is("name"))) + .andExpect(jsonPath("$.uber.data[3].data[4].data[1].value", is("Bilbo Baggins"))) + ; + } + + @Test + public void createNewEmployeeUber() throws Exception { + + String input = MappingUtils.read(new ClassPathResource("../uber/create-employee.json", getClass())); + + this.mockMvc.perform(post("/employees") + .content(input) + .contentType(MediaTypes.UBER_JSON)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2")); + + this.mockMvc.perform(get("/employees/2").accept(MediaTypes.UBER_JSON)) + .andDo(print()) + .andExpect(status().isOk()) // + + .andExpect(jsonPath("$.uber.version", is("1.0"))) + + .andExpect(jsonPath("$.uber.data.*", hasSize(5))) + .andExpect(jsonPath("$.uber.data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[1]", is("findOne"))) + .andExpect(jsonPath("$.uber.data[0].url", is("http://localhost/employees/2"))) + + .andExpect(jsonPath("$.uber.data[1].name", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[1].rel[0]", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[1].url", is("http://localhost/employees/2"))) + .andExpect(jsonPath("$.uber.data[1].action", is("replace"))) + .andExpect(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].name", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].rel[0]", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].url", is("http://localhost/employees/2"))) + .andExpect(jsonPath("$.uber.data[2].action", is("partial"))) + .andExpect(jsonPath("$.uber.data[2].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[3].name", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].rel[0]", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[3].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[4].name", is("employee"))) + .andExpect(jsonPath("$.uber.data[4].data.*", hasSize(2))) + .andExpect(jsonPath("$.uber.data[4].data[0].name", is("role"))) + .andExpect(jsonPath("$.uber.data[4].data[0].value", is("gardener"))) + .andExpect(jsonPath("$.uber.data[4].data[1].name", is("name"))) + .andExpect(jsonPath("$.uber.data[4].data[1].value", is("Samwise Gamgee"))) + ; + } + + @RestController static class EmployeeController { @@ -403,7 +574,7 @@ public class MultiMediatypeWebMvcIntegrationTest { @Configuration @EnableWebMvc - @EnableHypermediaSupport(type = { HypermediaType.COLLECTION_JSON, HypermediaType.HAL_FORMS }) + @EnableHypermediaSupport(type = { HypermediaType.HAL, HypermediaType.COLLECTION_JSON, HypermediaType.HAL_FORMS, HypermediaType.UBER }) static class TestConfig { @Bean diff --git a/src/test/java/org/springframework/hateoas/uber/Jackson2UberIntegrationTest.java b/src/test/java/org/springframework/hateoas/uber/Jackson2UberIntegrationTest.java new file mode 100644 index 00000000..638276a7 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/uber/Jackson2UberIntegrationTest.java @@ -0,0 +1,432 @@ +/* + * 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.uber; + +import static org.assertj.core.api.Assertions.*; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.AbstractJackson2MarshallingIntegrationTest; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.Links; +import org.springframework.hateoas.PagedResources; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.support.MappingUtils; +import org.springframework.hateoas.uber.Jackson2UberModule.UberHandlerInstantiator; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * @author Greg Turnquist + */ +public class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegrationTest { + + static final Links PAGINATION_LINKS = new Links( + new Link("localhost", Link.REL_SELF), + new Link("foo", Link.REL_NEXT), + new Link("bar", Link.REL_PREVIOUS)); + + @Before + public void setUpModule() { + + this.mapper.registerModule(new Jackson2UberModule()); + this.mapper.setHandlerInstantiator(new UberHandlerInstantiator()); + this.mapper.enable(SerializationFeature.INDENT_OUTPUT); + } + + /** + * @see #784 + */ + @Test + public void rendersSingleLinkAsObject() throws Exception { + + ResourceSupport resourceSupport = new ResourceSupport(); + resourceSupport.add(new Link("localhost").withSelfRel()); + + assertThat(write(resourceSupport)).isEqualTo(MappingUtils.read(new ClassPathResource("resource-support.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void deserializeSingleLink() throws Exception { + + ResourceSupport expected = new ResourceSupport(); + expected.add(new Link("localhost")); + assertThat(read(MappingUtils.read(new ClassPathResource("resource-support.json", getClass())), ResourceSupport.class)) + .isEqualTo(expected); + } + + /** + * @see #784 + */ + @Test + public void rendersMultipleLinkAsArray() throws Exception { + + ResourceSupport resourceSupport = new ResourceSupport(); + resourceSupport.add(new Link("localhost")); + resourceSupport.add(new Link("localhost2").withRel("orders")); + + assertThat(write(resourceSupport)).isEqualTo(MappingUtils.read(new ClassPathResource("resource-support-2.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void deserializeMultipleLinks() throws Exception { + + ResourceSupport expected = new ResourceSupport(); + expected.add(new Link("localhost")); + expected.add(new Link("localhost2").withRel("orders")); + + assertThat(read(MappingUtils.read(new ClassPathResource("resource-support-2.json", getClass())), ResourceSupport.class)) + .isEqualTo(expected); + } + + /** + * @see #784 + */ + @Test + public void rendersSimpleResourcesAsEmbedded() throws Exception { + + List content = new ArrayList<>(); + content.add("first"); + content.add("second"); + + Resources resources = new Resources<>(content); + resources.add(new Link("localhost")); + + assertThat(write(resources)).isEqualTo(MappingUtils.read(new ClassPathResource("resources.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void deserializesSimpleResourcesWithNoLinks() throws Exception { + + List content = new ArrayList<>(); + content.add("first"); + content.add("second"); + + Resources expected = new Resources<>(content); + expected.add(new Link("localhost")); + + String resourcesJson = MappingUtils.read(new ClassPathResource("resources.json", getClass())); + JavaType resourcesType = mapper.getTypeFactory().constructParametricType(Resources.class, String.class); + Resources result = mapper.readValue(resourcesJson, resourcesType); + + assertThat(result).isEqualTo(expected); + } + + /** + * @see #784 + */ + @Test + public void deserializeComplexResourcesSimply() throws IOException { + + List> content = new ArrayList<>(); + content.add(new Resource<>("first")); + content.add(new Resource<>("second")); + + Resources> expected = new Resources<>(content); + expected.add(new Link("localhost")); + + String resourcesJson = MappingUtils.read(new ClassPathResource("resources.json", getClass())); + + JavaType resourcesType = mapper.getTypeFactory().constructParametricType(Resources.class, + mapper.getTypeFactory().constructParametricType(Resource.class, String.class)); + + Resources> result = mapper.readValue(resourcesJson, resourcesType); + + assertThat(result).isEqualTo(expected); + } + + /** + * @see #784 + */ + @Test + public void renderSimpleResource() throws Exception { + + Resource data = new Resource<>("first", new Link("localhost")); + assertThat(write(data)).isEqualTo(MappingUtils.read(new ClassPathResource("resource.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void renderResourceWithCustomRel() throws Exception { + + Resource data2 = new Resource<>("second", new Link("localhost").withRel("custom")); + assertThat(write(data2)).isEqualTo(MappingUtils.read(new ClassPathResource("resource2.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void renderResourceWithMultipleLinks() throws Exception { + + Resource data3 = new Resource<>("third", + new Link("localhost"), + new Link("second").withRel("second"), + new Link("third").withRel("third")); + assertThat(write(data3)).isEqualTo(MappingUtils.read(new ClassPathResource("resource3.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void renderResourceWithMultipleRels() throws Exception { + + Resource data4 = new Resource<>("third", + new Link("localhost"), + new Link("localhost").withRel("http://example.org/rels/todo"), + new Link("second").withRel("second"), + new Link("third").withRel("third")); + assertThat(write(data4)).isEqualTo(MappingUtils.read(new ClassPathResource("resource4.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void deserializeResource() throws IOException { + + JavaType resourceStringType = mapper.getTypeFactory().constructParametricType(Resource.class, String.class); + + Resource expected = new Resource<>("first", new Link("localhost")); + Resource actual = mapper.readValue(MappingUtils.read(new ClassPathResource("resource.json", getClass())), + resourceStringType); + + assertThat(actual).isEqualTo(expected); + + Resource expected2 = new Resource<>("second", new Link("localhost").withRel("custom")); + Resource actual2 = mapper.readValue(MappingUtils.read(new ClassPathResource("resource2.json", getClass())), + resourceStringType); + + assertThat(actual2).isEqualTo(expected2); + + Resource expected3 = new Resource<>("third", + new Link("localhost"), + new Link("second").withRel("second"), + new Link("third").withRel("third")); + Resource actual3 = mapper.readValue(MappingUtils.read(new ClassPathResource("resource3.json", getClass())), + resourceStringType); + + assertThat(actual3).isEqualTo(expected3); + + Resource expected4 = new Resource<>("third", + new Link("localhost"), + new Link("localhost").withRel("http://example.org/rels/todo"), + new Link("second").withRel("second"), + new Link("third").withRel("third")); + Resource actual4 = mapper.readValue(MappingUtils.read(new ClassPathResource("resource4.json", getClass())), + resourceStringType); + + assertThat(actual4).isEqualTo(expected4); + } + + /** + * @see #784 + */ + @Test + public void renderComplexStructure() throws Exception { + + List> data = new ArrayList<>(); + data.add(new Resource<>("first", new Link("localhost"), new Link("orders").withRel("orders"))); + data.add(new Resource<>("second", new Link("remotehost"), new Link("order").withRel("orders"))); + + Resources> resources = new Resources<>(data); + resources.add(new Link("localhost")); + resources.add(new Link("/page/2").withRel("next")); + + assertThat(write(resources)).isEqualTo(MappingUtils.read(new ClassPathResource("resources-with-resource-objects.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void deserializeResources() throws Exception { + + List> data = new ArrayList>(); + data.add(new Resource<>("first", new Link("localhost"), new Link("orders").withRel("orders"))); + data.add(new Resource<>("second", new Link("remotehost"), new Link("order").withRel("orders"))); + + Resources expected = new Resources<>(data); + expected.add(new Link("localhost")); + expected.add(new Link("/page/2").withRel("next")); + + Resources> actual = mapper.readValue(MappingUtils.read(new ClassPathResource("resources-with-resource-objects.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resources.class, + mapper.getTypeFactory().constructParametricType(Resource.class, String.class))); + + assertThat(actual).isEqualTo(expected); + } + + /** + * @see #784 + */ + @Test + public void deserializeResourcesSimply() throws Exception { + + List data = new ArrayList<>(); + data.add("first"); + data.add("second"); + + Resources expected = new Resources<>(data); + expected.add(new Link("localhost")); + expected.add(new Link("/page/2").withRel("next")); + + Resources actual = mapper.readValue(MappingUtils.read(new ClassPathResource("resources-with-resource-objects.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resources.class, String.class)); + + assertThat(actual).isEqualTo(expected); + } + + /** + * @see #784 + */ + @Test + public void serializeWrappedSimplePojo() throws Exception { + + Employee employee = new Employee("Frodo", "ring bearer"); + Resource expected = new Resource<>(employee, new Link("/employees/1").withSelfRel()); + + String actual = MappingUtils.read(new ClassPathResource("resource-with-simple-pojo.json", getClass())); + + assertThat(write(expected)).isEqualTo(actual); + } + + /** + * @see #784 + */ + @Test + public void deserializeWrappedSimplePojo() throws IOException { + + Employee employee = new Employee("Frodo", "ring bearer"); + Resource expected = new Resource<>(employee, new Link("/employees/1").withSelfRel()); + + Resource actual = mapper.readValue(MappingUtils.read(new ClassPathResource("resource-with-simple-pojo.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resource.class, Employee.class)); + + assertThat(actual).isEqualTo(expected); + } + + /** + * @see #784 + */ + @Test + public void serializeConcreteResourceSupport() throws Exception { + + EmployeeResource expected = new EmployeeResource("Frodo", "ring bearer"); + expected.add(new Link("/employees/1").withSelfRel()); + expected.add(new Link("/employees").withRel("employees")); + + String actual = MappingUtils.read(new ClassPathResource("resource-support-pojo.json", getClass())); + + assertThat(write(expected)).isEqualTo(actual); + } + + /** + * @see #784 + */ + @Test + public void deserializeConcreteResourceSupport() throws Exception { + + EmployeeResource expected = new EmployeeResource("Frodo", "ring bearer"); + expected.add(new Link("/employees/1").withSelfRel()); + expected.add(new Link("/employees").withRel("employees")); + + EmployeeResource actual = mapper.readValue( + MappingUtils.read(new ClassPathResource("resource-support-pojo.json", getClass())), + EmployeeResource.class); + + assertThat(actual).isEqualTo(expected); + } + + /** + * @see #784 + */ + @Test + public void serializesPagedResource() throws Exception { + + String actual = write(setupAnnotatedPagedResources()); + assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("paged-resources.json", getClass()))); + } + + /** + * @see #784 + */ + @Test + public void deserializesPagedResource() throws Exception { + + PagedResources> result = mapper.readValue(MappingUtils.read(new ClassPathResource("paged-resources.json", getClass())), + mapper.getTypeFactory().constructParametricType(PagedResources.class, + mapper.getTypeFactory().constructParametricType(Resource.class, Employee.class))); + + assertThat(result).isEqualTo(setupAnnotatedPagedResources()); + } + + private static Resources> setupAnnotatedPagedResources() { + + List> content = new ArrayList<>(); + Employee employee = new Employee("Frodo", "ring bearer"); + Resource employeeResource = new Resource<>(employee, new Link("/employees/1").withSelfRel()); + content.add(employeeResource); + + return new PagedResources<>( + content, + new PagedResources.PageMetadata(2, 0, 4), + PAGINATION_LINKS); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + static class Employee { + + private String name; + private String role; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class EmployeeResource extends ResourceSupport { + + private String name; + private String role; + } + +} \ No newline at end of file diff --git a/src/test/java/org/springframework/hateoas/uber/UberLinkDiscovererUnitTest.java b/src/test/java/org/springframework/hateoas/uber/UberLinkDiscovererUnitTest.java new file mode 100644 index 00000000..d399c83b --- /dev/null +++ b/src/test/java/org/springframework/hateoas/uber/UberLinkDiscovererUnitTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2013-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. + * 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.uber; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.hateoas.support.MappingUtils.*; + +import java.io.IOException; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.LinkDiscoverer; +import org.springframework.hateoas.core.AbstractLinkDiscovererUnitTest; +import org.springframework.hateoas.hal.HalLinkDiscoverer; + +/** + * Unit tests for {@link HalLinkDiscoverer}. + * + * @author Oliver Gierke + */ +public class UberLinkDiscovererUnitTest extends AbstractLinkDiscovererUnitTest { + + LinkDiscoverer discoverer = new UberLinkDiscoverer(); + String sample; + + @Before + public void setUp() throws IOException { + + this.discoverer = new UberLinkDiscoverer(); + this.sample = read(new ClassPathResource("link-discovery.json", getClass())); + } + /** + * @see #314 + * @see #784 + */ + @Test + public void discoversFullyQualifiedRel() { + assertThat(getDiscoverer().findLinkWithRel("http://foo.com/bar", this.sample)).isNotNull(); + } + + @Override + protected LinkDiscoverer getDiscoverer() { + return discoverer; + } + + @Override + protected String getInputString() { + return this.sample; + } + + @Override + protected String getInputStringWithoutLinkContainer() { + return "{ \"uber\" : { \"data\" : []}}"; + } +} \ No newline at end of file diff --git a/src/test/java/org/springframework/hateoas/uber/UberWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/uber/UberWebMvcIntegrationTest.java new file mode 100644 index 00000000..d94c458d --- /dev/null +++ b/src/test/java/org/springframework/hateoas/uber/UberWebMvcIntegrationTest.java @@ -0,0 +1,405 @@ +/* + * 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.uber; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.collection.IsCollectionWithSize.*; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.support.MappingUtils.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.support.Employee; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * @author Greg Turnquist + */ +@RunWith(SpringRunner.class) +@WebAppConfiguration +@ContextConfiguration +public class UberWebMvcIntegrationTest { + + @Autowired WebApplicationContext context; + + MockMvc mockMvc; + + private static Map EMPLOYEES; + + @Before + public void setUp() { + + this.mockMvc = webAppContextSetup(this.context).build(); + + EMPLOYEES = new TreeMap<>(); + + EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer")); + EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar")); + } + + /** + * @see #784 + */ + @Test + public void singleEmployee() throws Exception { + + this.mockMvc.perform(get("/employees/0").accept(MediaTypes.UBER_JSON)) // + .andDo(print()) + .andExpect(status().isOk()) // + + .andExpect(jsonPath("$.uber.version", is("1.0"))) + + .andExpect(jsonPath("$.uber.data.*", hasSize(5))) + .andExpect(jsonPath("$.uber.data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[1]", is("findOne"))) + .andExpect(jsonPath("$.uber.data[0].url", is("http://localhost/employees/0"))) + + .andExpect(jsonPath("$.uber.data[1].name", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[1].rel[0]", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[1].url", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$.uber.data[1].action", is("replace"))) + .andExpect(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].name", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].rel[0]", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].url", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$.uber.data[2].action", is("partial"))) + .andExpect(jsonPath("$.uber.data[2].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[3].name", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].rel[0]", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[3].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[4].name", is("employee"))) + .andExpect(jsonPath("$.uber.data[4].data.*", hasSize(2))) + .andExpect(jsonPath("$.uber.data[4].data[0].name", is("role"))) + .andExpect(jsonPath("$.uber.data[4].data[0].value", is("ring bearer"))) + .andExpect(jsonPath("$.uber.data[4].data[1].name", is("name"))) + .andExpect(jsonPath("$.uber.data[4].data[1].value", is("Frodo Baggins"))) + ; + } + + /** + * @see #784 + */ + @Test + public void collectionOfEmployees() throws Exception { + + this.mockMvc.perform(get("/employees").accept(MediaTypes.UBER_JSON)) // + .andDo(print()) + .andExpect(status().isOk()) // + + .andExpect(jsonPath("$.uber.version", is("1.0"))) + + .andExpect(jsonPath("$.uber.data.*", hasSize(4))) + + .andExpect(jsonPath("$.uber.data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[0].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[1].name", is("newEmployee"))) + .andExpect(jsonPath("$.uber.data[1].rel[0]", is("newEmployee"))) + .andExpect(jsonPath("$.uber.data[1].url", is("http://localhost/employees"))) + .andExpect(jsonPath("$.uber.data[1].action", is("append"))) + .andExpect(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[2].data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[2].data[0].rel[1]", is("findOne"))) + .andExpect(jsonPath("$.uber.data[2].data[0].url", is("http://localhost/employees/0"))) + + .andExpect(jsonPath("$.uber.data[2].data[1].name", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].data[1].rel[0]", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].data[1].url", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$.uber.data[2].data[1].action", is("replace"))) + .andExpect(jsonPath("$.uber.data[2].data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].data[2].name", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].data[2].rel[0]", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].data[2].url", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$.uber.data[2].data[2].action", is("partial"))) + .andExpect(jsonPath("$.uber.data[2].data[2].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].data[3].rel[0]", is("employees"))) + .andExpect(jsonPath("$.uber.data[2].data[3].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[2].data[3].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[2].data[4].name", is("employee"))) + .andExpect(jsonPath("$.uber.data[2].data[4].data[0].name", is("role"))) + .andExpect(jsonPath("$.uber.data[2].data[4].data[0].value", is("ring bearer"))) + .andExpect(jsonPath("$.uber.data[2].data[4].data[1].name", is("name"))) + .andExpect(jsonPath("$.uber.data[2].data[4].data[1].value", is("Frodo Baggins"))) + + .andExpect(jsonPath("$.uber.data[3].data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[3].data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[3].data[0].rel[1]", is("findOne"))) + .andExpect(jsonPath("$.uber.data[3].data[0].url", is("http://localhost/employees/1"))) + + .andExpect(jsonPath("$.uber.data[3].data[1].name", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[3].data[1].rel[0]", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[3].data[1].url", is("http://localhost/employees/1"))) + .andExpect(jsonPath("$.uber.data[3].data[1].action", is("replace"))) + .andExpect(jsonPath("$.uber.data[3].data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[3].data[2].name", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[3].data[2].rel[0]", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[3].data[2].url", is("http://localhost/employees/1"))) + .andExpect(jsonPath("$.uber.data[3].data[2].action", is("partial"))) + .andExpect(jsonPath("$.uber.data[3].data[2].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[3].data[3].rel[0]", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].data[3].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[3].data[3].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[3].data[4].name", is("employee"))) + .andExpect(jsonPath("$.uber.data[3].data[4].data[0].name", is("role"))) + .andExpect(jsonPath("$.uber.data[3].data[4].data[0].value", is("burglar"))) + .andExpect(jsonPath("$.uber.data[3].data[4].data[1].name", is("name"))) + .andExpect(jsonPath("$.uber.data[3].data[4].data[1].value", is("Bilbo Baggins"))) + ; + } + + /** + * @see #784 + */ + @Test + public void createNewEmployee() throws Exception { + + String input = read(new ClassPathResource("create-employee.json", getClass())); + + this.mockMvc.perform(post("/employees") + .content(input) + .contentType(MediaTypes.UBER_JSON)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2")); + + this.mockMvc.perform(get("/employees/2").accept(MediaTypes.UBER_JSON)) + .andDo(print()) + .andExpect(status().isOk()) // + + .andExpect(jsonPath("$.uber.version", is("1.0"))) + + .andExpect(jsonPath("$.uber.data.*", hasSize(5))) + .andExpect(jsonPath("$.uber.data[0].name", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[0]", is("self"))) + .andExpect(jsonPath("$.uber.data[0].rel[1]", is("findOne"))) + .andExpect(jsonPath("$.uber.data[0].url", is("http://localhost/employees/2"))) + + .andExpect(jsonPath("$.uber.data[1].name", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[1].rel[0]", is("updateEmployee"))) + .andExpect(jsonPath("$.uber.data[1].url", is("http://localhost/employees/2"))) + .andExpect(jsonPath("$.uber.data[1].action", is("replace"))) + .andExpect(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[2].name", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].rel[0]", is("partiallyUpdateEmployee"))) + .andExpect(jsonPath("$.uber.data[2].url", is("http://localhost/employees/2"))) + .andExpect(jsonPath("$.uber.data[2].action", is("partial"))) + .andExpect(jsonPath("$.uber.data[2].model", is("name={name}&role={role}"))) + + .andExpect(jsonPath("$.uber.data[3].name", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].rel[0]", is("employees"))) + .andExpect(jsonPath("$.uber.data[3].rel[1]", is("all"))) + .andExpect(jsonPath("$.uber.data[3].url", is("http://localhost/employees"))) + + .andExpect(jsonPath("$.uber.data[4].name", is("employee"))) + .andExpect(jsonPath("$.uber.data[4].data.*", hasSize(2))) + .andExpect(jsonPath("$.uber.data[4].data[0].name", is("role"))) + .andExpect(jsonPath("$.uber.data[4].data[0].value", is("gardener"))) + .andExpect(jsonPath("$.uber.data[4].data[1].name", is("name"))) + .andExpect(jsonPath("$.uber.data[4].data[1].value", is("Samwise Gamgee"))) + ; + } + + @RestController + static class EmployeeController { + + @GetMapping("/employees") + public Resources> all() { + + // Create a list of Resource's to return + List> employees = new ArrayList<>(); + + // Fetch each Resource using the controller's findOne method. + for (int i = 0; i < EMPLOYEES.size(); i++) { + employees.add(findOne(i)); + } + + // Generate an "Affordance" based on this method (the "self" link) + Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel() + .andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null))) + .andAffordance(afford(methodOn(EmployeeController.class).search(null, null))); + + // Return the collection of employee resources along with the composite affordance + return new Resources<>(employees, selfLink); + } + + @GetMapping("/employees/search") + public Resources> search(@RequestParam(value="name", required=false) String name, + @RequestParam(value="role", required=false) String role) { + + // Create a list of Resource's to return + List> employees = new ArrayList<>(); + + // Fetch each Resource using the controller's findOne method. + for (int i = 0; i < EMPLOYEES.size(); i++) { + Resource employeeResource = findOne(i); + + boolean nameMatches = Optional.ofNullable(name) + .map(s -> employeeResource.getContent().getName().contains(s)) + .orElse(true); + + boolean roleMatches = Optional.ofNullable(role) + .map( s -> employeeResource.getContent().getRole().contains(s)) + .orElse(true); + + if (nameMatches && roleMatches) { + employees.add(employeeResource); + } + } + + // Generate an "Affordance" based on this method (the "self" link) + Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel() + .andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null))) + .andAffordance(afford(methodOn(EmployeeController.class).search(null, null))); + + // Return the collection of employee resources along with the composite affordance + return new Resources<>(employees, selfLink); + } + + @GetMapping("/employees/{id}") + public Resource findOne(@PathVariable Integer id) { + + // Start the affordance with the "self" link, i.e. this method. + Link findOneLink = linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel(); + + // Define final link as means to find entire collection. + Link employeesLink = linkTo(methodOn(EmployeeController.class).all()).withRel("employees"); + + // Return the affordance + a link back to the entire collection resource. + return new Resource<>(EMPLOYEES.get(id), + findOneLink + .andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id))) // + .andAffordance(afford(methodOn(EmployeeController.class).partiallyUpdateEmployee(null, id))), + employeesLink); + } + + @PostMapping("/employees") + public ResponseEntity newEmployee(@RequestBody Resource employee) { + + int newEmployeeId = EMPLOYEES.size(); + + EMPLOYEES.put(newEmployeeId, employee.getContent()); + + try { + return ResponseEntity.created(new URI(findOne(newEmployeeId).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/employees/{id}") + public ResponseEntity updateEmployee(@RequestBody Resource employee, @PathVariable Integer id) { + + EMPLOYEES.put(id, employee.getContent()); + + try { + return ResponseEntity.noContent().location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PatchMapping("/employees/{id}") + public ResponseEntity partiallyUpdateEmployee(@RequestBody Resource employee, @PathVariable Integer id) { + + Employee oldEmployee = EMPLOYEES.get(id); + Employee newEmployee = oldEmployee; + + if (employee.getContent().getName() != null) { + newEmployee = newEmployee.withName(employee.getContent().getName()); + } + + if (employee.getContent().getRole() != null) { + newEmployee = newEmployee.withRole(employee.getContent().getRole()); + } + + EMPLOYEES.put(id, newEmployee); + + try { + return ResponseEntity.noContent().location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + } + + @Configuration + @EnableWebMvc + @EnableHypermediaSupport(type = { HypermediaType.UBER}) + static class TestConfig { + + @Bean + EmployeeController employeeController() { + return new EmployeeController(); + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/create-employee.json b/src/test/resources/org/springframework/hateoas/uber/create-employee.json new file mode 100644 index 00000000..91cee98e --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/create-employee.json @@ -0,0 +1,20 @@ +{ + "uber": { + "version": "1.0", + "data": [ + { + "name": "employee", + "data": [ + { + "name": "role", + "value": "gardener" + }, + { + "name": "name", + "value": "Samwise Gamgee" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/link-discovery.json b/src/test/resources/org/springframework/hateoas/uber/link-discovery.json new file mode 100644 index 00000000..2605f33a --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/link-discovery.json @@ -0,0 +1,25 @@ +{ + "uber" : + { + "version" : "1.0", + "data" : + [ + { + "rel" : ["self"], + "url" : "selfHref" + }, + { + "rel" : ["relation"], + "url" : "firstHref" + }, + { + "rel" : ["relation"], + "url" : "secondHref" + }, + { + "rel" : ["http://foo.com/bar"], + "url" : "fullRelHref" + } + ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/paged-resources.json b/src/test/resources/org/springframework/hateoas/uber/paged-resources.json new file mode 100644 index 00000000..8d3729c4 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/paged-resources.json @@ -0,0 +1,44 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "localhost" + }, { + "rel" : [ "next" ], + "url" : "foo" + }, { + "rel" : [ "prev" ], + "url" : "bar" + }, { + "data" : [ { + "rel" : [ "self" ], + "url" : "/employees/1" + }, { + "name" : "employee", + "data" : [ { + "name" : "role", + "value" : "ring bearer" + }, { + "name" : "name", + "value" : "Frodo" + } ] + } ] + }, { + "name" : "page", + "data" : [ { + "name" : "number", + "value" : 0 + }, { + "name" : "size", + "value" : 2 + }, { + "name" : "totalElements", + "value" : 4 + }, { + "name" : "totalPages", + "value" : 2 + } ] + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/reference-links-only.json b/src/test/resources/org/springframework/hateoas/uber/reference-links-only.json new file mode 100644 index 00000000..a9ab847e --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/reference-links-only.json @@ -0,0 +1,10 @@ +{ + "_links" : { + "collection" : { + "href" : "/employees" + }, + "self" : { + "href" : "/employees/1" + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resource-support-2.json b/src/test/resources/org/springframework/hateoas/uber/resource-support-2.json new file mode 100644 index 00000000..6b778eb0 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resource-support-2.json @@ -0,0 +1,12 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "localhost" + }, { + "rel" : [ "orders" ], + "url" : "localhost2" + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resource-support-pojo.json b/src/test/resources/org/springframework/hateoas/uber/resource-support-pojo.json new file mode 100644 index 00000000..8e8b69f4 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resource-support-pojo.json @@ -0,0 +1,21 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "/employees/1" + }, { + "rel" : [ "employees" ], + "url" : "/employees" + }, { + "name" : "employeeResource", + "data" : [ { + "name" : "role", + "value" : "ring bearer" + }, { + "name" : "name", + "value" : "Frodo" + } ] + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resource-support.json b/src/test/resources/org/springframework/hateoas/uber/resource-support.json new file mode 100644 index 00000000..3f45c46a --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resource-support.json @@ -0,0 +1,9 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "localhost" + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resource-with-simple-pojo.json b/src/test/resources/org/springframework/hateoas/uber/resource-with-simple-pojo.json new file mode 100644 index 00000000..760e99e4 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resource-with-simple-pojo.json @@ -0,0 +1,18 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "/employees/1" + }, { + "name" : "employee", + "data" : [ { + "name" : "role", + "value" : "ring bearer" + }, { + "name" : "name", + "value" : "Frodo" + } ] + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resource.json b/src/test/resources/org/springframework/hateoas/uber/resource.json new file mode 100644 index 00000000..7462008d --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resource.json @@ -0,0 +1,14 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "localhost" + }, { + "name" : "string", + "data" : [ { + "value" : "first" + } ] + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resource2.json b/src/test/resources/org/springframework/hateoas/uber/resource2.json new file mode 100644 index 00000000..4d2e74e1 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resource2.json @@ -0,0 +1,14 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "custom" ], + "url" : "localhost" + }, { + "name" : "string", + "data" : [ { + "value" : "second" + } ] + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resource3.json b/src/test/resources/org/springframework/hateoas/uber/resource3.json new file mode 100644 index 00000000..c496a7f6 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resource3.json @@ -0,0 +1,20 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "localhost" + }, { + "rel" : [ "second" ], + "url" : "second" + }, { + "rel" : [ "third" ], + "url" : "third" + }, { + "name" : "string", + "data" : [ { + "value" : "third" + } ] + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resource4.json b/src/test/resources/org/springframework/hateoas/uber/resource4.json new file mode 100644 index 00000000..9e9711f7 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resource4.json @@ -0,0 +1,20 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self", "http://example.org/rels/todo" ], + "url" : "localhost" + }, { + "rel" : [ "second" ], + "url" : "second" + }, { + "rel" : [ "third" ], + "url" : "third" + }, { + "name" : "string", + "data" : [ { + "value" : "third" + } ] + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resources-with-resource-objects.json b/src/test/resources/org/springframework/hateoas/uber/resources-with-resource-objects.json new file mode 100644 index 00000000..61c6b8f4 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resources-with-resource-objects.json @@ -0,0 +1,38 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "localhost" + }, { + "rel" : [ "next" ], + "url" : "/page/2" + }, { + "data" : [ { + "rel" : [ "self" ], + "url" : "localhost" + }, { + "rel" : [ "orders" ], + "url" : "orders" + }, { + "name" : "string", + "data" : [ { + "value" : "first" + } ] + } ] + }, { + "data" : [ { + "rel" : [ "self" ], + "url" : "remotehost" + }, { + "rel" : [ "orders" ], + "url" : "order" + }, { + "name" : "string", + "data" : [ { + "value" : "second" + } ] + } ] + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/uber/resources.json b/src/test/resources/org/springframework/hateoas/uber/resources.json new file mode 100644 index 00000000..1513c281 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/uber/resources.json @@ -0,0 +1,23 @@ +{ + "uber" : { + "version" : "1.0", + "data" : [ { + "rel" : [ "self" ], + "url" : "localhost" + }, { + "data" : [ { + "name" : "string", + "data" : [ { + "value" : "first" + } ] + } ] + }, { + "data" : [ { + "name" : "string", + "data" : [ { + "value" : "second" + } ] + } ] + } ] + } +} \ No newline at end of file