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 475 forks source link

Name embedded collections #175

Closed drdamour closed 6 years ago

drdamour commented 10 years ago

The Resources type (and any derived types, ie PagedResource) should allow you to name the collection that will eventually be the embedded set. It's auto named now, but the api developer should really have control over this.

odrotbohm commented 10 years ago

Can you elaborate what you mean by that? What exactly are you referring to with "name of the collection"? Relation names are abstracted away by a RelProvider and we ship two implementations: one based on the Evo Inflector library and one that creates a default …List for collection relations and uses the lower case simple class name for the item relations.

You're free to simply implement a RelProvider on your own, implement supports(Class<?> type) to select the types you want to be responsible for and register your implementation as Spring bean. This will cause the rendering infrastructure (e.g. the custom Jackson renderers we deploy) to call your implementation when calculating the relation names for representations.

drdamour commented 10 years ago

That can work as a workaround...but it is possible that i would have two collections of the same type with different semantic meaning.

So in the HAL format i could have something like this at /customer/123

{
  _links : [
    "favorite products" : [
         {href : "/products/998"},
         {href : "/products/777"}
    ],
    "purchased products" : [
         {href : "/products/998"},
         {href : "/products/444"},
         {href : "/products/333"},
         {href : "/products/222"},
         {href : "/products/111"},
         {href : "/products/555"},
         {href : "/products/666"},
     ]
 ]
}

but in my protocol i might implement something like the zoom protocol to get things embedded. so a url like /customer/123?expand=purchased products&size=4 should give back something like this:

{
  _links : [
    "favorite products" : [
         {href : "/products/998"},
         {href : "/products/777"}
    ],
    "purchased products" : [
         {href : "/products/998"},
         {href : "/products/444"},
         {href : "/products/333"},
         {href : "/products/222"},
         {href : "/products/111"},
         {href : "/products/555"},
         {href : "/products/666"},
     ]
 ],
_embedded : [
  {
    "purchased products" [
      {
        _links : { self : { href : "/products/998"} },
        someProductProperty : "someValue"
      },
      {
        _links : { self : { href : "/products/444"} },
        someProductProperty : "someValue"
      },
      {
        _links : { self : { href : "/products/333"} },
        someProductProperty : "someValue"
      },
      {
        _links : { self : { href : "/products/222"} },
        someProductProperty : "someValue"
      }
    ]
  }
],
page : { ...}
}

the embedded name should be the same as the link. So yes i could do this for one of the links, but the other at URL /customer/123?expand=favorite products&size=4 would be out of luck. and you could technically do both simultaniously (but paging both would be pretty strange).

Personally i want to just name the embedded paged collection items or page items as someTypeNameList isn't semantically meaningful enough. Your workaround will probably get me there.

fiddlerpianist commented 10 years ago

Traverson (the JS variety) attempts to perform this optimization (look for link relation name in _embedded if it's provided). The auto-generated name does not match what would be generated in a link list, and so Traverson can't find _embedded links.

williewheeler commented 9 years ago

+1 to drdamour's remarks, including the inadequacy of driving the name via types alone.

It would be useful for example to be able to do something like

PagedResources<PersonResource> pagedResources =
    new PagedResources("employees", employeeDtos, pageMetadata, links);

and have that show up as

"_embedded": {
    "employees": [ ... ]
}

rather than

"_embedded": {
    "personResourceList": [ ... ]
}
gjrwebber commented 9 years ago

+1

marvinrichter commented 9 years ago

For those who didn't get Olivers answer straight away, like me:

If you want the plural form of the class' name rather than uncapitalized class.getSimpleName + "List", you just need to use the EvoInflectorRelProvider instead of the standard DefaultRelProvider.

If you use Spring Boot, just add the following dependency

<dependency>
    <groupId>org.atteo</groupId>
    <artifactId>evo-inflector</artifactId>
    <version>1.2</version>
</dependency>

otherwise you need to also add the following bean to your configuration class

@Bean
public RelProvider relProvider() {
    return new EvoInflectorRelProvider();
}

These results in returning

"_embedded": {
    "users": [{
        "name": "Marvin"
    }]
}

instead of

"_embedded": {
    "userList": [{
        "name": "Marvin"
    }]
}
odrotbohm commented 9 years ago

As documented in the reference documentation. :smile:

marvinrichter commented 9 years ago

lol. I totally missed the point 3. 😄

zhengl7 commented 8 years ago

+1.

Also add to OP's comment, what's the rationale to have PagedResources returned under _embedded with class name? Is it possible to return just the array of data like non-HAL format?

Like for /v1/users/ just return

"_embedded": 
[
  {
    "name": "Marvin"
  },
  {
    "name": "Alex"
  }
]

This way client doesn't have to know the property name to look for under _embedded.

odrotbohm commented 8 years ago

The HAL specification.

stephaneeybert commented 6 years ago

Even if we can have, for example the name 'users' instead of the name 'userResourceList', we still have a name varying for each resource type. It'd be helpful to configure a static name, common to all resource types, like 'items' for example.

marvinrichter commented 6 years ago

@stephaneeybert having one static name for all collections would be very easy with the solution that @olivergierke suggested first.

Implement your custom RelProvider which will always return items for the collection name and itemfor the item name and always returns true for the method supports(Class<?> type). You can check out the DefaultRelProvider.

To be honest, I would not do that because the client is supposed to know what it was asking for anyway. And a resource type reflects a resource which itself should be identifiable and recognizable. Having everything named items sounds like the complete opposite of that.

stephaneeybert commented 6 years ago

@marvinrichter I just found out about the @Relation(collectionRelation = RESTConstants.EMBEDDED_COLLECTION_NAME) and implemented it. But reading your advice I shall revert and keep the default naming in place.

marvinrichter commented 6 years ago

That depends on what you want to achieve. If there are only some resources you want to name items then the approach with @Relation is the way to go. Think of it as some kind of overruling the default behavior.

But if you want to have all collections named items by default then I would suggest to write your own RelProvider which enforces this default behavior.

I for myself like the pluralized forms and use the EVO Inflector. So instead of having items or userList I get users.

gregturn commented 6 years ago

Given the project supports both annotation overrides and writing your own RelProvider, I’m closing this issue.

drdamour commented 6 years ago

@gregturn the specific scenario I outlined in
https://github.com/spring-projects/spring-hateoas/issues/175#issuecomment-43690320 remains unsupported and relprovided nor annotations supports it. can we please reopen

stephaneeybert commented 6 years ago

@drdamour It would like to read a proper english with no obvious spelling mistakes, and if possible, punctuation and accentuation. As to your request, may I suggest you provide the maintainer with a bit more explaination ?

gregturn commented 6 years ago

Again, using EvoInflectorRelProvider as a guide, write your own. Or use @Relation.

drdamour commented 6 years ago

@gregturn as outlined in https://github.com/spring-projects/spring-hateoas/issues/175#issuecomment-43690320 neither of those suggestions work when you need two of the same type embedded within the same resource, there's no way to distinguish between them. perhaps if @relation was on the getter or field instead of just the type it could work.

drdamour commented 6 years ago

@stephaneeybert what is missing from https://github.com/spring-projects/spring-hateoas/issues/175#issuecomment-43690320 ?

stephaneeybert commented 6 years ago

@drdamour I'm reading your comment again and again to try to understand what you want to do. For example; that phrase "but in my protocol i might implement something like the zoom protocol to get things embedded." is not so easy to grasp. From what I could guess, you are trying to have in the _embedded node a list of items that doesn't match what you have in the _links node. Why do you want to filter out part of the content of the _embedded node ? Can't you leave that to the client consuming the API response ?

drdamour commented 6 years ago

zoom protocol just allows client have more control on what is and is not embedded, and irrelevant to the underlying issue but was a real world example i had at the time. if you look again I'll think you'll see everything embedded in the example was also a link and not divergent as suggested by you.

again the issue is the available rel defining methods are limited to the type being embeeded, and I often have the same type being embedded for distinct rels. eg favorite-products and purchased-products on a user resource

gregturn commented 6 years ago

RelProvider and the annotation give you the power to name the collection.

Link rels you control.

Therefore you can do all this.

drdamour commented 6 years ago

@gregturn can you explain how since relprovider's signature only has the class as a variable (and not something like the field name in it's method signature)...how can I return a different rel for two collections of the same type?

odrotbohm commented 5 years ago

Here's how you'd create an _embedded clause with objects of different type under the same link relation:

class First {
    public String getName() {
        return "name";
    }
}

class Second {
    public String getOtherName() {
        return "otherName";
    }
}

EmbeddedWrappers wrappers = new EmbeddedWrappers(false);
List<Object> elements = new ArrayList<>();

LinkRelation relation = LinkRelation.of("common");
elements.add(wrappers.wrap(new First(), relation));
elements.add(wrappers.wrap(new Second(), relation));

CollectionModel<Object> model = new CollectionModel<>(elements);

For the HAL module this renders:

{
  "_embedded" : {
    "common" : [ {
      "name" : "name"
    }, {
      "otherName" : "otherName"
    } ]
  }
}
drdamour commented 5 years ago

but my problem is the reverse...two getters of the SAME type with different rels.

drdamour commented 5 years ago

looks like EmbeddedWrappers would work for that case too now. cool.

odrotbohm commented 5 years ago

Yes, if an explicit LinkRelation is provided there's no implicit resolution – and thus grouping – going on. Does that mean we consider you use case covered?

drdamour commented 5 years ago

trying it out

@RestController
public class ThingController {

    class Product {
        private final String name;

        Product(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }
    }

    public class Result extends EntityModel<Result> {
        private final List<Object> allProducts;

        public Result(

            List<Object> allProducts
        ){

            this.allProducts = allProducts;
        }

        public List<Object> getAllProducts() {
            return allProducts;
        }
    }

    @GetMapping("/")
    RepresentationModel stuff(){

        EmbeddedWrappers wrappers = new EmbeddedWrappers(false);
        List<Object> elements = new ArrayList<>();

        elements.add(wrappers.wrap(new Product("Product1a"), LinkRelation.of("all")));
        elements.add(wrappers.wrap(new Product("Product2a"), LinkRelation.of("purchased")));
        elements.add(wrappers.wrap(new Product("Product1b"), LinkRelation.of("all")));

        return new CollectionModel(elements);
    }

}

gave me

{
 _embedded: {
   purchased: {
    name: "Product2a"
   },
  all: [
   {
    name: "Product1a"
   },
   {
    name: "Product1b"
   }
  ]
 }
}

so that is pretty nice...but when switched the return to return new Result(elements) i didn't get hal back...but i supsect that is just some limitation of the hateoas serializer. i can work with that.

thanks!

drdamour commented 5 years ago

ah yeah i see now, changing to RepresentationalModel and changing the getter to public

List<Object> getContent() {
            return allProducts;
        }

content property is special to jackson serializer looks like...but getContent is there on entity model too.

oh man i'm gonna have to forget so much of ResourceSupport

odrotbohm commented 5 years ago

I'd love to keep this focused on actual tickets and actionable items here. For general discussion and questions, please refer to StackOverflow.

Short: yes, all media type specific serialization is based on the RepresentationModel type hierarchy. This is to make sure we're not interfering with other HttpMessageConverter and ObjectMapper instances that might be configured in the system. By deciding to return a RepresentationModel you sort of opt-in to the hypermedia support.

gregturn commented 5 years ago

@drdamour I'm experimenting on a fluent API to build RepresentationModel objects. What do you think of something like this for building an embedded structure like you depicted up above?

@RestController
static class ProductController {

    LinkRelation favoriteProducts = LinkRelation.of("favorite products");
    LinkRelation purchasedProducts = LinkRelation.of("purchased products");

    @GetMapping("/products")
    public RepresentationModel<?> all() {

        EmbeddedModelBuilder builder = ModelBuilder //
                .embed() //
                .rootLink(linkTo(methodOn(ProductController.class).all()).withSelfRel());

        PRODUCTS.keySet().stream() //
                .map(id -> new EntityModel<>(PRODUCTS.get(id), new Link("http://localhost/products/{id}").expand(id))) //
                .forEach(productEntityModel -> {

                    if (productEntityModel.getContent().isFavorite()) {

                        builder //
                                .embed(favoriteProducts) //
                                .entityModel(productEntityModel) //
                                .rootLink(productEntityModel.getRequiredLink(SELF).withRel(favoriteProducts));
                    }

                    if (productEntityModel.getContent().isPurchased()) {

                        builder //
                                .embed(purchasedProducts) //
                                .entityModel(productEntityModel) //
                                .rootLink(productEntityModel.getRequiredLink(SELF).withRel(purchasedProducts));
                    }
                });

        return builder.build();
    }
}

It yields:

{
  "_embedded" : {
    "favorite products" : [ {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/777"
        }
      }
    }, {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/998"
        }
      }
    } ],
    "purchased products" : [ {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/111"
        }
      }
    }, {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/222"
        }
      }
    }, {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/333"
        }
      }
    }, {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/444"
        }
      }
    }, {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/555"
        }
      }
    }, {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/666"
        }
      }
    }, {
      "someProductProperty" : "someValue",
      "_links" : {
        "self" : {
          "href" : "http://localhost/products/998"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost/products"
    },
    "purchased products" : [ {
      "href" : "http://localhost/products/111"
    }, {
      "href" : "http://localhost/products/222"
    }, {
      "href" : "http://localhost/products/333"
    }, {
      "href" : "http://localhost/products/444"
    }, {
      "href" : "http://localhost/products/555"
    }, {
      "href" : "http://localhost/products/666"
    }, {
      "href" : "http://localhost/products/998"
    } ],
    "favorite products" : [ {
      "href" : "http://localhost/products/777"
    }, {
      "href" : "http://localhost/products/998"
    } ]
  }
}
gregturn commented 5 years ago

A plain old collection where the embedded link relation is a based on the domain type:

@GetMapping("/authors")
RepresentationModel<?> collection() {

    return ModelBuilder //
            .collection() //

            .entity(new Author("Greg L. Turnquist", null, null)) //
            .link(linkTo(methodOn(EmbeddedController.class).authorDetails(1)).withSelfRel())
            .link(linkTo(methodOn(EmbeddedController.class).collection()).withRel("authors")) //

            .entity(new Author("Craig Walls", null, null)) //
            .link(linkTo(methodOn(EmbeddedController.class).authorDetails(2)).withSelfRel())
            .link(linkTo(methodOn(EmbeddedController.class).collection()).withRel("authors")) //

            .entity(new Author("Oliver Drotbhom", null, null)) //
            .link(linkTo(methodOn(EmbeddedController.class).authorDetails(2)).withSelfRel())
            .link(linkTo(methodOn(EmbeddedController.class).collection()).withRel("authors")) //

            .rootLink(linkTo(methodOn(EmbeddedController.class).collection()).withSelfRel()) //

            .build();
}

produces

{
  "_embedded" : {
    "authors" : [ {
      "name" : "Greg L. Turnquist",
      "_links" : {
        "self" : {
          "href" : "http://localhost/author/1"
        },
        "authors" : {
          "href" : "http://localhost/authors"
        }
      }
    }, {
      "name" : "Craig Walls",
      "_links" : {
        "self" : {
          "href" : "http://localhost/author/2"
        },
        "authors" : {
          "href" : "http://localhost/authors"
        }
      }
    }, {
      "name" : "Oliver Drotbhom",
      "_links" : {
        "self" : {
          "href" : "http://localhost/author/2"
        },
        "authors" : {
          "href" : "http://localhost/authors"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost/authors"
    }
  }
}
gregturn commented 5 years ago

And a single item representation:

@GetMapping("/other-author")
RepresentationModel<?> singleItem() {

    return ModelBuilder //
            .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) //
            .link(new Link("/people/alan-watts")) //
            .build();
}

produces

{
  "name" : "Alan Watts",
  "born" : "January 6, 1915",
  "died" : "November 16, 1973",
  "_links" : {
    "self" : {
      "href" : "/people/alan-watts"
    }
  }
}
drdamour commented 5 years ago

overall it seems fine. some comments pulling from how i've used resources:

that's my immediate feedback.

gregturn commented 5 years ago

I've opened a new ticket to focus the design of the representation builder.

stephaneeybert commented 5 years ago

@gregturn You'd have the link to the newly opened ticket ?

gregturn commented 5 years ago

I guess that’s not visible on your phone.

864.