#1038 - Update documentation and testing for ALPS.

Also make the ALPS types support deserialization using @JsonCreator-based private constructor calls.
This commit is contained in:
Greg Turnquist
2019-08-13 13:01:49 -05:00
parent 30ddf1873c
commit f15740fb25
9 changed files with 425 additions and 9 deletions

View File

@@ -2,6 +2,7 @@
= Media types
:code-dir: ../../../src/docs/java/org/springframework/hateoas
:resource-dir: ../../../src/docs/resources/org/springframework/hateoas
:test-dir: ../../../src/test/java/org/springframework/hateoas
:linkattrs:
[[mediatypes.hal]]
@@ -408,6 +409,86 @@ https://github.com/spring-projects/spring-hateoas/issues[open a ticket, window="
NOTE: *UBER media type* is not associated in any way with *Uber Technologies Inc.*, the ride sharing company.
[[mediatypes.alps]]
== ALPS - Application-Level Profile Semantics
https://tools.ietf.org/html/draft-amundsen-richardson-foster-alps-01[ALPS, window="_blank"] is a media type for providing
profile-based metadata about another resource.
[quote, Mike Amundsen, ALPS spec]
____
An ALPS document can be used as a profile to
explain the application semantics of a document with an application-
agnostic media type (such as HTML, HAL, Collection+JSON, Siren,
etc.). This increases the reusability of profile documents across
media types.
____
ALPS requires no special activation. Instead you "build" an `Alps` record and return it from either a Spring MVC or a Spring WebFlux web method as shown below:
.Building an `Alps` record
====
[source, java, tabsize=2, indent=0]
----
include::{test-dir}/support/WebMvcEmployeeController.java[tag=alps-profile]
----
* This example leverages `PropertyUtils.getExposedProperties()` to extract metadata about the domain object's attributes.
====
This fragment has test data plugged in. It yields JSON like this:
.ALPS JSON
====
----
{
"version": "1.0",
"doc": {
"format": "TEXT",
"href": "https://example.org/samples/full/doc.html",
"value": "value goes here"
},
"descriptor": [
{
"id": "class field [name]",
"name": "name",
"type": "SEMANTIC",
"descriptor": [
{
"id": "embedded"
}
],
"ext": {
"id": "ext [name]",
"href": "https://example.org/samples/ext/name",
"value": "value goes here"
},
"rt": "rt for [name]"
},
{
"id": "class field [role]",
"name": "role",
"type": "SEMANTIC",
"descriptor": [
{
"id": "embedded"
}
],
"ext": {
"id": "ext [role]",
"href": "https://example.org/samples/ext/role",
"value": "value goes here"
},
"rt": "rt for [role]"
}
]
}
----
====
Instead of linking each field "automatically" to a domain object's fields, you can write them by hand if you like. It's also possible
to use Spring Framework's message bundles and the `MessageSource` interface. This gives you the ability to delegate these values to
locale-specific message bundles and even internationalize the metadata.
[[mediatypes.custom]]
== Registering a custom media type

View File

@@ -24,7 +24,9 @@ import org.springframework.hateoas.mediatype.alps.Descriptor.DescriptorBuilder;
import org.springframework.hateoas.mediatype.alps.Doc.DocBuilder;
import org.springframework.hateoas.mediatype.alps.Ext.ExtBuilder;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
/**
@@ -38,14 +40,23 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
*/
@Value
@Builder(builderMethodName = "alps")
@JsonPropertyOrder({"version", "doc", "descriptor"})
@JsonPropertyOrder({ "version", "doc", "descriptor" })
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Alps {
private final String version = "1.0";
private final String version;
private final Doc doc;
private final List<Descriptor> descriptor;
@JsonCreator
private Alps(@JsonProperty("version") String version, @JsonProperty("doc") Doc doc,
@JsonProperty("descriptor") List<Descriptor> descriptor) {
this.version = "1.0";
this.doc = doc;
this.descriptor = descriptor;
}
/**
* Returns a new {@link DescriptorBuilder}.
*

View File

@@ -20,7 +20,9 @@ import lombok.Value;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
/**
@@ -33,14 +35,32 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
*/
@Value
@Builder
@JsonPropertyOrder({"id", "href", "name", "type", "doc", "descriptor", "ext"})
@JsonPropertyOrder({ "id", "href", "name", "type", "doc", "descriptor", "ext" })
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Descriptor {
private final String id, href, name;
private final String id;
private final String href;
private final String name;
private final Doc doc;
private final Type type;
private final Ext ext;
private final String rt;
private final List<Descriptor> descriptor;
@JsonCreator
private Descriptor(@JsonProperty("id") String id, @JsonProperty("href") String href,
@JsonProperty("name") String name, @JsonProperty("doc") Doc doc, @JsonProperty("type") Type type,
@JsonProperty("ext") Ext ext, @JsonProperty("rt") String rt,
@JsonProperty("descriptor") List<Descriptor> descriptor) {
this.id = id;
this.href = href;
this.name = name;
this.doc = doc;
this.type = type;
this.ext = ext;
this.rt = rt;
this.descriptor = descriptor;
}
}

View File

@@ -15,13 +15,14 @@
*/
package org.springframework.hateoas.mediatype.alps;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;
import org.springframework.util.Assert;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
/**
@@ -34,12 +35,12 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
*/
@Value
@Builder
@AllArgsConstructor
@JsonPropertyOrder({"format", "href", "value"})
@JsonPropertyOrder({ "format", "href", "value" })
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Doc {
private final String href, value;
private final String href;
private final String value;
private final Format format;
/**
@@ -57,4 +58,13 @@ public class Doc {
this.value = value;
this.format = format;
}
@JsonCreator
private Doc(@JsonProperty("href") String href, @JsonProperty("value") String value,
@JsonProperty("format") Format format) {
this.href = href;
this.value = value;
this.format = format;
}
}

View File

@@ -18,6 +18,8 @@ package org.springframework.hateoas.mediatype.alps;
import lombok.Builder;
import lombok.Value;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
/**
@@ -30,10 +32,18 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
*/
@Value
@Builder
@JsonPropertyOrder({"id", "href", "value"})
@JsonPropertyOrder({ "id", "href", "value" })
public class Ext {
private final String id;
private final String href;
private final String value;
@JsonCreator
private Ext(@JsonProperty("id") String id, @JsonProperty("href") String href, @JsonProperty("value") String value) {
this.id = id;
this.href = href;
this.value = value;
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2019 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
*
* https://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.mediatype.alps;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.*;
import reactor.test.StepVerifier;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.WebClientConfigurer;
import org.springframework.hateoas.support.WebFluxEmployeeController;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.config.EnableWebFlux;
/**
* @author Greg Turnquist
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration
public class AlpsWebFluxIntegrationTest {
@Autowired WebTestClient testClient;
@Test
void profileEndpointReturnsAlps() {
this.testClient.get().uri("/profile") //
.accept(MediaTypes.ALPS_JSON) //
.exchange() //
.expectStatus().isOk() //
.returnResult(Alps.class).getResponseBody() //
.as(StepVerifier::create) //
.expectNextMatches(alps -> {
assertThat(alps.getVersion()).isEqualTo("1.0");
assertThat(alps.getDoc().getFormat()).isEqualByComparingTo(Format.TEXT);
assertThat(alps.getDoc().getHref()).isEqualTo("https://example.org/samples/full/doc.html");
assertThat(alps.getDoc().getValue()).isEqualTo("value goes here");
assertThat(alps.getDescriptor()).hasSize(2);
assertThat(alps.getDescriptor().get(0).getId()).isEqualTo("class field [name]");
assertThat(alps.getDescriptor().get(0).getName()).isEqualTo("name");
assertThat(alps.getDescriptor().get(0).getType()).isEqualByComparingTo(Type.SEMANTIC);
assertThat(alps.getDescriptor().get(0).getDescriptor()).hasSize(1);
assertThat(alps.getDescriptor().get(0).getDescriptor().get(0).getId()).isEqualTo("embedded");
assertThat(alps.getDescriptor().get(0).getExt().getId()).isEqualTo("ext [name]");
assertThat(alps.getDescriptor().get(0).getExt().getHref()).isEqualTo("https://example.org/samples/ext/name");
assertThat(alps.getDescriptor().get(0).getExt().getValue()).isEqualTo("value goes here");
assertThat(alps.getDescriptor().get(0).getRt()).isEqualTo("rt for [name]");
assertThat(alps.getDescriptor().get(1).getId()).isEqualTo("class field [role]");
assertThat(alps.getDescriptor().get(1).getName()).isEqualTo("role");
assertThat(alps.getDescriptor().get(1).getType()).isEqualByComparingTo(Type.SEMANTIC);
assertThat(alps.getDescriptor().get(1).getDescriptor()).hasSize(1);
assertThat(alps.getDescriptor().get(1).getDescriptor().get(0).getId()).isEqualTo("embedded");
assertThat(alps.getDescriptor().get(1).getExt().getId()).isEqualTo("ext [role]");
assertThat(alps.getDescriptor().get(1).getExt().getHref()).isEqualTo("https://example.org/samples/ext/role");
assertThat(alps.getDescriptor().get(1).getExt().getValue()).isEqualTo("value goes here");
assertThat(alps.getDescriptor().get(1).getRt()).isEqualTo("rt for [role]");
return true;
}) //
.verifyComplete();
}
@Configuration
@EnableWebFlux
@EnableHypermediaSupport(type = HAL)
static class TestConfig {
@Bean
WebFluxEmployeeController employeeController() {
return new WebFluxEmployeeController();
}
@Bean
WebTestClient webTestClient(WebClientConfigurer webClientConfigurer, ApplicationContext ctx) {
return WebTestClient.bindToApplicationContext(ctx).build() //
.mutate() //
.exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()) //
.build();
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2019 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
*
* https://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.mediatype.alps;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.*;
import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.*;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.support.WebMvcEmployeeController;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
/**
* @author Greg Turnquist
*/
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration
public class AlpsWebMvcIntegrationTest {
@Autowired WebApplicationContext context;
MockMvc mockMvc;
@BeforeEach
void setUp() {
this.mockMvc = webAppContextSetup(this.context).build();
}
@Test
void profileEndpointReturnsAlps() throws Exception {
this.mockMvc.perform(get("/profile").accept(MediaTypes.ALPS_JSON_VALUE)) //
.andDo(print()) //
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.version", is("1.0"))) //
.andExpect(jsonPath("$.doc.format", is("TEXT")))
.andExpect(jsonPath("$.doc.href", is("https://example.org/samples/full/doc.html")))
.andExpect(jsonPath("$.doc.value", is("value goes here"))).andExpect(jsonPath("$.descriptor", hasSize(2)))
.andExpect(jsonPath("$.descriptor[0].id", is("class field [name]")))
.andExpect(jsonPath("$.descriptor[0].name", is("name")))
.andExpect(jsonPath("$.descriptor[0].type", is("SEMANTIC")))
.andExpect(jsonPath("$.descriptor[0].descriptor", hasSize(1)))
.andExpect(jsonPath("$.descriptor[0].descriptor[0].id", is("embedded")))
.andExpect(jsonPath("$.descriptor[0].ext.id", is("ext [name]")))
.andExpect(jsonPath("$.descriptor[0].ext.href", is("https://example.org/samples/ext/name")))
.andExpect(jsonPath("$.descriptor[0].ext.value", is("value goes here")))
.andExpect(jsonPath("$.descriptor[0].rt", is("rt for [name]")))
.andExpect(jsonPath("$.descriptor[1].id", is("class field [role]")))
.andExpect(jsonPath("$.descriptor[1].name", is("role")))
.andExpect(jsonPath("$.descriptor[1].type", is("SEMANTIC")))
.andExpect(jsonPath("$.descriptor[1].descriptor", hasSize(1)))
.andExpect(jsonPath("$.descriptor[1].descriptor[0].id", is("embedded")))
.andExpect(jsonPath("$.descriptor[1].ext.id", is("ext [role]")))
.andExpect(jsonPath("$.descriptor[1].ext.href", is("https://example.org/samples/ext/role")))
.andExpect(jsonPath("$.descriptor[1].ext.value", is("value goes here")))
.andExpect(jsonPath("$.descriptor[1].rt", is("rt for [role]")));
}
@Configuration
@EnableWebMvc
@EnableHypermediaSupport(type = HAL)
static class TestConfig {
@Bean
WebMvcEmployeeController employeeController() {
return new WebMvcEmployeeController();
}
}
}

View File

@@ -15,12 +15,17 @@
*/
package org.springframework.hateoas.support;
import static org.springframework.hateoas.MediaTypes.*;
import static org.springframework.hateoas.mediatype.PropertyUtils.*;
import static org.springframework.hateoas.mediatype.alps.Alps.*;
import static org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.*;
import static reactor.function.TupleUtils.*;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -30,6 +35,11 @@ import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.IanaLinkRelations;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Links;
import org.springframework.hateoas.mediatype.alps.Alps;
import org.springframework.hateoas.mediatype.alps.Descriptor;
import org.springframework.hateoas.mediatype.alps.Ext;
import org.springframework.hateoas.mediatype.alps.Format;
import org.springframework.hateoas.mediatype.alps.Type;
import org.springframework.hateoas.server.reactive.WebFluxLinkBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -174,4 +184,30 @@ public class WebFluxEmployeeController {
.build() //
);
}
@GetMapping(value = "/profile", produces = ALPS_JSON_VALUE)
Alps profile() {
return Alps.alps() //
.doc(doc() //
.href("https://example.org/samples/full/doc.html") //
.value("value goes here") //
.format(Format.TEXT) //
.build()) //
.descriptor(getExposedProperties(Employee.class).stream() //
.map(property -> Descriptor.builder() //
.id("class field [" + property.getName() + "]") //
.name(property.getName()) //
.type(Type.SEMANTIC) //
.ext(Ext.builder() //
.id("ext [" + property.getName() + "]") //
.href("https://example.org/samples/ext/" + property.getName()) //
.value("value goes here") //
.build()) //
.rt("rt for [" + property.getName() + "]") //
.descriptor(Collections.singletonList(Descriptor.builder().id("embedded").build())) //
.build()) //
.collect(Collectors.toList()))
.build();
}
}

View File

@@ -15,9 +15,13 @@
*/
package org.springframework.hateoas.support;
import static org.springframework.hateoas.MediaTypes.*;
import static org.springframework.hateoas.mediatype.PropertyUtils.*;
import static org.springframework.hateoas.mediatype.alps.Alps.*;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -29,6 +33,11 @@ import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.IanaLinkRelations;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.mediatype.alps.Alps;
import org.springframework.hateoas.mediatype.alps.Descriptor;
import org.springframework.hateoas.mediatype.alps.Ext;
import org.springframework.hateoas.mediatype.alps.Format;
import org.springframework.hateoas.mediatype.alps.Type;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -174,4 +183,32 @@ public class WebMvcEmployeeController {
.toUri()) //
.build();
}
// tag::alps-profile[]
@GetMapping(value = "/profile", produces = ALPS_JSON_VALUE)
Alps profile() {
return Alps.alps() //
.doc(doc() //
.href("https://example.org/samples/full/doc.html") //
.value("value goes here") //
.format(Format.TEXT) //
.build()) //
.descriptor(getExposedProperties(Employee.class).stream() //
.map(property -> Descriptor.builder() //
.id("class field [" + property.getName() + "]") //
.name(property.getName()) //
.type(Type.SEMANTIC) //
.ext(Ext.builder() //
.id("ext [" + property.getName() + "]") //
.href("https://example.org/samples/ext/" + property.getName()) //
.value("value goes here") //
.build()) //
.rt("rt for [" + property.getName() + "]") //
.descriptor(Collections.singletonList(Descriptor.builder().id("embedded").build())) //
.build()) //
.collect(Collectors.toList()))
.build();
}
// end::alps-profile[]
}