diff --git a/src/docs/java/org/springframework/hateoas/reactive/PersonController.java b/src/docs/java/org/springframework/hateoas/reactive/PersonController.java new file mode 100644 index 000000000..1dd9b2052 --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/reactive/PersonController.java @@ -0,0 +1,50 @@ +package org.springframework.hateoas.reactive; + +import org.springframework.hateoas.EntityModel; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.linkTo; +import static org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.methodOn; + +@Controller +@RequestMapping("/people") +class PersonController { + + @GetMapping + Flux showAll() { + return Flux.just(new PersonModel(new Person(1L))); + } + + @GetMapping("/{person}") + Mono show(@PathVariable Long person) { + return Mono.just(new PersonModel(new Person(person))); + } + + private Mono> getHeader(Long person) { + var baseLink = linkTo(methodOn(PersonController.class).showAll()); + return baseLink.slash(person.toString()).withSelfRel().toMono() + .map(l -> { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(l.toUri()); + return new ResponseEntity<>(headers, HttpStatus.CREATED); + }); + } + + record Person(Long id) { + } + + static class PersonModel extends EntityModel { + public PersonModel(Person person) { + super(person); + } + } +} + diff --git a/src/docs/java/org/springframework/hateoas/reactive/PersonControllerTest.java b/src/docs/java/org/springframework/hateoas/reactive/PersonControllerTest.java new file mode 100644 index 000000000..b07ab7c54 --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/reactive/PersonControllerTest.java @@ -0,0 +1,58 @@ +package org.springframework.hateoas.reactive; + +import org.junit.jupiter.api.Test; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkRelation; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.linkTo; +import static org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.methodOn; + +class PersonControllerTest { + @Test + public void testLink() { + Mono link = linkTo(methodOn(PersonController.class).showAll()).withRel("people").toMono(); + + StepVerifier.create(link) + .expectNextMatches(l -> { + assertThat(l.getRel()).isEqualTo(LinkRelation.of("people")); + assertThat(l.getHref()).endsWith("/people"); + return true; + }) + .verifyComplete(); + } + + @Test + public void testLink2() { + var person = new PersonController.Person(1L); + // /people + var baseLink = linkTo(methodOn(PersonController.class).showAll()); + // / 1 + var link = baseLink.slash(person.id().toString()).withSelfRel().toMono(); + + StepVerifier.create(link) + .expectNextMatches(l -> { + assertThat(l.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(l.getHref()).endsWith("/people/1"); + return true; + }) + .verifyComplete(); + } + + @Test + public void testLink3() { + var link = linkTo(methodOn(PersonController.class).show(1L)) + .withSelfRel().toMono(); + + StepVerifier.create(link) + .expectNextMatches(l -> { + assertThat(l.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(l.getHref()).endsWith("/people/1"); + return true; + }) + .verifyComplete(); + } +} diff --git a/src/main/asciidoc/server.adoc b/src/main/asciidoc/server.adoc index 169253472..9dd05073b 100644 --- a/src/main/asciidoc/server.adoc +++ b/src/main/asciidoc/server.adoc @@ -6,12 +6,14 @@ [[server.link-builder.webmvc]] == [[fundamentals.obtaining-links]] [[fundamentals.obtaining-links.builder]] Building links in Spring MVC -Now we have the domain vocabulary in place, but the main challenge remains: how to create the actual URIs to be wrapped into `Link` instances in a less fragile way. Right now, we would have to duplicate URI strings all over the place. Doing so is brittle and unmaintainable. +Now we have the domain vocabulary in place, but the main challenge remains: how to create the actual URIs to be wrapped into `Link` instances in a less fragile way. +Right now, we would have to duplicate URI strings all over the place. +Doing so is brittle and unmaintainable. Assume you have your Spring MVC controllers implemented as follows: ==== -[source, java] +[source,java] ---- @Controller class PersonController { @@ -25,16 +27,22 @@ class PersonController { ---- ==== -We see two conventions here. The first is a collection resource that is exposed through `@GetMapping` annotation of the controller method, with individual elements of that collection exposed as direct sub resources. The collection resource might be exposed at a simple URI (as just shown) or more complex ones (such as `/people/{id}/addresses`). Suppose you would like to link to the collection resource of all people. Following the approach from above would cause two problems: +We see two conventions here. +The first is a collection resource that is exposed through `@GetMapping` annotation of the controller method, with individual elements of that collection exposed as direct sub resources. +The collection resource might be exposed at a simple URI (as just shown) or more complex ones (such as `/people/{id}/addresses`). +Suppose you would like to link to the collection resource of all people. +Following the approach from above would cause two problems: -* To create an absolute URI, you would need to look up the protocol, hostname, port, servlet base, and other values. This is cumbersome and requires ugly manual string concatenation code. -* You probably do not want to concatenate the `/people` on top of your base URI, because you would then have to maintain the information in multiple places. If you change the mapping, you then have to change all the clients pointing to it. +* To create an absolute URI, you would need to look up the protocol, hostname, port, servlet base, and other values. +This is cumbersome and requires ugly manual string concatenation code. +* You probably do not want to concatenate the `/people` on top of your base URI, because you would then have to maintain the information in multiple places. +If you change the mapping, you then have to change all the clients pointing to it. Spring HATEOAS now provides a `WebMvcLinkBuilder` that lets you create links by pointing to controller classes. The following example shows how to do so: ==== -[source, java] +[source,java] ---- import static org.sfw.hateoas.server.mvc.WebMvcLinkBuilder.*; @@ -45,11 +53,14 @@ assertThat(link.getHref()).endsWith("/people"); ---- ==== -The `WebMvcLinkBuilder` uses Spring's `ServletUriComponentsBuilder` under the hood to obtain the basic URI information from the current request. Assuming your application runs at `http://localhost:8080/your-app`, this is exactly the URI on top of which you are constructing additional parts. The builder now inspects the given controller class for its root mapping and thus ends up with `http://localhost:8080/your-app/people`. You can also build more nested links as well. +The `WebMvcLinkBuilder` uses Spring's `ServletUriComponentsBuilder` under the hood to obtain the basic URI information from the current request. +Assuming your application runs at `http://localhost:8080/your-app`, this is exactly the URI on top of which you are constructing additional parts. +The builder now inspects the given controller class for its root mapping and thus ends up with `http://localhost:8080/your-app/people`. +You can also build more nested links as well. The following example shows how to do so: ==== -[source, java] +[source,java] ---- Person person = new Person(1L, "Dave", "Matthews"); // /person / 1 @@ -62,7 +73,7 @@ assertThat(link.getHref(), endsWith("/people/1")); The builder also allows creating URI instances to build up (for example, response header values): ==== -[source, java] +[source,java] ---- HttpHeaders headers = new HttpHeaders(); headers.setLocation(linkTo(PersonController.class).slash(person).toUri()); @@ -79,7 +90,7 @@ The first approach is to hand a `Method` instance to the `WebMvcLinkBuilder`. The following example shows how to do so: ==== -[source, java] +[source,java] ---- Method method = PersonController.class.getMethod("show", Long.class); Link link = linkTo(method, 2L).withSelfRel(); @@ -88,11 +99,13 @@ assertThat(link.getHref()).endsWith("/people/2")); ---- ==== -This is still a bit dissatisfying, as we have to first get a `Method` instance, which throws an exception and is generally quite cumbersome. At least we do not repeat the mapping. An even better approach is to have a dummy method invocation of the target method on a controller proxy, which we can create by using the `methodOn(…)` helper. +This is still a bit dissatisfying, as we have to first get a `Method` instance, which throws an exception and is generally quite cumbersome. +At least we do not repeat the mapping. +An even better approach is to have a dummy method invocation of the target method on a controller proxy, which we can create by using the `methodOn(…)` helper. The following example shows how to do so: ==== -[source, java] +[source,java] ---- Link link = linkTo(methodOn(PersonController.class).show(2L)).withSelfRel(); @@ -100,7 +113,9 @@ assertThat(link.getHref()).endsWith("/people/2"); ---- ==== -`methodOn(…)` creates a proxy of the controller class that records the method invocation and exposes it in a proxy created for the return type of the method. This allows the fluent expression of the method for which we want to obtain the mapping. However, there are a few constraints on the methods that can be obtained by using this technique: +`methodOn(…)` creates a proxy of the controller class that records the method invocation and exposes it in a proxy created for the return type of the method. +This allows the fluent expression of the method for which we want to obtain the mapping. +However, there are a few constraints on the methods that can be obtained by using this technique: * The return type has to be capable of proxying, as we need to expose the method invocation on it. * The parameters handed into the methods are generally neglected (except the ones referred to through `@PathVariable`, because they make up the URI). @@ -115,7 +130,7 @@ Rendering the values defaults to the composite style by default. If you want the values to be rendered in the non-composite style, you can use the `@NonComposite` annotation with the request parameter handler method parameter: ==== -[source, java] +[source,java] ---- @Controller class PersonController { @@ -130,6 +145,7 @@ var link = linkTo(methodOn(PersonController.class).showAll(values)).withSelfRel( assertThat(link.getHref()).endsWith("/people?names=Matthews,Beauford"); <3> ---- + <1> We use the `@NonComposite` annotation to declare we want values to be rendered comma-separated. <2> We invoke the method using a list of values. <3> See how the request parameter is rendered in the expected format. @@ -138,17 +154,185 @@ assertThat(link.getHref()).endsWith("/people?names=Matthews,Beauford"); <3> NOTE: The reason we're exposing `@NonComposite` is that the composite way of rendering request parameters is baked into the internals of Spring's `UriComponents` builder and we only introduced that non-composite style in Spring HATEOAS 1.4. If we started from scratch today, we'd probably default to that style and rather let users opt into the composite style explicitly rather than the other way around. +[[server.link-builder.webmvc.kotlin]] +=== Kotlin extensions + +The following Kotlin extensions simplify creating links and adding them to the model: + +==== +[source,kotlin] +---- +import org.springframework.hateoas.server.mvc.* + +val link: Link = linkTo { findById("15") } withRel IanaLinkRelations.SELF + +val customer = EntityModel.of(Customer("15", "John Doe")) +customer.add(CustomerController::class) { + linkTo { findById(it.content.id) } withRel IanaLinkRelations.SELF + linkTo { findProductsById(it.content.id) } withRel REL_PRODUCTS +} +---- +==== + [[server.link-builder.webflux]] == Building links in Spring WebFlux -TODO +Assume you have your Spring WebFlux controller implemented as follows: + +==== +[source,java] +---- +@Controller +@RequestMapping("/people") +class PersonController { + + @GetMapping + Flux showAll() { … } + + @GetMapping("/{person}") + Mono show(@PathVariable Long person) { … } +} +---- +==== + +We see two conventions here. +The first is a collection resource that is exposed through @GetMapping annotation of the controller method, with individual elements of that collection exposed as direct sub resources. +The collection resource might be exposed at a simple URI (as just shown) or more complex ones (such as /people/{id}/addresses). +Suppose you would like to link to the collection resource of all people. +Following the approach from above would cause two problems: + +* To create an absolute URI, you would need to look up the protocol, hostname, port, servlet base, and other values. +This is cumbersome and requires ugly manual string concatenation code. +* You probably do not want to concatenate the /people on top of your base URI, because you would then have to maintain the information in multiple places. +If you change the mapping, you then have to change all the clients pointing to it. + +Spring HATEOAS provides a `WebFluxLinkBuilder` that lets you create links by pointing to controller methods. +The following example shows how to do so: + +==== +[source,java] +---- +import static org.sfw.hateoas.server.reactive.WebFluxLinkBuilder.*; + +Mono link = linkTo(methodOn(PersonController.class).showAll()).withRel("people").toMono(); + +StepVerifier.create(link) + .expectNextMatches(l -> { + assertThat(l.getRel()).isEqualTo(LinkRelation.of("people")); + assertThat(l.getHref()).endsWith("/people"); + return true; + }) + .verifyComplete(); +---- +==== + +The `WebFluxLinkBuilder` uses Spring’s `UriComponentsBuilder` under the hood to obtain the basic URI information from the current request. +Assuming your application runs at http://localhost:8080, this is exactly the URI on top of which you are constructing additional parts. +The builder now inspects the given controller method for its request mapping and thus ends up with http://localhost:8080/people. +You can also build more nested links as well. +The following example shows how to do so: + +==== +[source,java] +---- +var person = new PersonController.Person(1L); +// /people +var baseLink = linkTo(methodOn(PersonController.class).showAll()); +// / 1 +var link = baseLink.slash(person.id().toString()).withSelfRel().toMono(); + +StepVerifier.create(link) + .expectNextMatches(l -> { + assertThat(l.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(l.getHref()).endsWith("/people/1"); + return true; + }) + .verifyComplete(); +---- +==== + +The builder also allows creating URI instances to build up (for example, response header values): + +==== +[source,java] +---- +var baseLink = linkTo(methodOn(PersonController.class).showAll()); +return baseLink.slash(person.toString()).withSelfRel().toMono() + .map(l -> { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(l.toUri()); + return new ResponseEntity(headers, HttpStatus.CREATED); + }); +---- +==== + +You can also pass arguments to controller methods. +These arguments will be used to create the links. +The following example shows how to do so: + +==== +[source,java] +---- +var link = linkTo(methodOn(PersonController.class).show(1L)) + .withSelfRel().toMono(); + +StepVerifier.create(link) + .expectNextMatches(l -> { + assertThat(l.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(l.getHref()).endsWith("/people/1"); + return true; + }) + .verifyComplete(); +---- +==== + +`methodOn(…)` creates a proxy of the controller class that records the method invocation and exposes it in a proxy created for the return type of the method. +This allows the fluent expression of the method for which we want to obtain the mapping. +However, there are a few constraints on the methods that can be obtained by using this technique: + +* The return type has to be capable of proxying, as we need to expose the method invocation on it. +* The parameters handed into the methods are generally neglected (except the ones referred to through `@PathVariable`, because they make up the URI). + +[[server.link-builder.webflux.kotlin]] +=== Kotlin extensions + +The following Kotlin extensions simplify creating links and adding them to the model: + +==== +[source,kotlin] +---- +import org.springframework.hateoas.server.reactive.* + +val link: Mono = linkTo { findById("15") } withRel SELF + +val customer: Mono> = EntityModel.of(Customer("15", "John Doe")) + .add(CustomerController::class) { entity -> + linkTo { findById(entity.content.id) } withRel SELF + linkTo { findProductsById(entity.content.id) } withRel REL_PRODUCTS + } + +val customerWithLink: Mono = Mono.just(CustomerModel("15", "John Doe")) + .add { linkTo { findById(it.id) } withRel SELF } +---- +==== + +If you use Kotlin coroutines, you can use the following instead: + +==== +[source,kotlin] +---- +linkTo { findById("15") } awaitRel IanaLinkRelations.SELF +---- +==== [[server.affordances]] == Affordances -[quote, James J. Gibson, The Ecological Approach to Visual Perception (page 126)] +[quote,James J. Gibson,The Ecological Approach to Visual Perception (page 126)] ____ -The affordances of the environment are what it offers …​ what it provides or furnishes, either for good or ill. The verb 'to afford' is found in the dictionary, but the noun 'affordance' is not. I have made it up. +The affordances of the environment are what it offers …​ what it provides or furnishes, either for good or ill. +The verb 'to afford' is found in the dictionary, but the noun 'affordance' is not. +I have made it up. ____ REST-based resources provide not just data but controls. @@ -160,10 +344,11 @@ The following code shows how to take a *self* link and associate two more afford .Connecting affordances to `GET /employees/{id}` ==== -[source, java, indent=0, tabsize=2] +[source,java,indent=0,tabsize=2] ---- include::{code-dir}/EmployeeController.java[tag=get] ---- + <1> Create the *self* link. <2> Associate the `updateEmployee` method with the `self` link. <3> Associate the `partiallyUpdateEmployee` method with the `self` link. @@ -174,7 +359,7 @@ Imagine that the related methods *afforded* above look like this: .`updateEmpoyee` method that responds to `PUT /employees/{id}` ==== -[source, java, indent=0, tabsize=2] +[source,java,indent=0,tabsize=2] ---- include::{code-dir}/EmployeeController.java[tag=put] ---- @@ -182,7 +367,7 @@ include::{code-dir}/EmployeeController.java[tag=put] .`partiallyUpdateEmployee` method that responds to `PATCH /employees/{id}` ==== -[source, java, indent=0, tabsize=2] +[source,java,indent=0,tabsize=2] ---- include::{code-dir}/EmployeeController.java[tag=patch] ---- @@ -198,13 +383,18 @@ This can be achieved by using the `Affordances` API: .Using the `Affordances` API to manually register affordances ==== -[source, java, indent=0, tabsize=2] +[source,java,indent=0,tabsize=2] ---- include::{code-dir}/AffordancesSample.java[tag=affordances] ---- + <1> You start by creating an instance of `Affordances` from a `Link` instance creating the context for describing the affordances. -<2> Each affordance starts with the HTTP method it's supposed to support. We then register a type as payload description and name the affordance explicitly. The latter can be omitted and a default name will be derived from the HTTP method and input type name. This effectively creates the same affordance as the pointer to `EmployeeController.newEmployee(…)` created. -<3> The next affordance is built to reflect what's happening for the pointer to `EmployeeController.search(…)`. Here we define `Employee` to be the model for the response created and explicitly register ``QueryParameter``s. +<2> Each affordance starts with the HTTP method it's supposed to support. +We then register a type as payload description and name the affordance explicitly. +The latter can be omitted and a default name will be derived from the HTTP method and input type name. +This effectively creates the same affordance as the pointer to `EmployeeController.newEmployee(…)` created. +<3> The next affordance is built to reflect what's happening for the pointer to `EmployeeController.search(…)`. +Here we define `Employee` to be the model for the response created and explicitly register ``QueryParameter``s. ==== Affordances are backed by media type specific affordance models that translate the general affordance metadata into specific representations. @@ -229,7 +419,7 @@ In a Spring Boot application those components can be simply declared as Spring b .Registering a `ForwardedHeaderFilter` ==== -[source, java, tabsize=2, indent=0] +[source,java,tabsize=2,indent=0] ---- include::{code-dir}/ForwardedEnabledConfig.java[tags=code-1] ---- @@ -242,7 +432,7 @@ For a Spring WebFlux application, the reactive counterpart is `ForwardedHeaderTr .Registering a `ForwardedHeaderTransformer` ==== -[source, java, tabsize=2, indent=0] +[source,java,tabsize=2,indent=0] ---- include::{code-dir}/ForwardedEnabledConfig.java[tags=code-2] ---- @@ -255,7 +445,7 @@ With configuration as shown above in place, a request passing `X-Forwarded-…` .A request using `X-Forwarded-…` headers ==== -[source, bash] +[source,bash] ---- curl -v localhost:8080/employees \ -H 'X-Forwarded-Proto: https' \ @@ -266,7 +456,7 @@ curl -v localhost:8080/employees \ .The corresponding response with the links generated to consider those headers ==== -[source, javascript] +[source,javascript] ---- { "_embedded": { @@ -313,7 +503,7 @@ The methods essentially return links that point either to the collection resourc The following example shows how to use `EntityLinks`: ==== -[source, java] +[source,java] ---- EntityLinks links = …; LinkBuilder builder = links.linkFor(Customer.class); @@ -340,7 +530,7 @@ Beyond that, we assume that you adhere to the following URI mapping setup and co The following example shows an implementation of an `EntityLinks`-capable controller: ==== -[source, java] +[source,java] ---- @Controller @ExposesResourceFor(Order.class) <1> @@ -354,16 +544,18 @@ class OrderController { ResponseEntity order(@PathVariable("id") … ) { … } } ---- + <1> The controller indicates it's exposing collection and item resources for the entity `Order`. <2> Its collection resource is exposed under `/orders` -<3> That collection resource can handle `GET` requests. Add more methods for other HTTP methods at your convenience. +<3> That collection resource can handle `GET` requests. +Add more methods for other HTTP methods at your convenience. <4> An additional controller method to handle a subordinate resource taking a path variable to expose an item resource, i.e. a single `Order`. ==== With this in place, when you enable `EntityLinks` `@EnableHypermediaSupport` in your Spring MVC configuration, you can create links to the controller as follows: ==== -[source, java] +[source,java] ---- @Controller class PaymentController { @@ -382,6 +574,7 @@ class PaymentController { } } ---- + <1> Inject `EntityLinks` made available by `@EnableHypermediaSupport` in your configuration. <2> Use the APIs to build links by using the entity types instead of controller classes. ==== @@ -400,7 +593,7 @@ This usually looks like this: .Obtaining a link to an item resource ==== -[source, java] +[source,java] ---- entityLinks.linkToItemResource(order, order.getId()); ---- @@ -409,12 +602,13 @@ entityLinks.linkToItemResource(order, order.getId()); If you find yourself repeating those method calls the identifier extraction step can be pulled out into a reusable `Function` to be reused throughout different invocations: ==== -[source, java] +[source,java] ---- Function idExtractor = Order::getId; <1> entityLinks.linkToItemResource(order, idExtractor); <2> ---- + <1> The identifier extraction is externalized so that it can be held in a field or constant. <2> The link lookup using the extractor. ==== @@ -427,7 +621,7 @@ We can centralize the identifier extraction logic even more by obtaining a `Type .Using TypedEntityLinks ==== -[source, java] +[source,java] ---- class OrderController { @@ -446,6 +640,7 @@ class OrderController { } } ---- + <1> Inject an `EntityLinks` instance. <2> Indicate you're going to look up `Order` instances with a certain identifier extractor function. <3> Look up item resource links based on a sole `Order` instance. @@ -461,7 +656,7 @@ Making those available to the `EntityLinks` instance available for injection is .Declaring a custom EntityLinks implementation ==== -[source, java] +[source,java] ---- @Configuration class CustomEntityLinksConfiguration { @@ -481,7 +676,8 @@ If you want to make use of these, simply inject `RepositoryEntityLinks` explicit [[server.representation-model-assembler]] == [[fundamentals.resource-assembler]] Representation model assembler -As the mapping from an entity to a representation model must be used in multiple places, it makes sense to create a dedicated class responsible for doing so. The conversion contains very custom steps but also a few boilerplate steps: +As the mapping from an entity to a representation model must be used in multiple places, it makes sense to create a dedicated class responsible for doing so. +The conversion contains very custom steps but also a few boilerplate steps: . Instantiation of the model class . Adding a link with a `rel` of `self` pointing to the resource that gets rendered. @@ -490,7 +686,7 @@ Spring HATEOAS now provides a `RepresentationModelAssemblerSupport` base class t The following example shows how to use it: ==== -[source, java] +[source,java] ---- class PersonModelAssembler extends RepresentationModelAssemblerSupport { @@ -507,19 +703,23 @@ class PersonModelAssembler extends RepresentationModelAssemblerSupport people = Collections.singletonList(person); @@ -530,6 +730,26 @@ CollectionModel model = assembler.toCollectionModel(people); ---- ==== +NOTE: If you are fine with a `RepresentationModelAssembler` based purely on the domain type, you can use the `SimpleRepresentationModelAssembler` instead. + +=== Reactive model assembler + +With Spring WebFlux, you can use the `ReactiveRepresentationModelAssembler` or its `SimpleReactiveRepresentationModelAssembler` implementation. +If you prefer WebFlux with Kotlin coroutines, there are according `CoroutineRepresentationModelAssembler` and `SimpleCoroutineRepresentationModelAssembler` interfaces. +The following example transforms a `ReactiveRepresentationModelAssembler` to a coroutine model: + +==== +[source,kotlin] +---- +runBlocking { + val model = resourceAssembler.toModelAndAwait(Employee("Frodo"), exchange) + assertThat(model.content.name).isEqualTo("Frodo") +} +---- + +NOTE: `toCollectionModelAndAwait()` does the same for collection resources. +==== + [[server.processors]] == Representation Model Processors @@ -540,21 +760,21 @@ A perfect example is when you have a controller that deals with order fulfillmen Imagine having your ordering system producing this type of hypermedia: ==== -[source, json, tabsize=2] +[source,json,tabsize=2] ---- include::{resource-dir}/docs/order-plain.json[] ---- ==== -You wish to add a link so the client can make payment, but don't want to mix details about your `PaymentController` into -the `OrderController`. +You wish to add a link so the client can make payment, but don't want to mix details about your `PaymentController` into the `OrderController`. Instead of polluting the details of your ordering system, you can write a `RepresentationModelProcessor` like this: ==== -[source, java, tabsize=2] +[source,java,tabsize=2] ---- include::{code-dir}/PaymentProcessor.java[tag=code] ---- + <1> This processor will only be applied to `EntityModel` objects. <2> Manipulate the existing `EntityModel` object by adding an unconditional link. <3> Return the `EntityModel` so it can be serialized into the requested media type. @@ -563,7 +783,7 @@ include::{code-dir}/PaymentProcessor.java[tag=code] Register the processor with your application: ==== -[source, java, tabsize=2] +[source,java,tabsize=2] ---- include::{code-dir}/PaymentProcessingApp.java[tag=code] ---- @@ -572,10 +792,11 @@ include::{code-dir}/PaymentProcessingApp.java[tag=code] Now when you issue a hypermedia respresentation of an `Order`, the client receives this: ==== -[source, java, tabsize=2] +[source,java,tabsize=2] ---- include::{resource-dir}/docs/order-with-payment-link.json[] ---- + <1> You see the `LinkRelation.of("payments")` plugged in as this link's relation. <2> The URI was provided by the processor. ==== @@ -586,8 +807,10 @@ This example is quite simple, but you can easily: * Inject any services needed to conditionally add other links (e.g. `cancel`, `amend`) that are driven by state. * Leverage cross cutting services like Spring Security to add, remove, or revise links based upon the current user's context. -Also, in this example, the `PaymentProcessor` alters the provided `EntityModel`. You also have the power to -_replace_ it with another object. Just be advised the API requires the return type to equal the input type. +Also, in this example, the `PaymentProcessor` alters the provided `EntityModel`. +You also have the power to +_replace_ it with another object. +Just be advised the API requires the return type to equal the input type. [[server.processors.empty-collections]] === Processing empty collection models @@ -601,11 +824,15 @@ See <> for details. [[server.rel-provider]] == [[spis.rel-provider]] Using the `LinkRelationProvider` API -When building links, you usually need to determine the relation type to be used for the link. In most cases, the relation type is directly associated with a (domain) type. We encapsulate the detailed algorithm to look up the relation types behind a `LinkRelationProvider` API that lets you determine the relation types for single and collection resources. The algorithm for looking up the relation type follows: +When building links, you usually need to determine the relation type to be used for the link. +In most cases, the relation type is directly associated with a (domain) type. +We encapsulate the detailed algorithm to look up the relation types behind a `LinkRelationProvider` API that lets you determine the relation types for single and collection resources. +The algorithm for looking up the relation type follows: . If the type is annotated with `@Relation`, we use the values configured in the annotation. . If not, we default to the uncapitalized simple class name plus an appended `List` for the collection `rel`. . If the https://github.com/atteo/evo-inflector[EVO inflector] JAR is in the classpath, we use the plural of the single resource `rel` provided by the pluralizing algorithm. . `@Controller` classes annotated with `@ExposesResourceFor` (see <> for details) transparently look up the relation types for the type configured in the annotation, so that you can use `LinkRelationProvider.getItemResourceRelFor(MyController.class)` and get the relation type of the domain type exposed. -A `LinkRelationProvider` is automatically exposed as a Spring bean when you use `@EnableHypermediaSupport`. You can plug in custom providers by implementing the interface and exposing them as Spring beans in turn. +A `LinkRelationProvider` is automatically exposed as a Spring bean when you use `@EnableHypermediaSupport`. +You can plug in custom providers by implementing the interface and exposing them as Spring beans in turn. diff --git a/src/main/kotlin/org/springframework/hateoas/server/reactive/CoroutineRepresentationModelAssembler.kt b/src/main/kotlin/org/springframework/hateoas/server/reactive/CoroutineRepresentationModelAssembler.kt new file mode 100644 index 000000000..a7608b29c --- /dev/null +++ b/src/main/kotlin/org/springframework/hateoas/server/reactive/CoroutineRepresentationModelAssembler.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2019-2024 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.server.reactive + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.hateoas.CollectionModel +import org.springframework.hateoas.RepresentationModel +import org.springframework.web.server.ServerWebExchange + +/** + * Coroutine variant of [RepresentationModelAssembler]. + * + * @author Christoph Huber + */ +interface CoroutineRepresentationModelAssembler> { + /** + * Converts the given entity into a `D`, which extends [RepresentationModel]. + */ + suspend fun toModel(entity: T, exchange: ServerWebExchange): D + + /** + * Converts an [Iterable] or `T`s into an [Iterable] of [RepresentationModel] and wraps them + * in a [CollectionModel] instance. + */ + suspend fun toCollectionModel(entities: Flow< T>, exchange: ServerWebExchange): CollectionModel { + val entities = entities.map { toModel(it, exchange) }.toList() + return CollectionModel.of(entities) + } +} diff --git a/src/main/kotlin/org/springframework/hateoas/server/reactive/SimpleCoroutineRepresentationModelAssembler.kt b/src/main/kotlin/org/springframework/hateoas/server/reactive/SimpleCoroutineRepresentationModelAssembler.kt new file mode 100644 index 000000000..6312ba51a --- /dev/null +++ b/src/main/kotlin/org/springframework/hateoas/server/reactive/SimpleCoroutineRepresentationModelAssembler.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2019-2024 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.server.reactive + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.hateoas.CollectionModel +import org.springframework.hateoas.EntityModel +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +/** + * Coroutine variant of [SimpleRepresentationModelAssembler]. + * + * @author Christoph Huber + */ +interface SimpleCoroutineRepresentationModelAssembler : CoroutineRepresentationModelAssembler> { + /** + * Converts the given entity into a [EntityModel] wrapped in a [Mono]. + */ + override suspend fun toModel(entity: T, exchange: ServerWebExchange): EntityModel { + val resource = EntityModel.of(entity) + return addLinks(resource, exchange) + } + + /** + * Define links to add to every individual [EntityModel]. + */ + suspend fun addLinks(resource: EntityModel, exchange: ServerWebExchange): EntityModel { + return resource + } + + /** + * Converts all given entities into resources and wraps the collection as a resource as well. + * + * @see toModel + */ + override suspend fun toCollectionModel(entities: Flow, exchange: ServerWebExchange): CollectionModel> { + val entityModels = entities + .map { toModel(it, exchange) } + .toList() + return addLinks(CollectionModel.of(entityModels), exchange) + } + + /** + * Define links to add to the [CollectionModel] collection. + * + * @param resources must not be null. + * @return will never be null. + */ + suspend fun addLinks(resources: CollectionModel>, exchange: ServerWebExchange): CollectionModel> { + return resources + } +} diff --git a/src/main/kotlin/org/springframework/hateoas/server/reactive/WebFluxLinkBuilderDsl.kt b/src/main/kotlin/org/springframework/hateoas/server/reactive/WebFluxLinkBuilderDsl.kt new file mode 100644 index 000000000..fa3d24adb --- /dev/null +++ b/src/main/kotlin/org/springframework/hateoas/server/reactive/WebFluxLinkBuilderDsl.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2022 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.server.reactive + +import kotlinx.coroutines.reactor.awaitSingle +import org.reactivestreams.Publisher +import org.springframework.hateoas.Link +import org.springframework.hateoas.LinkRelation +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.methodOn +import org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.linkTo +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +import kotlin.reflect.KClass + +/** + * Create a [WebFluxLinkBuilder.WebFluxBuilder] pointing to a [func] method. + * + * @author Christoph Huber + */ +inline fun linkTo(func: C.() -> Unit): WebFluxLinkBuilder.WebFluxBuilder = linkTo(methodOn(C::class.java).apply(func)) + +/** + * Create a [Link] with the given [rel]. + * + * @author Christoph Huber + */ +infix fun WebFluxLinkBuilder.WebFluxBuilder.withRel(rel: LinkRelation): Mono = withRel(rel).toMono() +infix fun WebFluxLinkBuilder.WebFluxBuilder.withRel(rel: String): Mono = withRel(rel).toMono() +suspend infix fun WebFluxLinkBuilder.WebFluxBuilder.awaitRel(rel: LinkRelation): Link = withRel(rel).toMono().awaitSingle() +suspend infix fun WebFluxLinkBuilder.WebFluxBuilder.awaitRel(rel: String): Link = withRel(rel).toMono().awaitSingle() + +/** + * Adds the given [links] to this model. + * + * @author Christoph Huber + */ +infix fun > Mono.add(links: (R) -> Publisher) = flatMap { model -> + when (val linksToAdd = links(model)) { + is Flux -> linksToAdd.collectList().map { model.add(it) } + is Mono -> linksToAdd.map { model.add(it) } + else -> Mono.error(IllegalStateException("Unsupported Publisher $linksToAdd")) + } +} + +/** + * Add [links] to the [R] resource. + * + * @author Christoph Huber + */ +fun > R.add(controller: Class, links: WebFluxLinkBuilderDsl.(R) -> Unit): Mono { + val builder = WebFluxLinkBuilderDsl(controller, this) + builder.links(this) + return builder.build() +} + +/** + * Add [links] to the [R] resource. + * + * @author Christoph Huber + */ +fun > R.add(controller: KClass, links: WebFluxLinkBuilderDsl.(R) -> Unit): Mono { + return add(controller.java, links) +} + +/** + * Provide a [WebFluxLinkBuilder] DSL to help write idiomatic Kotlin code. + * + * @author Christoph Huber + */ +open class WebFluxLinkBuilderDsl>( + private val controller: Class, + private val resource: R, + private val links: MutableList> = mutableListOf() +) { + + /** + * Create a [WebFluxLinkBuilder.WebFluxBuilder] pointing to [func] method. + */ + fun linkTo(func: C.() -> R): WebFluxLinkBuilder.WebFluxBuilder = linkTo(methodOn(controller).run(func)) + + /** + * Add a link with the given [rel] to the [resource]. + */ + infix fun WebFluxLinkBuilder.WebFluxBuilder.withRel(rel: String): Mono { + return this withRel (LinkRelation.of(rel)) + } + + /** + * Add a link with the given [rel] to the [resource]. + */ + infix fun WebFluxLinkBuilder.WebFluxBuilder.withRel(rel: LinkRelation): Mono { + val link = withRel(rel).toMono() + links.add(link) + + return link + } + + fun build(): Mono = Flux.concat(links).collectList().map { resource.add(it) } +} diff --git a/src/test/kotlin/org/springframework/hateoas/server/mvc/WebMvcLinkBuilderDslUnitTest.kt b/src/test/kotlin/org/springframework/hateoas/server/mvc/WebMvcLinkBuilderDslUnitTest.kt index 2f0695057..61da4fea6 100644 --- a/src/test/kotlin/org/springframework/hateoas/server/mvc/WebMvcLinkBuilderDslUnitTest.kt +++ b/src/test/kotlin/org/springframework/hateoas/server/mvc/WebMvcLinkBuilderDslUnitTest.kt @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.hateoas.mvc +package org.springframework.hateoas.server.mvc import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.hateoas.* -import org.springframework.hateoas.server.mvc.* import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* diff --git a/src/test/kotlin/org/springframework/hateoas/server/reactive/SimpleCoroutineRepresentationModelAssemblerTest.kt b/src/test/kotlin/org/springframework/hateoas/server/reactive/SimpleCoroutineRepresentationModelAssemblerTest.kt new file mode 100644 index 000000000..20f888a95 --- /dev/null +++ b/src/test/kotlin/org/springframework/hateoas/server/reactive/SimpleCoroutineRepresentationModelAssemblerTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2018-2024 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.server.reactive + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.hateoas.EntityModel +import org.springframework.hateoas.Link +import org.springframework.mock.http.server.reactive.MockServerHttpRequest +import org.springframework.mock.web.server.MockServerWebExchange +import org.springframework.web.server.ServerWebExchange + +/** + * @author Christoph Huber + */ +internal class SimpleCoroutineRepresentationModelAssemblerTest { + private val request = MockServerHttpRequest.get("http://localhost:8080/api") + private val exchange = MockServerWebExchange.from(request) + + @Test + fun convertingToResourceShouldWork() { + runBlocking { + val assembler = TestResourceAssembler() + val resource = assembler.toModel(Employee("Frodo"), exchange) + + assertThat(resource.content.name).isEqualTo("Frodo") + assertThat(resource.links).isEmpty() + } + } + + @Test + fun convertingToResourcesShouldWork() { + runBlocking { + val assembler = TestResourceAssembler() + val resources = assembler.toCollectionModel(flowOf(Employee("Frodo")), exchange) + + assertThat(resources.content).containsExactly(EntityModel.of(Employee("Frodo"))) + assertThat(resources.links).isEmpty() + } + } + + @Test + fun convertingToResourceWithCustomLinksShouldWork() { + runBlocking { + val assembler = ResourceAssemblerWithCustomLink() + val resource = assembler.toModel(Employee("Frodo"), exchange) + + assertThat(resource.content.name).isEqualTo("Frodo") + assertThat(resource.links).containsExactly(Link.of("/employees").withRel("employees")) + } + } + + @Test + fun convertingToResourcesWithCustomLinksShouldWork() { + runBlocking { + val assembler = ResourceAssemblerWithCustomLink() + val resources = assembler.toCollectionModel(flowOf(Employee("Frodo")), exchange) + + assertThat(resources.content).containsExactly( + EntityModel.of(Employee("Frodo"), Link.of("/employees").withRel("employees")) + ) + assertThat(resources.links).isEmpty() + } + } + + internal inner class TestResourceAssembler : SimpleCoroutineRepresentationModelAssembler + + internal inner class ResourceAssemblerWithCustomLink : SimpleCoroutineRepresentationModelAssembler { + override suspend fun addLinks(resource: EntityModel, exchange: ServerWebExchange): EntityModel { + return resource.add(Link.of("/employees").withRel("employees")) + } + } + + data class Employee(val name: String) +} diff --git a/src/test/kotlin/org/springframework/hateoas/server/reactive/WebFluxLinkBuilderDslUnitTest.kt b/src/test/kotlin/org/springframework/hateoas/server/reactive/WebFluxLinkBuilderDslUnitTest.kt new file mode 100644 index 000000000..d4054adc0 --- /dev/null +++ b/src/test/kotlin/org/springframework/hateoas/server/reactive/WebFluxLinkBuilderDslUnitTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2002-2022 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.server.reactive + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.hateoas.* +import org.springframework.hateoas.IanaLinkRelations.SELF +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.test.StepVerifier + +/** + * Unit tests for [WebFluxLinkBuilderDsl]. + * + * @author Christoph Huber + */ +class WebFluxLinkBuilderDslUnitTest { + + @Test + fun `creates link to controller method`() { + + val self = linkTo { findById("15") } withRel SELF + + StepVerifier.create(self) + .expectNextMatches { + assertThat(it.rel).isEqualTo(SELF) + assertThat(it.href).isEqualTo("/customers/15") + true + } + .verifyComplete() + } + + @Test + fun `adds links to wrapped domain object`() { + + val customer = EntityModel.of(Customer("15", "John Doe")) + .add(CustomerController::class) { entity -> + linkTo { findById(entity.content.id) } withRel SELF + linkTo { findProductsById(entity.content.id) } withRel REL_PRODUCTS + } + + StepVerifier.create(customer) + .expectNextMatches { + assertThat(it.hasLink(SELF)).isTrue() + assertThat(it.hasLink(REL_PRODUCTS)).isTrue() + true + } + .verifyComplete() + } + + @Test + fun `adds links to resourcesupport object`() { + + val customer = CustomerModel("15", "John Doe") + .add(CustomerController::class) { + linkTo { findById(it.id) } withRel SELF + linkTo { findProductsById(it.id) } withRel REL_PRODUCTS + } + + StepVerifier.create(customer) + .expectNextMatches { + assertThat(it.hasLink(SELF)).isTrue() + assertThat(it.hasLink(REL_PRODUCTS)).isTrue() + true + } + .verifyComplete() + } + + @Test + fun `add link to mono`() { + val customer = Mono.just(CustomerModel("15", "John Doe")) + .add { linkTo { findById(it.id) } withRel SELF } + StepVerifier.create(customer) + .expectNextMatches { + assertThat(it.hasLink(SELF)).isTrue() + true + } + } + + @Test + fun `add links to mono`() { + val customer = Mono.just(CustomerModel("15", "John Doe")) + .add { + Flux.concat( + linkTo { findById(it.id) } withRel SELF, + linkTo { findProductsById(it.id) } withRel REL_PRODUCTS + ) + } + StepVerifier.create(customer) + .expectNextMatches { + assertThat(it.hasLink(SELF)).isTrue() + assertThat(it.hasLink(REL_PRODUCTS)).isTrue() + true + } + } + + data class Customer(val id: String, val name: String) + data class CustomerDTO(val name: String) + open class CustomerModel(val id: String, val name: String) : RepresentationModel() + open class ProductModel(val id: String) : RepresentationModel() + + @RequestMapping("/customers") + interface CustomerController { + + @GetMapping("/{id}") + fun findById(@PathVariable id: String): Mono> + + @GetMapping("/{id}/products") + fun findProductsById(@PathVariable id: String): Mono> + + @PutMapping("/{id}") + fun update(@PathVariable id: String, @RequestBody customer: CustomerDTO): Mono> + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: String): Mono> + } + + companion object { + private const val REL_PRODUCTS = "products" + } +}