spring-projects / spring-hateoas

Spring HATEOAS - Library to support implementing representations for hyper-text driven REST web services.
https://spring.io/projects/spring-hateoas
Apache License 2.0
1.03k stars 477 forks source link

Support force Spring HATEOAS resources to render an empty embedded array in straightforward way #522

Open ceefour opened 7 years ago

ceefour commented 7 years ago

This has been requested before: http://stackoverflow.com/questions/30286795/how-to-force-spring-hateoas-resources-to-render-an-empty-embedded-array#comment69331410_30297552

For example, if the array is not rendered, REST clients will break. Handling this edge case by default will require additional coding in all REST clients.

How about extending the constructor?

new Resources<>(Exercise.class, exercises, ...);

is acceptable for me.

odrotbohm commented 7 years ago

I don't think adding something like this to Resources itself is a good idea as the concept of embedding is not something that's necessarily supported in other hypermedia types. I know that currently HAL is the only supported media type but that's going to change and I don't want to set precedence of adding media type specific features to core library classes as that'd open the door for request for specialties of other media types being added to those classes, too. That's basically the first step into a kitchen sink of code then.

The StackOverflow post actually contains an already supported way of achieving what you want. Another reason that there's a dedicated extra type for that is that — as you can see — there's more to embedding than just handing the collection of items, because you need to specify what to do with single element collections. Again, a specialty of HAL.

So basically see EmbeddedWrappers as HAL-related API that allows you to configure the specialties of (HAL) embedding so that Resources doesn't even have to know about that concept and yet the serializers needing to deal with the concept are able to detect it.

As you seem to be the commentor on the post, too, it can't really be seen as a validation of your request ;).

Sam-Kruglov commented 6 years ago

When we're using natural auto-generates SDR collection resources, it always returns the empty array because it has meta information. All of the custom ones return nothing when there is an empty array. In order to act the same way everywhere, I had to either overwrite all SDR collection resources so that it returns nothing when empty, or create a custom utility class and use it everywhere instead of Resources. I chose the utility path.

There is a problem that I have to manually inject only needed ResourceProcessor into each controller and pass it to the utility so that it can call it in a hacky way like that:

Resources<T> resForLinks = new Resources<>(new ArrayList<>());
 processor.process(resForLinks);

And then assign the links from that to the actual resources I'm gonna return. As I have to pass the Class<?> here, it is actually possible for Spring to search through the ResourceProcessors and apply one automatically, rather than me passing it all manually which is very verbose sometimes.

Also, there is a nice class that I use but it is for some reason declared as private org.springframework.hateoas.core.EmbeddedWrappers.EmptyCollectionEmbeddedWrapper so I had to just redeclare it. Otherwise, I'd have to manually provide relations, but here it is automatic.

Basically, my logic is the following: if the content is empty, assign Collections.singletonList(new EmptyCollectionEmbeddedWrapper(clazz)) to it and it works.

Full code of the method:

/**
     * Makes sure that JSON returns _embedded section even if it's empty.
     * If content is empty, then root links are processed by the passed processor and empty array in JSON is called
     * according to the passed class.
     *
     * @param clazz type of collection elements for naming the empty array.
     * @param processor resource processor bean to process the root links if the array is empty; may be null
     *
     * @return {@code Resources<>(content} if {@code content} is not empty and
     * {@code Resources<EmptyCollectionEmbeddedWrapper> } if empty
     *
     */
    public static <T> Resources<?> resourcesOf(final Iterable<T> content,
                                               ResourceProcessor<Resources<T>> processor,
                                               Class<?> clazz,
                                               Link... passedLinks) {

        List<Link> links = Arrays.asList(passedLinks);
        if (!content.iterator().hasNext()) {

            List<EmptyCollectionEmbeddedWrapper> newContent =
                    Collections.singletonList(new EmptyCollectionEmbeddedWrapper(clazz));

            if (processor == null){
                Resources res = new Resources<>(newContent);
                res.add(links);
                return res;
            }

            Resources<T> resForLinks = new Resources<>(new ArrayList<>());
            resForLinks.add(links);
            processor.process(resForLinks);

            return new Resources<>(newContent, resForLinks.getLinks());
        } else {
            Resources<T> res = new Resources<>(content);
            res.add(links);
            return res; //processor is called naturally
        }
    }
snebjorn commented 6 years ago

I just ran into this issue. Or close to it.

@RestController
public class FooController {
  private final FooRepository fooRepository;
  private final PagedResourcesAssembler<Foo> pagedAssembler;

  @Autowired
  public FooController(FooRepository fooRepository, PagedResourcesAssembler<Foo> pagedAssembler) {
    this.fooRepository = fooRepository;
    this.pagedAssembler = pagedAssembler;
  }

  @GetMapping()
  public HttpEntity<?> getFoos(Pageable pageable) {
    Page<Foo> pagedFoos = fooRepository.findAll(pageable);
    return ResponseEntity.ok(pagedAssembler.toResource(pagedFoos));
  }
}

If there are no Foos then the result will look like this:

{
  "_links" : {
    "self" : {
      "href" : "https://localhost/foos?page=0&size=20"
    }
  },
  "page" : {
    "size" : 20,
    "totalElements" : 0,
    "totalPages" : 0,
    "number" : 0
  }
}

However if the FooRepository was using @RepositoryRestResource then the result would look like this:

{
  "_embedded" : {
    "foos" : [ ]
  },
  "_links" : {
    "self" : {
      "href" : "https://localhost/foos?page=0&size=20"
    }
  },
  "page" : {
    "size" : 20,
    "totalElements" : 0,
    "totalPages" : 0,
    "number" : 0
  }
}

I think they should be the same for the sake of consistency.

cyril-gambis commented 5 years ago

@odrotbohm Could you please provide a complete (small) example of the right way to deal with this?

With the code on the stackoverflow:

    EmbeddedWrappers wrappers = new EmbeddedWrappers(false);
    EmbeddedWrapper wrapper = wrappers.emptyCollectionOf(Exercise.class);
    Resources<Object> resources = new Resources<>(Arrays.asList(wrapper));

You return Resources<Object>.

Can you provide, for instance, a method that returns the comments on a blog post?

Important edit: the right way to get the comments of the blog with id "4" would be (...)/blogs/4/comments directly, but in my case, I need to add the "author" info on each comment, and this information comes from another microservice, so I have to process each comment to add the author info to them before returning the result. So I need a simplified example of how to deal with empty list from a Controller.

    @GetMapping(value = "/comments", params="blogId", produces = MediaTypes.HAL_JSON_VALUE)
    public ResponseEntity<Resources<??????>> getComments(@RequestParam("blogId") Long blogId, Pageable pageable) {

        List<Comment> comments = this.commentRepository.findByBlogId(blogId, pageable);

        Resources<Object> resources = null;
        if (comments.isEmpty()) {
            EmbeddedWrappers wrappers = new EmbeddedWrappers(false);
            EmbeddedWrapper wrapper = wrappers.emptyCollectionOf(Comment.class);
            resources = new Resources<>(Arrays.asList(wrapper));
        } else {
            resources = new Resources<?????>(comments);
        }

        resources.add(ControllerLinkBuilder.linkTo(ControllerLinkBuilder.methodOn(CommentController.class).getComments()).withSelfRel());

        return ResponseEntity.ok(resources);
    }

I have difficulties to understand the right way to do this.

Thanks !

reda-alaoui commented 4 years ago

I don't think adding something like this to Resources itself is a good idea as the concept of embedding is not something that's necessarily supported in other hypermedia types. I know that currently HAL is the only supported media type but that's going to change and I don't want to set precedence of adding media type specific features to core library classes as that'd open the door for request for specialties of other media types being added to those classes, too. That's basically the first step into a kitchen sink of code then.

@odrotbohm, it seems that what you described in 2016 is not true anymore. There is now a HAL dedicated CollectionModel Mixin:

@JsonPropertyOrder({ "content", "links" })
abstract class CollectionModelMixin<T> extends CollectionModel<T> {

    @Override
    @JsonProperty("_embedded")
    @JsonInclude(Include.NON_EMPTY)
    @JsonSerialize(using = Jackson2HalModule.HalResourcesSerializer.class)
    @JsonDeserialize(using = Jackson2HalModule.HalResourcesDeserializer.class)
    public abstract Collection<T> getContent();
}

Removing @JsonInclude(Include.NON_EMPTY) would render an empty array for HAL only. Would you accept a PR for this?

reda-alaoui commented 4 years ago

Oups, I misread the code, forget what I said :) It only impact the embedded presence.

gregturn commented 4 years ago

https://docs.spring.io/spring-hateoas/docs/1.1.1.RELEASE/reference/html/#mediatypes.hal.configuration gives you the means to configure single links as either a single item or as a list of one.

As for depicting an empty array, I'm not quite as sold. Isn't reacting to the existence of links a fundamental HATEOAS aspect?

morganriand commented 3 years ago

We are currently using an Angular library for handling Hal/Hateoas, and this library requires always having an "_embedded" attribute, even if it is empty. Currently the only solution we found was to add code to each controller method and use "embeddedWrappers", this is not an elegant solution.

Like reda said, it would be nice to always have the "_embedded" attribute with HAL.

reda-alaoui commented 3 years ago

@morganriand , if you are willing to change for a great hypermedia client, https://github.com/badgateway/ketting handles gracefully the array absence.

morganriand commented 3 years ago

@reda-alaoui Thanks! I'll check it out

I also just found "way" that's easier to use than EmbeddedWrappers :

 HalModelBuilder.emptyHalModel()
                 .embed(this.service.find(x, x, x, x, x).stream()
                         .map(f -> this.Assembler.toModel(new Dto(f, service.findDtoByCode(f.getX()))))
                         .collect(Collectors.toList()), Dto.class)
                 .link(linkTo(methodOn(Controller.class).findAll(query,x,x,x,request)).withSelfRel())
         .build();
odrotbohm commented 3 years ago

... and this library requires always having an "_embedded" attribute, even if it is empty.

That's clearly an invalid assumption as the HAL specification explicitly states that the _embedded property is optional.

morganriand commented 3 years ago

That's clearly an invalid assumption as the HAL specification explicitly states that the _embedded property is optional.

Completely agree, but it's what this library requires, I'll ask the frontend team for the name of this library. Edit: the library is https://www.npmjs.com/package/@lagoshny/ngx-hateoas-client

jamesdh commented 1 year ago

@morganriand , if you are willing to change for a great hypermedia client, https://github.com/badgateway/ketting handles gracefully the array absence.

Can confirm that ketting is wonderfully well designed and thought out and it's the only reason we're continuing to go the spring-hateoas route despite numerous pain points we've had to work around along the way.