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.
This commit is contained in:
Greg Turnquist
2015-08-10 23:56:52 -05:00
committed by Oliver Gierke
parent 5f7f912e69
commit 345c198a75
14 changed files with 524 additions and 136 deletions

View File

@@ -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<Object>(headers, HttpStatus.OK);
}
/**
* List a profile link for each exported repository.
*
* @return
*/
@RequestMapping(value = PROFILE_ROOT_MAPPING, method = GET)
HttpEntity<ResourceSupport> 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<ResourceSupport>(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();
}
}
}

View File

@@ -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<RepositoryLinksResource> {
public class ProfileResourceProcessor implements ResourceProcessor<RepositoryLinksResource> {
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;
}

View File

@@ -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;

View File

@@ -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<JsonSchema> schema(RootResourceInformation resourceInformation) {
JsonSchema schema = jsonSchemaConverter.convert(resourceInformation.getDomainType());

View File

@@ -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);
}

View File

@@ -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<Object>(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> alps() {
verifyAlpsEnabled();
List<Descriptor> descriptors = new ArrayList<Descriptor>();
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>(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<RootResourceInformation> descriptor(RootResourceInformation information) {
verifyAlpsEnabled();

View File

@@ -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<Object> {
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));
}
/*

View File

@@ -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).//

View File

@@ -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);
}
//

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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()));
}
/**

View File

@@ -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")));
}

View File

@@ -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 <<metadata.alps,previous section>>, 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 <<metadata.alps,ALPS>> 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