spring-projects / spring-framework

Spring Framework
https://spring.io/projects/spring-framework
Apache License 2.0
56.63k stars 38.13k forks source link

Spring WebFlux HATEOAS No Encoder for EntityModel with preset Content-Type 'null' #27327

Open Fradantim opened 3 years ago

Fradantim commented 3 years ago

Affects: org.springframework.boot:spring-boot-starter-parent:2.5.2


Currently also an open stackoverflow question.

While trying to expose and consume an endpoint which produces application/x-ndjson and returns a HAL-JSON some kind of integration is missing, I'm able to return and de-serialize a Mono<List> of HAL-JSON, but not a Flux.

pom.xml and Pojo class ``` xml (...) org.springframework.boot spring-boot-starter-parent 2.5.2 org.springframework.boot spring-boot-starter-webflux (...) (...) ``` ``` java public class Pojo { private Integer id; private String name; public Pojo(Integer id, String name) { this.id=id; this.name=name; } public Integer getId() { return id; } public String getName() { return name; } } ```
@RestController
@RequestMapping()
public class PojoResource {
    Flux<Pojo> getPojos() {
        return Flux.just(new Pojo(1, "alpha"), new Pojo(2, "beta"), new Pojo(3, "gamma"));
    }

    @GetMapping(value="/pojoA", produces = MediaType.APPLICATION_NDJSON_VALUE)
    public Flux<Pojo> getAllPojosAsStream() {
        return getPojos();
    }

    @GetMapping("/pojoB")
    public Mono<List<EntityModel<Pojo>>> getAllPojosAsHAL() {
        return getAllPojosAsHALStream().collectList();
    }

    @GetMapping(value="/pojoC", produces = MediaType.APPLICATION_NDJSON_VALUE)
    public Flux<EntityModel<Pojo>> getAllPojosAsHALStream() {
        return getPojos().map(p -> EntityModel.of(p));
    }
}

Calling for Flux of Pojo

curl -X 'GET' 'http://localhost:8080/pojoA' -H 'accept: application/x-ndjson'

Works like a charm:

{"id":1,"name":"alpha"}
{"id":2,"name":"beta"}
{"id":3,"name":"gamma"}

Calling for Mono of List of EntityModel of Pojo

curl -X 'GET' 'http://localhost:8080/pojoB' -H 'accept: */*'

Another gem:

[
  {
    "id": 1,
    "name": "alpha",
    "links": []
  },
  {
    "id": 2,
    "name": "beta",
    "links": []
  },
  {
    "id": 3,
    "name": "gamma",
    "links": []
  }
]

But calling for Flux of EntityModel of Pojo

curl -X 'GET' 'http://localhost:8080/pojoC' -H 'accept: application/x-ndjson'

Fails with an exception:

org.springframework.http.converter.HttpMessageNotWritableException: No Encoder for [org.springframework.hateoas.EntityModel<my.package.Pojo>] with preset Content-Type 'null'
(Full stacktrace can be provided if needed)

Is this not an intended use case? If you can provide me a little of info about the jackson encoders perhaps I can contribute with my grain of sand.

bclozel commented 3 years ago

I've tested this with Spring Boot 2.5.4 and this has a slightly different behavior than the one you've described (a different error message). I guess this is all related to #26212.

  1. the "/pojoA" endpoint produces Flux<Pojo> as "application/x-ndjson". The http://ndjson.org/ spec is about producing separate JSON documents, separated by newlines. This is especially useful for streaming responses. In this case, Spring serializes each Pojo separately as they come using the current configuration for the "application/json" media type.
  2. the "/pojoB" endpoint produces Mono<List<EntityModel<Pojo>>> with no specific media type. This falls back to "application/json" using the default JSON configuration. This does not produce HAL-JSON, as this merely serializes EntityModel instances as best as it can.
  3. the "/pojoC" endpoint produces Flux<EntityModel<Pojo>> with the ndjson media type. Since #26212, Spring Framework now automatically selects an alternate ObjectMapper configuration if a specific type has been registered with the infrastructure. Here, Spring HATEOAS registers a specific one for all classes extending RepresentationModel. This means we're trying to find a JSON serializer registered for the EntityModel+"application/x-ndjson" pair. Nothing has been registered and the serialization process fails with an error message.

Spring HATEOAS considerations

First, I must say that your assumptions are wrong - serializing an EntityModel outside of the media types supported by Spring HATEOAS is not supported. In this case, this merely gets serialized as JSON but I don't think Spring HATEOAS guarantees any behavior here. I don't think that HAL-JSON resources are meant to be streamed with in a media type like ND-JSON, @odrotbohm ?

Spring Framework discussion

@rstoyanchev I initially thought about some inconsistency, blindly serializing in one case and rejecting in another. It seems that this is all related to the following: with "application/json", we collect the elements and consider the org.springframework.web.servlet.mvc.method.annotation.ReactiveTypeHandler.CollectedValuesList type; with ND-JSON, we are considering EntityModel elements one by one because this is a streaming media type. Should we do something special with registered types when it comes to collections or collected values?

If you think that the current behavior is fine, then we should close this issue on this side.

odrotbohm commented 3 years ago

This seems to be a duplicate of spring-projects/spring-hateoas#1584 that has quite a bit of discussion on Spring HATEOAS' take on things. Also, there's spring-projects/spring-framework#27074 that discusses especially 3 in detail.

I am torn on 2, as, AFAIR, handling collections inspects the collection's element type for ObjectMapper selection. Thus, from a consistency point of view, I guess I'd expect the additional Mono wrapping not to result in different serialization behavior compared to without the Mono wrapping. That said, the devil is in the details, here, too. Accepting */* makes this work as it can adapt to the media type for calculated for List (likely application/json). If the media type to be produced was calculated from the element type, we'd end up with application/hal+json but then end up rendering a simple JSON array, which is not valid HAL (also discussed in detail here).

The gist of spring-projects/spring-framework#27074's take on 3 is that I think looking up an ObjectMapper for EntityModel and application/x-ndjson is wrong, as the processing is already implementing the media type and looking up ObjectMapper instances for the elements of the stream. The NDJSON spec requires them to be of plain JSON, which means that a lookup should rather use application/json as media type to produce. Spring HATEOAS current setup would then cause that to be answered with the ObjectMapper registered for the first listed media type in @EnableHypermediaSupport, for which in a standard Spring Boot arrangement HAL is the actual format produced. I.e. the client would see a stream of HAL rendered EntityModel documents.

Happy to jump on a call if needed, as I can see that it's quite a complex topic to discuss via text.

bclozel commented 10 months ago

27400 asks for an enhancement to partially solve this.

jhoeller commented 9 months ago

Putting this into 6.x Backlog along with #27400. @odrotbohm as discussed, please update the issue title to reflect the actual enhancement request more clearly.

onacit commented 2 months ago

Do we have any progress here? Thanks.