From 345c198a7506057f203873e675b9c274d5f4e1fd Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Mon, 10 Aug 2015 23:56:52 -0500 Subject: [PATCH] DATAREST-638 - Consolidated metadata under a single profile link. Moved /alps into a separate link underneath /profile, so that /schema can ALSO be served there as well. Also added a profile link to the collection resources, linking to collection-specific metadata. We now use strict content negotiation for each form of metadata so clients don't have to navigate a labyrinth of links. To preserve backwards compatibility, make ALPS the default metadata type. Original pull request: #196. --- .../data/rest/webmvc/ProfileController.java | 142 +++++++++++++++ ...sor.java => ProfileResourceProcessor.java} | 41 ++--- .../webmvc/RepositoryEntityController.java | 2 + .../webmvc/RepositorySchemaController.java | 10 +- .../data/rest/webmvc/RestMediaTypes.java | 12 +- .../data/rest/webmvc/alps/AlpsController.java | 51 +----- .../alps/AlpsJsonHttpMessageConverter.java | 5 +- ...eInformationToAlpsDescriptorConverter.java | 16 +- .../RepositoryRestMvcConfiguration.java | 7 +- .../data/rest/webmvc/CommonWebTests.java | 18 +- .../rest/webmvc/ProfileIntegrationTests.java | 125 +++++++++++++ .../data/rest/webmvc/TestMvcClient.java | 25 ++- .../alps/AlpsControllerIntegrationTests.java | 42 ++--- src/main/asciidoc/metadata.adoc | 164 +++++++++++++++--- 14 files changed, 524 insertions(+), 136 deletions(-) create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ProfileController.java rename spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/{alps/AlpsResourceProcessor.java => ProfileResourceProcessor.java} (54%) create mode 100644 spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/ProfileIntegrationTests.java diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ProfileController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ProfileController.java new file mode 100644 index 000000000..0f1124741 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ProfileController.java @@ -0,0 +1,142 @@ +/* + * Copyright 2015 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.data.rest.webmvc; + +import static org.springframework.web.bind.annotation.RequestMethod.*; + +import java.util.Collections; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.Path; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; +import org.springframework.data.rest.core.mapping.RepositoryResourceMappings; +import org.springframework.data.rest.core.mapping.ResourceMapping; +import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +/** + * Profile-based controller exposing multiple forms of metadata. + * + * @author Greg Turnquist + * @see DATAREST-638 + * @since 2.4 + */ +@BasePathAwareController +public class ProfileController { + + public static final String PROFILE_ROOT_MAPPING = "/profile"; + public static final String RESOURCE_PROFILE_MAPPING = PROFILE_ROOT_MAPPING + "/{repository}"; + + private final RepositoryRestConfiguration configuration; + private final RepositoryResourceMappings mappings; + private final Repositories repositories; + + /** + * Wire up the controller with a copy of {@link RepositoryRestConfiguration}. + * + * @param configuration must not be {@literal null}. + * @param mappings must not be {@literal null}. + * @param repositories must not be {@literal null}. + */ + @Autowired + public ProfileController(RepositoryRestConfiguration configuration, RepositoryResourceMappings mappings, + Repositories repositories) { + + Assert.notNull(configuration, "RepositoryRestConfiguration must not be null!"); + Assert.notNull(mappings, "RepositoryResourceMappings must not be null!"); + Assert.notNull(repositories, "Repositories must not be null!"); + + this.configuration = configuration; + this.mappings = mappings; + this.repositories = repositories; + } + + /** + * List the OPTIONS for this controller. + * + * @return + */ + @RequestMapping(value = PROFILE_ROOT_MAPPING, method = RequestMethod.OPTIONS) + public HttpEntity profileOptions() { + + HttpHeaders headers = new HttpHeaders(); + headers.setAllow(Collections.singleton(HttpMethod.GET)); + + return new ResponseEntity(headers, HttpStatus.OK); + } + + /** + * List a profile link for each exported repository. + * + * @return + */ + @RequestMapping(value = PROFILE_ROOT_MAPPING, method = GET) + HttpEntity listAllFormsOfMetadata() { + + ResourceSupport profile = new ResourceSupport(); + + profile.add(new Link(getRootPath(this.configuration)).withSelfRel()); + + for (Class domainType : this.repositories) { + + ResourceMetadata mapping = this.mappings.getMetadataFor(domainType); + + if (mapping.isExported()) { + profile.add(new Link(getPath(this.configuration, mapping), mapping.getRel())); + } + } + + return new ResponseEntity(profile, HttpStatus.OK); + } + + /** + * Return href for the profile root link of a given baseUri. + * + * @param configuration is the source of the app's baseUri. + * @return + */ + public static String getRootPath(RepositoryRestConfiguration configuration) { + + BaseUri baseUri = new BaseUri(configuration.getBaseUri()); + return baseUri.getUriComponentsBuilder().path(ProfileController.PROFILE_ROOT_MAPPING).build().toString(); + } + + /** + * Return href for the profile link of a given baseUri and domain type mapping. + * + * @param configuration is the source of the app's baseUri. + * @param mapping provides the resource's path. + * @return + */ + public static String getPath(RepositoryRestConfiguration configuration, ResourceMapping mapping) { + + if (mapping == null) { + return getRootPath(configuration); + } else { + return getRootPath(configuration) + mapping.getPath(); + } + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsResourceProcessor.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ProfileResourceProcessor.java similarity index 54% rename from spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsResourceProcessor.java rename to spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ProfileResourceProcessor.java index 9636716ce..e1a21ea42 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsResourceProcessor.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ProfileResourceProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2014 the original author or authors. + * Copyright 2015 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. @@ -13,55 +13,50 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.rest.webmvc.alps; +package org.springframework.data.rest.webmvc; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; -import org.springframework.data.rest.webmvc.BaseUri; -import org.springframework.data.rest.webmvc.RepositoryLinksResource; import org.springframework.hateoas.Link; import org.springframework.hateoas.ResourceProcessor; import org.springframework.util.Assert; /** - * {@link ResourceProcessor} to add a {@code profile} link to the root resource to point to the ALPS resources in case - * the support for ALPS is activated. - * - * @author Oliver Gierke + * {@link ResourceProcessor} to add a {@code profile} link to the root resource to point to multiple forms of metadata. + * + * @author Greg Turnquist + * @see DATAREST-638 + * @since 2.4 */ -public class AlpsResourceProcessor implements ResourceProcessor { +public class ProfileResourceProcessor implements ResourceProcessor { - private static final String PROFILE_REL = "profile"; + static final String PROFILE_REL = "profile"; private final RepositoryRestConfiguration configuration; /** - * Creates a new {@link AlpsResourceProcessor} with the given {@link RepositoryRestConfiguration}. - * + * Creates a new {@link ProfileResourceProcessor} with the given {@link RepositoryRestConfiguration}. + * * @param configuration must not be {@literal null}. */ @Autowired - public AlpsResourceProcessor(RepositoryRestConfiguration configuration) { + public ProfileResourceProcessor(RepositoryRestConfiguration configuration) { Assert.notNull(configuration, "RepositoryRestConfiguration must not be null!"); this.configuration = configuration; } - /* - * (non-Javadoc) - * @see org.springframework.hateoas.ResourceProcessor#process(org.springframework.hateoas.ResourceSupport) + /** + * Add a link to the {@link ProfileController}'s base URI to the app's root URI. + * + * @param resource + * @return */ @Override public RepositoryLinksResource process(RepositoryLinksResource resource) { - if (configuration.metadataConfiguration().alpsEnabled()) { - - BaseUri baseUri = new BaseUri(configuration.getBaseUri()); - String href = baseUri.getUriComponentsBuilder().path(AlpsController.ALPS_ROOT_MAPPING).build().toString(); - - resource.add(new Link(href, PROFILE_REL)); - } + resource.add(new Link(ProfileController.getRootPath(this.configuration), PROFILE_REL)); return resource; } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java index 6704b2a05..12fda4136 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java @@ -202,6 +202,8 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem Link baseLink = entityLinks.linkToPagedResource(resourceInformation.getDomainType(), pageable.isDefault() ? null : pageable.getPageable()); + links.add(new Link(ProfileController.getPath(this.config, metadata), ProfileResourceProcessor.PROFILE_REL)); + Resources result = toResources(results, assembler, metadata.getDomainType(), baseLink); result.add(links); return result; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java index f8bf345fb..d5a996510 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java @@ -31,12 +31,12 @@ import org.springframework.web.bind.annotation.RequestMapping; * * @author Jon Brisbin * @author Oliver Gierke + * @author Greg Turnquist + * @see http://json-schema.org/ */ -@RepositoryRestController +@BasePathAwareController class RepositorySchemaController { - private static final String BASE_MAPPING = "/{repository}"; - private final PersistentEntityToJsonSchemaConverter jsonSchemaConverter; /** @@ -48,6 +48,7 @@ class RepositorySchemaController { public RepositorySchemaController(PersistentEntityToJsonSchemaConverter jsonSchemaConverter) { Assert.notNull(jsonSchemaConverter, "PersistentEntityToJsonSchemaConverter must not be null!"); + this.jsonSchemaConverter = jsonSchemaConverter; } @@ -57,7 +58,8 @@ class RepositorySchemaController { * @param resourceInformation will never be {@literal null}. * @return */ - @RequestMapping(value = BASE_MAPPING + "/schema", method = GET) + @RequestMapping(value = ProfileController.RESOURCE_PROFILE_MAPPING, method = GET, + produces = RestMediaTypes.SCHEMA_JSON_VALUE) public HttpEntity schema(RootResourceInformation resourceInformation) { JsonSchema schema = jsonSchemaConverter.convert(resourceInformation.getDomainType()); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RestMediaTypes.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RestMediaTypes.java index 2e748f032..850d042be 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RestMediaTypes.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RestMediaTypes.java @@ -22,21 +22,27 @@ import org.springframework.http.MediaType; * Constants to refer to supported media types. * * @author Oliver Gierke + * @author Greg Turnquist */ public class RestMediaTypes { - public static final String SPRING_DATA_COMPACT_JSON_VALUE = "application/x-spring-data-compact+json"; - public static final String TEXT_URI_LIST_VALUE = "text/uri-list"; public static final MediaType HAL_JSON = MediaTypes.HAL_JSON; public static final MediaType JSON_PATCH_JSON = MediaType.valueOf("application/json-patch+json"); public static final MediaType MERGE_PATCH_JSON = MediaType.valueOf("application/merge-patch+json"); - public static final MediaType SCHEMA_JSON = MediaType.valueOf("application/schema+json"); + public static final String ALPS_JSON_VALUE = "application/alps+json"; + public static final MediaType ALPS_JSON = MediaType.parseMediaType(ALPS_JSON_VALUE); + + public static final String SCHEMA_JSON_VALUE = "application/schema+json"; + public static final MediaType SCHEMA_JSON = MediaType.valueOf(SCHEMA_JSON_VALUE); public static final MediaType SPRING_DATA_VERBOSE_JSON = MediaType.valueOf("application/x-spring-data-verbose+json"); + + public static final String SPRING_DATA_COMPACT_JSON_VALUE = "application/x-spring-data-compact+json"; public static final MediaType SPRING_DATA_COMPACT_JSON = MediaType.valueOf(SPRING_DATA_COMPACT_JSON_VALUE); + public static final String TEXT_URI_LIST_VALUE = "text/uri-list"; public static final MediaType TEXT_URI_LIST = MediaType.valueOf(TEXT_URI_LIST_VALUE); } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsController.java index 3837b485b..eb677b086 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsController.java @@ -17,43 +17,37 @@ package org.springframework.data.rest.webmvc.alps; import static org.springframework.web.bind.annotation.RequestMethod.*; -import java.util.ArrayList; import java.util.Collections; -import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.ResourceMappings; -import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.webmvc.BasePathAwareController; -import org.springframework.data.rest.webmvc.BaseUri; +import org.springframework.data.rest.webmvc.ProfileController; import org.springframework.data.rest.webmvc.ResourceNotFoundException; +import org.springframework.data.rest.webmvc.RestMediaTypes; import org.springframework.data.rest.webmvc.RootResourceInformation; -import org.springframework.hateoas.alps.Alps; -import org.springframework.hateoas.alps.Descriptor; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.util.UriComponentsBuilder; /** * Controller exposing semantic documentation for the resources exposed using the Application Level Profile Semantics * format. * * @author Oliver Gierke + * @author Greg Turnquist * @see http://alps.io */ @BasePathAwareController public class AlpsController { - static final String ALPS_ROOT_MAPPING = "/alps"; - static final String ALPS_RESOURCE_MAPPING = ALPS_ROOT_MAPPING + "/{repository}"; - private final Repositories repositories; private final ResourceMappings mappings; private final RepositoryRestConfiguration configuration; @@ -83,7 +77,7 @@ public class AlpsController { * * @return */ - @RequestMapping(value = { ALPS_ROOT_MAPPING, ALPS_RESOURCE_MAPPING }, method = OPTIONS) + @RequestMapping(value = ProfileController.RESOURCE_PROFILE_MAPPING, method = OPTIONS, produces = RestMediaTypes.ALPS_JSON_VALUE) HttpEntity alpsOptions() { verifyAlpsEnabled(); @@ -94,45 +88,14 @@ public class AlpsController { return new ResponseEntity(headers, HttpStatus.OK); } - /** - * Exposes a resource to contain descriptors pointing to the discriptors for individual resources. - * - * @return - */ - @RequestMapping(value = ALPS_ROOT_MAPPING, method = GET) - HttpEntity alps() { - - verifyAlpsEnabled(); - - List descriptors = new ArrayList(); - - for (Class domainType : repositories) { - - ResourceMetadata mapping = mappings.getMetadataFor(domainType); - - if (mapping.isExported()) { - - BaseUri baseUri = new BaseUri(configuration.getBaseUri()); - UriComponentsBuilder builder = baseUri.getUriComponentsBuilder().path(ALPS_ROOT_MAPPING); - String href = builder.path(mapping.getPath().toString()).build().toUriString(); - descriptors.add(Alps.descriptor().name(mapping.getRel()).href(href).build()); - } - } - - Alps alps = Alps.alps().// - descriptors(descriptors).// - build(); - - return new ResponseEntity(alps, HttpStatus.OK); - } - /** * Exposes an ALPS resource to describe an individual repository resource. * * @param information * @return */ - @RequestMapping(value = ALPS_RESOURCE_MAPPING, method = GET) + @RequestMapping(value = ProfileController.RESOURCE_PROFILE_MAPPING, method = GET, + produces = { MediaType.ALL_VALUE, RestMediaTypes.ALPS_JSON_VALUE }) HttpEntity descriptor(RootResourceInformation information) { verifyAlpsEnabled(); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsJsonHttpMessageConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsJsonHttpMessageConverter.java index 2ebc703e2..f0c016e37 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsJsonHttpMessageConverter.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/AlpsJsonHttpMessageConverter.java @@ -20,6 +20,7 @@ import java.util.Arrays; import org.springframework.core.MethodParameter; import org.springframework.core.convert.converter.Converter; +import org.springframework.data.rest.webmvc.RestMediaTypes; import org.springframework.data.rest.webmvc.RootResourceInformation; import org.springframework.hateoas.alps.Alps; import org.springframework.http.MediaType; @@ -42,8 +43,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; public class AlpsJsonHttpMessageConverter extends MappingJackson2HttpMessageConverter implements ResponseBodyAdvice { - private static final MediaType ALPS_MEDIA_TYPE = MediaType.parseMediaType("application/alps+json"); - private final RootResourceInformationToAlpsDescriptorConverter converter; /** @@ -61,7 +60,7 @@ public class AlpsJsonHttpMessageConverter extends MappingJackson2HttpMessageConv mapper.setSerializationInclusion(Include.NON_EMPTY); setPrettyPrint(true); - setSupportedMediaTypes(Arrays.asList(ALPS_MEDIA_TYPE, MediaType.APPLICATION_JSON, MediaType.ALL)); + setSupportedMediaTypes(Arrays.asList(RestMediaTypes.ALPS_JSON, MediaType.APPLICATION_JSON, MediaType.ALL)); } /* diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/RootResourceInformationToAlpsDescriptorConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/RootResourceInformationToAlpsDescriptorConverter.java index bedcd3f5b..df724a29b 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/RootResourceInformationToAlpsDescriptorConverter.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/alps/RootResourceInformationToAlpsDescriptorConverter.java @@ -48,6 +48,7 @@ import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.core.mapping.ResourceType; import org.springframework.data.rest.core.mapping.SimpleResourceDescription; import org.springframework.data.rest.core.mapping.SupportedHttpMethods; +import org.springframework.data.rest.webmvc.ProfileController; import org.springframework.data.rest.webmvc.RootResourceInformation; import org.springframework.data.rest.webmvc.json.JacksonMetadata; import org.springframework.data.rest.webmvc.mapping.AssociationLinks; @@ -60,7 +61,6 @@ import org.springframework.hateoas.alps.Descriptor.DescriptorBuilder; import org.springframework.hateoas.alps.Doc; import org.springframework.hateoas.alps.Format; import org.springframework.hateoas.alps.Type; -import org.springframework.hateoas.mvc.ControllerLinkBuilder; import org.springframework.http.HttpMethod; import com.fasterxml.jackson.databind.ObjectMapper; @@ -147,9 +147,11 @@ public class RootResourceInformationToAlpsDescriptorConverter { ResourceMetadata metadata = mappings.getMetadataFor(type); + String href = ProfileController.getPath(this.configuration, metadata); + return descriptor().// id(getRepresentationDescriptorId(metadata)).// - href(entityLinks.linkFor(type).slash("schema").toString()).// + href(href).// doc(getDocFor(metadata.getItemResourceDescription())).// descriptors(buildPropertyDescriptors(type, metadata.getItemResourceRel())).// build(); @@ -178,7 +180,6 @@ public class RootResourceInformationToAlpsDescriptorConverter { * Builds a descriptor for the projection parameter of the given resource. * * @param metadata - * @param projectionConfiguration * @return */ private Descriptor buildProjectionDescriptor(ResourceMetadata metadata) { @@ -353,10 +354,11 @@ public class RootResourceInformationToAlpsDescriptorConverter { name(mapping.getRel()).doc(getDocFor(mapping.getDescription())); ResourceMetadata targetTypeMetadata = mappings.getMetadataFor(property.getActualType()); - String localPath = targetTypeMetadata.getRel().concat("#") - .concat(getRepresentationDescriptorId(targetTypeMetadata)); - Link link = ControllerLinkBuilder.linkTo(AlpsController.class).slash(AlpsController.ALPS_ROOT_MAPPING) - .slash(localPath).withSelfRel(); + + String href = ProfileController.getPath(configuration, targetTypeMetadata) + + "#" + getRepresentationDescriptorId(targetTypeMetadata); + + Link link = new Link(href).withSelfRel(); builder.// type(Type.SAFE).// diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index e2d70b7ea..e3c6f51ce 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -73,6 +73,7 @@ import org.springframework.data.rest.core.support.UnwrappingRepositoryInvokerFac import org.springframework.data.rest.webmvc.BasePathAwareController; import org.springframework.data.rest.webmvc.BasePathAwareHandlerMapping; import org.springframework.data.rest.webmvc.BaseUri; +import org.springframework.data.rest.webmvc.ProfileResourceProcessor; import org.springframework.data.rest.webmvc.RepositoryRestController; import org.springframework.data.rest.webmvc.RepositoryRestExceptionHandler; import org.springframework.data.rest.webmvc.RepositoryRestHandlerAdapter; @@ -80,7 +81,6 @@ import org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping; import org.springframework.data.rest.webmvc.RestMediaTypes; import org.springframework.data.rest.webmvc.ServerHttpRequestMethodArgumentResolver; import org.springframework.data.rest.webmvc.alps.AlpsJsonHttpMessageConverter; -import org.springframework.data.rest.webmvc.alps.AlpsResourceProcessor; import org.springframework.data.rest.webmvc.alps.RootResourceInformationToAlpsDescriptorConverter; import org.springframework.data.rest.webmvc.convert.UriListHttpMessageConverter; import org.springframework.data.rest.webmvc.json.DomainObjectReader; @@ -143,6 +143,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; * * @author Oliver Gierke * @author Jon Brisbin + * @author Greg Turnquist */ @Configuration @EnableHypermediaSupport(type = HypermediaType.HAL) @@ -769,8 +770,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon } @Bean - public AlpsResourceProcessor alpsResourceProcessor() { - return new AlpsResourceProcessor(config()); + public ProfileResourceProcessor profileResourceProcessor(RepositoryRestConfiguration config) { + return new ProfileResourceProcessor(config); } // diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/CommonWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/CommonWebTests.java index 2b421a13e..fdd354b3e 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/CommonWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/CommonWebTests.java @@ -27,6 +27,7 @@ import java.util.Map; import net.minidev.json.JSONArray; import org.junit.Test; + import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; import org.springframework.http.MediaType; @@ -64,6 +65,7 @@ public abstract class CommonWebTests extends AbstractWebIntegrationTests { /** * @see DATAREST-113 + * @see DATAREST-638 */ @Test public void exposesSchemasForResourcesExposed() throws Exception { @@ -75,12 +77,18 @@ public abstract class CommonWebTests extends AbstractWebIntegrationTests { Link link = client.assertHasLinkWithRel(rel, response); // Resource - client.request(link); + client.follow(link).andExpect(status().is2xxSuccessful()); - // Schema - TODO:Improve by using hypermedia - mvc.perform(get(link.expand().getHref() + "/schema").// - accept(MediaType.parseMediaType("application/schema+json"))).// - andExpect(status().isOk()); + Link profileLink = client.discoverUnique(link, "profile"); + + // Default metadata + client.follow(profileLink).andExpect(status().is2xxSuccessful()); + + // JSON Schema + client.follow(profileLink, RestMediaTypes.SCHEMA_JSON).andExpect(status().is2xxSuccessful()); + + // ALPS + client.follow(profileLink, RestMediaTypes.ALPS_JSON).andExpect(status().is2xxSuccessful()); } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/ProfileIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/ProfileIntegrationTests.java new file mode 100644 index 000000000..2f24d66d6 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/ProfileIntegrationTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2015 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.data.rest.webmvc; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; +import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurerAdapter; +import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkDiscoverers; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * Series of tests to verify {@link ProfileController} serves ALPS and JSON Schema metadata from the root level and + * at collection resource levels. + * + * @author Greg Turnquist + * @since 2.4 + */ +@WebAppConfiguration +@ContextConfiguration(classes = { JpaRepositoryConfig.class, ProfileIntegrationTests.Config.class}) +public class ProfileIntegrationTests extends AbstractControllerIntegrationTests { + + @Autowired WebApplicationContext context; + @Autowired LinkDiscoverers discoverers; + + private static final String ROOT_URI = "/api"; + + @Configuration + static class Config extends RepositoryRestConfigurerAdapter { + + @Override + public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config) { + config.setBasePath(ROOT_URI); + } + } + + TestMvcClient client; + + @Before + public void setUp() { + + MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build(); + this.client = new TestMvcClient(mvc, this.discoverers); + } + + /** + * @see DATAREST-230 + * @see DATAREST-638 + */ + @Test + public void exposesProfileLink() throws Exception { + + client.follow(ROOT_URI)// + .andExpect(status().is2xxSuccessful())// + .andExpect(jsonPath("$._links.profile.href", endsWith(ProfileController.PROFILE_ROOT_MAPPING))); + } + + /** + * @see DATAREST-230 + * @see DATAREST-638 + */ + @Test + public void profileRootLinkContainsMetadataForEachRepo() throws Exception { + + Link profileLink = client.discoverUnique(new Link(ROOT_URI), ProfileResourceProcessor.PROFILE_REL); + + assertThat(client.discoverUnique(profileLink, "self", MediaType.ALL), is(notNullValue())); + assertThat(client.discoverUnique(profileLink, "people", MediaType.ALL), is(notNullValue())); + assertThat(client.discoverUnique(profileLink, "items", MediaType.ALL), is(notNullValue())); + assertThat(client.discoverUnique(profileLink, "authors", MediaType.ALL), is(notNullValue())); + assertThat(client.discoverUnique(profileLink, "books", MediaType.ALL), is(notNullValue())); + assertThat(client.discoverUnique(profileLink, "orders", MediaType.ALL), is(notNullValue())); + assertThat(client.discoverUnique(profileLink, "receipts", MediaType.ALL), is(notNullValue())); + assertThat(client.discoverUnique(profileLink, "addresses", MediaType.ALL), is(notNullValue())); + } + + + + /** + * @see DATAREST-638 + */ + @Test + public void profileLinkOnCollectionResourceLeadsToRepositorySpecificMetadata() throws Exception { + + Link peopleLink = client.discoverUnique(new Link(ROOT_URI), "people"); + Link profileLink = client.discoverUnique(peopleLink, ProfileResourceProcessor.PROFILE_REL); + + client.follow(profileLink, RestMediaTypes.ALPS_JSON) + .andExpect(status().is2xxSuccessful()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, RestMediaTypes.ALPS_JSON_VALUE)); + + client.follow(profileLink, RestMediaTypes.SCHEMA_JSON) + .andExpect(status().is2xxSuccessful()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, RestMediaTypes.SCHEMA_JSON_VALUE)); + } + +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/TestMvcClient.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/TestMvcClient.java index 6b1727e58..21f928863 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/TestMvcClient.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/TestMvcClient.java @@ -174,7 +174,30 @@ public class TestMvcClient { * @throws Exception */ public ResultActions follow(String href) throws Exception { - return mvc.perform(get(href)); + return follow(href, MediaType.ALL); + } + + /** + * Folow Link with a specific Accept header (media type). + * + * @param link + * @param accept + * @return + * @throws Exception + */ + public ResultActions follow(Link link, MediaType accept) throws Exception { + return follow(link.expand().getHref(), accept); + } + + /** + * Follow URL supplied as a string with a specific Accept header. + * @param href + * @param accept + * @return + * @throws Exception + */ + public ResultActions follow(String href, MediaType accept) throws Exception { + return mvc.perform(get(href).header(HttpHeaders.ACCEPT, accept.toString())); } /** diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/alps/AlpsControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/alps/AlpsControllerIntegrationTests.java index a1dada7c0..525f747c3 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/alps/AlpsControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/alps/AlpsControllerIntegrationTests.java @@ -17,15 +17,19 @@ package org.springframework.data.rest.webmvc.alps; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import org.junit.Before; import org.junit.Test; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.webmvc.AbstractControllerIntegrationTests; +import org.springframework.data.rest.webmvc.ProfileController; +import org.springframework.data.rest.webmvc.RestMediaTypes; import org.springframework.data.rest.webmvc.TestMvcClient; import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurerAdapter; import org.springframework.data.rest.webmvc.jpa.Item; @@ -82,31 +86,21 @@ public class AlpsControllerIntegrationTests extends AbstractControllerIntegratio * @see DATAREST-230 */ @Test - public void exposesProfileLink() throws Exception { - - client.follow("/")// - .andExpect(status().is2xxSuccessful())// - .andExpect(jsonPath("$._links.profile.href", endsWith(AlpsController.ALPS_ROOT_MAPPING))); - } - - /** - * @see DATAREST-230 - */ - @Test - public void alpsResourceExposesResourcePerCollectionResource() throws Exception { + public void exposesAlpsCollectionResources() throws Exception { Link profileLink = client.discoverUnique("profile"); + Link peopleLink = client.discoverUnique(profileLink, "people", MediaType.ALL); - assertThat(client.discoverUnique(profileLink, "orders", MediaType.ALL), is(notNullValue())); - assertThat(client.discoverUnique(profileLink, "people", MediaType.ALL), is(notNullValue())); - assertThat(client.discoverUnique(profileLink, "items", MediaType.ALL), is(notNullValue())); + client.follow(peopleLink, RestMediaTypes.ALPS_JSON)// + .andExpect(jsonPath("$.version").value("1.0"))// + .andExpect(jsonPath("$.descriptors[*].name", hasItems("people", "person"))); } /** - * @see DATAREST-230 + * @see DATAREST-638 */ @Test - public void exposesAlpsCollectionResources() throws Exception { + public void verifyThatAlpsIsDefaultProfileFormat() throws Exception { Link profileLink = client.discoverUnique("profile"); Link peopleLink = client.discoverUnique(profileLink, "people", MediaType.ALL); @@ -114,6 +108,7 @@ public class AlpsControllerIntegrationTests extends AbstractControllerIntegratio client.follow(peopleLink)// .andExpect(jsonPath("$.version").value("1.0"))// .andExpect(jsonPath("$.descriptors[*].name", hasItems("people", "person"))); + } /** @@ -125,7 +120,7 @@ public class AlpsControllerIntegrationTests extends AbstractControllerIntegratio Link profileLink = client.discoverUnique("profile"); Link itemsLink = client.discoverUnique(profileLink, "items", MediaType.ALL); - client.follow(itemsLink)// + client.follow(itemsLink, RestMediaTypes.ALPS_JSON)// // Exposes standard property .andExpect(jsonPath("$.descriptors[*].descriptors[*].name", hasItems("name"))) // Does not expose explicitly @JsonIgnored property @@ -145,8 +140,8 @@ public class AlpsControllerIntegrationTests extends AbstractControllerIntegratio assertThat(itemsLink, is(notNullValue())); - client.follow(itemsLink)// - .andExpect(jsonPath("$.descriptors[?(@.id == 'item-representation')].href", is(notNullValue()))); + client.follow(itemsLink, RestMediaTypes.ALPS_JSON)// + .andExpect(jsonPath("$.descriptors[?(@.id == 'item-representation')][0].href", endsWith("/profile/items"))); } /** @@ -163,8 +158,9 @@ public class AlpsControllerIntegrationTests extends AbstractControllerIntegratio jsonPath += "descriptors[?(@.name == 'father')][0]."; // First father descriptor jsonPath += "rt"; // Return type - client.follow(usersLink)// - .andExpect(jsonPath(jsonPath, allOf(containsString("alps"), endsWith("-representation")))); + client.follow(usersLink, RestMediaTypes.ALPS_JSON)// + .andExpect(jsonPath(jsonPath, + allOf(containsString(ProfileController.PROFILE_ROOT_MAPPING), endsWith("-representation")))); } /** @@ -176,7 +172,7 @@ public class AlpsControllerIntegrationTests extends AbstractControllerIntegratio Link profileLink = client.discoverUnique("profile"); Link itemsLink = client.discoverUnique(profileLink, "items", MediaType.ALL); - client.follow(itemsLink)// + client.follow(itemsLink, RestMediaTypes.ALPS_JSON)// // Exposes identifier if configured to .andExpect(jsonPath("$.descriptors[*].descriptors[*].name", hasItems("id", "name"))); } diff --git a/src/main/asciidoc/metadata.adoc b/src/main/asciidoc/metadata.adoc index 12a5ae35a..0e68363a2 100644 --- a/src/main/asciidoc/metadata.adoc +++ b/src/main/asciidoc/metadata.adoc @@ -26,7 +26,7 @@ document would look like this: "href" : "http://localhost:8080/addresses" }, "profile" : { - "href" : "http://localhost:8080/alps" + "href" : "http://localhost:8080/profile" } } } @@ -36,29 +36,29 @@ A *profile* link, as defined in https://tools.ietf.org/html/rfc6906[RFC 6906], i http://tools.ietf.org/html/draft-amundsen-richardson-foster-alps-00[ALPS draft spec] is meant to define a particular profile format which we'll explore further down in this section. -If you navigate into the *profile* link at `localhost:8080/alps`, you would see something like this: +If you navigate into the *profile* link at `localhost:8080/profile`, you would see something like this: [source,javascript] ---- { - "version" : "1.0", - "descriptors" : [ { - "href" : "http://localhost:8080/alps/persons", - "name" : "persons" - }, { - "href" : "http://localhost:8080/alps/addresses", - "name" : "addresses" - } ] + "_links" : { + "self" : { + "href" : "http://localhost:8080/profile" + }, + "persons" : { + "href" : "http://localhost:8080/profile/persons" + }, + "addresses" : { + "href" : "http://localhost:8080/profile/addresses" + } + } } ---- IMPORTANT: At the root level, *profile* is a single link and hence can't handle serving up more than one application profile. That -is why you must navigate to `/alps` to find a link for each resource's ALPS metadata. +is why you must navigate to `/profile` to find a link for each resource's metadata. -NOTE: This JSON document has a media type of `application/alps+json`. This is different than the previous JSON document, which had -a media type of `application/hal+json`. These formats are different and governed by different specs. - -Let's navigate to `/alps/persons` and look at the profile data for a `Person` resource. +Let's navigate to `/profile/persons` and look at the profile data for a `Person` resource. [source,javascript] ---- @@ -78,7 +78,7 @@ Let's navigate to `/alps/persons` and look at the profile data for a `Person` re }, { "name" : "address", "type" : "SAFE", - "rt" : "http://localhost:8080/addresses#address" + "rt" : "http://localhost:8080/profile/addresses#address" } ] }, { "id" : "create-persons", <2> @@ -119,7 +119,32 @@ of the attributes. <2> After the resource representation are all the supported operations. This one is how to create a new `Person`. <3> The name is *persons*, which indicates that a POST should be applied to the whole collection, not a single *person*. <4> The *type* is `UNSAFE` because this operation can alter the state of the system. -<5> + +NOTE: This JSON document has a media type of `application/alps+json`. This is different than the previous JSON document, which had +a media type of `application/hal+json`. These formats are different and governed by different specs. + +You will also find a "profile" link shown in the collection of *_links* when you are looking at a collection resource. + +[source,javascript] +---- +{ + "_links" : { + "self" : { + "href" : "http://localhost:8080/persons" <1> + }, + ... other links ... + "profile" : { + "href" : "http://localhost:8080/profile/persons" <2> + } + }, + ... +} +---- + +<1> This HAL document respresents the `Person` collection. +<2> It has a *profile* link to the same URI for metadata. + +The *profile* link, again, will serve up ALPS by default or if you use an http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1[Accept header] of *application/alps+json*. [[metadata.alps.control-types]] === Hypermedia control types @@ -253,7 +278,7 @@ As you can see, this defines details to display for a `Person` resource. They al "format" : "TEXT" }, "type" : "SAFE", - "rt" : "http://localhost:8080/addresses#address" + "rt" : "http://localhost:8080/profile/addresses#address" } ] } ... @@ -271,9 +296,108 @@ NOTE: Spring MVC (which is the essence of a Spring Data REST application) suppor properties files with different messages. -//= JSON Schema +[[metadata.json-schema]] +== JSON Schema -//TBD +http://json-schema.org/[JSON Schema] is another form of metadata supported by Spring Data REST. Per their website, JSON Schema has the following advantages: + +* describes your existing data format +* clear, human- and machine-readable documentation +* complete structural validation, useful for automated testing and validating client-submitted data + +As shown in the <>, you can reach this data by navigating from the root URI to the "profile" link. + +[source,javascript] +---- +{ + "_links" : { + "self" : { + "href" : "http://localhost:8080/profile" + }, + "persons" : { + "href" : "http://localhost:8080/profile/persons" + }, + "addresses" : { + "href" : "http://localhost:8080/profile/addresses" + } + } +} +---- + +These links are the same as shown earlier. To retrieve JSON Schema you invoke them with Accept header *application/schema+json*. + +In this case, if you executed `curl -H 'Accept:application/schema+json' http://localhost:8080/profile/persons`, you would see something like this: + +[source,javascript] +---- +{ + "title" : "org.springframework.data.rest.webmvc.jpa.Person", <1> + "properties" : { <2> + "firstName" : { + "readOnly" : false, + "type" : "string" + }, + "lastName" : { + "readOnly" : false, + "type" : "string" + }, + "siblings" : { + "readOnly" : false, + "type" : "string", + "format" : "uri" + }, + "created" : { + "readOnly" : false, + "type" : "string", + "format" : "date-time" + }, + "father" : { + "readOnly" : false, + "type" : "string", + "format" : "uri" + }, + "weight" : { + "readOnly" : false, + "type" : "integer" + }, + "height" : { + "readOnly" : false, + "type" : "integer" + } + }, + "descriptors" : { }, + "type" : "object", + "$schema" : "http://json-schema.org/draft-04/schema#" +} +---- + +<1> The type that was exported +<2> A listing of properties + +There are more details if your resources have links to other resources. + +You will also find a "profile" link shown in the collection of *_links* when you are looking at a collection resource. + +[source,javascript] +---- +{ + "_links" : { + "self" : { + "href" : "http://localhost:8080/persons" <1> + }, + ... other links ... + "profile" : { + "href" : "http://localhost:8080/profile/persons" <2> + } + }, + ... +} +---- + +<1> This HAL document respresents the `Person` collection. +<2> It has a *profile* link to the same URI for metadata. + +The *profile* link, again, will serve up <> by default. If you supply it with an http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1[Accept header] of *application/schema+json*, it will render the JSON Schema representation. //= JSON Patch