Spring-boot + REST + HATEOAS + HAL
Asked Answered
B

3

7

I followed the spring.io Pivotal tutorials to get a REST API with a MySQL DB off the ground and things are progressing nicely. However I found a behavior that I haven't been able to configure or work around.

When I use the built-in functionality to retrieve my Resources from the PagingAndSortingRepository, the resulting REST is automatically paged and encapsulated with useful HAL links (_links, self, search, linked Resources, etc). I want to use that.

When I implemented my Controller to customize the PostMapping behavior and introduce sanity checks, validation and etc, the GetMapping stopped working. So I re-implemented a GetMapping that leveraged my Service layer.

Doing so unfortunately broke the HATEOAS that was previously provided.

What I would like is to be able to customize the PostMapping, but retain the GetMapping exactly like the default. If possible I would love to avoid having to write it myself since I know the framework can supply it.

Any way to do that?

Controller:

@RestController
public class PartyMemberController {

    @Autowired
    PartyMemberService partyMemberService;

    @RequestMapping(method = RequestMethod.GET, value = "/partyMembers")
    public ResponseEntity<Iterable<PartyMember>> getAllPartyMembers() {
        Iterable<PartyMember> partyMemberList = partyMemberService.getAll();
        return new ResponseEntity<>(partyMemberList, HttpStatus.OK);
    }

    @RequestMapping(method = RequestMethod.POST, value = "/partyMembers")
    public ResponseEntity<PartyMember> addEmployee(@Valid @RequestBody PartyMember partyMember) {
        if (partyMemberService.exists(partyMember)) {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }

        partyMember = partyMemberService.save(partyMember);
        return new ResponseEntity<PartyMember>(partyMember, HttpStatus.CREATED);
    }
}

Resulting JSON

    [
      {
        "id": 2,
        "ward": {
          "id": 1,
          "name": "Mercier",
          "wardNumber": 42,
          "numberOfMembers": 3
        },
        "firstName": "Cindy",
        "lastName": "Tremblay",
        "partyMemberId": "12-1234-09876",
        "primaryPhone": "514-555-2323",
        "postalAddress": "1155 Robert-Bourassa, Montreal, Quebec, Canada, H3B3A7",
        "emailAddress": null,
        "secondaryPhone": null,
        "bestTimeToContact": null,
        "bestWayToContact": null,
        "membershipExpiry": null,
        "dateOfBirth": null,
        "donationEntries": [],
        "note": null
      },
      {
        "id": 3,
        "ward": {
          "id": 1,
          "name": "Mercier",
          "wardNumber": 42,
          "numberOfMembers": 3
        },
        "firstName": "Robert",
        "lastName": "Paulson",
        "partyMemberId": "12-1234-54321",
        "primaryPhone": "514-555-1212",
        "postalAddress": "440 Rue Saint-Pierre, App 5, Montreal, Quebec, Canada, H2Y2M5",
        "emailAddress": "[email protected]",
        "secondaryPhone": null,
        "bestTimeToContact": null,
        "bestWayToContact": null,
        "membershipExpiry": null,
        "dateOfBirth": null,
        "donationEntries": [],
        "note": null
      },
      {
        "id": 4,
        "ward": {
          "id": 1,
          "name": "Mercier",
          "wardNumber": 42,
          "numberOfMembers": 3
        },
        "firstName": "Richard",
        "lastName": "Schnobb",
        "partyMemberId": "12-4321-09876",
        "primaryPhone": "514-555-2323",
        "postalAddress": "440 Rue Saint-Pierre, App 5, Montreal, Quebec, Canada, H2Y2M5",
        "emailAddress": null,
        "secondaryPhone": null,
        "bestTimeToContact": null,
        "bestWayToContact": null,
        "membershipExpiry": null,
        "dateOfBirth": null,
        "donationEntries": [],
        "note": null
      }
    ]

Default JSON (Notice the _embedded, _links, etc). This what I want to have as a result.

{
  "_embedded" : {
    "partyMembers" : [ {
      "firstName" : "Cindy",
      "lastName" : "Tremblay",
      "partyMemberId" : "12-1234-09876",
      "primaryPhone" : "514-555-2323",
      "postalAddress" : "1155 Robert-Bourassa, Montreal, Quebec, Canada, H3B3A7",
      "emailAddress" : null,
      "secondaryPhone" : null,
      "bestTimeToContact" : null,
      "bestWayToContact" : null,
      "membershipExpiry" : null,
      "dateOfBirth" : null,
      "donationEntries" : [ ],
      "note" : null,
      "_links" : {
        "self" : {
          "href" : "http://127.0.0.1:8080/partyMembers/2"
        },
        "partyMember" : {
          "href" : "http://127.0.0.1:8080/partyMembers/2"
        },
        "ward" : {
          "href" : "http://127.0.0.1:8080/partyMembers/2/ward"
        }
      }
    }, {
      "firstName" : "Robert",
      "lastName" : "Paulson",
      "partyMemberId" : "12-1234-54321",
      "primaryPhone" : "514-555-1212",
      "postalAddress" : "440 Rue Saint-Pierre, App 5, Montreal, Quebec, Canada, H2Y2M5",
      "emailAddress" : "[email protected]",
      "secondaryPhone" : null,
      "bestTimeToContact" : null,
      "bestWayToContact" : null,
      "membershipExpiry" : null,
      "dateOfBirth" : null,
      "donationEntries" : [ ],
      "note" : null,
      "_links" : {
        "self" : {
          "href" : "http://127.0.0.1:8080/partyMembers/3"
        },
        "partyMember" : {
          "href" : "http://127.0.0.1:8080/partyMembers/3"
        },
        "ward" : {
          "href" : "http://127.0.0.1:8080/partyMembers/3/ward"
        }
      }
    }, {
      "firstName" : "Richard",
      "lastName" : "Schnobb",
      "partyMemberId" : "12-4321-09876",
      "primaryPhone" : "514-555-2323",
      "postalAddress" : "440 Rue Saint-Pierre, App 5, Montreal, Quebec, Canada, H2Y2M5",
      "emailAddress" : null,
      "secondaryPhone" : null,
      "bestTimeToContact" : null,
      "bestWayToContact" : null,
      "membershipExpiry" : null,
      "dateOfBirth" : null,
      "donationEntries" : [ ],
      "note" : null,
      "_links" : {
        "self" : {
          "href" : "http://127.0.0.1:8080/partyMembers/4"
        },
        "partyMember" : {
          "href" : "http://127.0.0.1:8080/partyMembers/4"
        },
        "ward" : {
          "href" : "http://127.0.0.1:8080/partyMembers/4/ward"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://127.0.0.1:8080/partyMembers{?page,size,sort}",
      "templated" : true
    },
    "profile" : {
      "href" : "http://127.0.0.1:8080/profile/partyMembers"
    },
    "search" : {
      "href" : "http://127.0.0.1:8080/partyMembers/search"
    }
  },
  "page" : {
    "size" : 20,
    "totalElements" : 3,
    "totalPages" : 1,
    "number" : 0
  }
}
Braze answered 18/10, 2018 at 22:22 Comment(0)
T
5

To keep Spring doing most of the work for you, you should use the annotation @RepositoryRestController. More explanations in the docs:

Sometimes, you may want to write a custom handler for a specific resource. To take advantage of Spring Data REST’s settings, message converters, exception handling, and more, use the @RepositoryRestController annotation instead of a standard Spring MVC @Controller or @RestController. Controllers annotated with @RepositoryRestController are served from the API base path defined in RepositoryRestConfiguration.setBasePath, which is used by all other RESTful endpoints (for example, /api).


In order to simplify the generation of HATEOAS resources (with the _links you mentioned) you can take advantage of ResourceAssemblerSupport.

As the mapping from an entity to a resource type will have to be used in multiple places it makes sense to create a dedicated class responsible for doing so. The conversion will of course contain very custom steps but also a few boilerplate ones. (...) Spring Hateoas now provides a ResourceAssemblerSupport base class that helps reducing the amount of code needed to be written

Triiodomethane answered 19/10, 2018 at 13:18 Comment(0)
G
2

You've wretched control of the ResponseEntity so it isn't build automatically for you anymore. That means you have to use LinkBuilder or ControllerLinkBuilder to build the links associated with those ResponseEntity's your self.

Docs and examples here: https://docs.spring.io/spring-hateoas/docs/current/reference/html/#fundamentals.obtaining-links.entity-links

a quick sample:

@Getter 
public class PersonResource extends ResourceSupport {
    private final Person person;
    public PersonResource(final Person person) {
        this.person = person;
        final long id = person.getId();
        add(linkTo(PersonController.class).withRel("people"));
        add(linkTo(methodOn(GymMembershipController.class).all(id)).withRel("memberships"));
        add(linkTo(methodOn(PersonController.class).get(id)).withSelfRel());
    }
}

From this most excellent tut: https://dzone.com/articles/applying-hateoas-to-a-rest-api-with-spring-boot

Gothenburg answered 19/10, 2018 at 3:40 Comment(0)
I
2

The return types on your controller are not Spring HATEOAS ResourceSupport types, hence it's HAL serializers will never be invoked.

You should return either Resource<PartyMember> (for single item) or Resources<Resource<PartyMember>> (for lists). Structure your controller to return these.

Make these changes, and then the concept of a reusable ResourceAssembler<PartyMember, Resource<PartyMember>> will make perfect sense as a way to transform PartyMember objects found in the service layer into Resource<PartyMember> objects used to render hypermedia.

To see this evolution of a REST-based service, check out this tutorial (which I rewrote just a few months ago to properly show usage of Spring HATEAOS) => https://spring.io/guides/tutorials/rest/

Italian answered 27/11, 2018 at 3:48 Comment(2)
This should really be the accepted answer. He should be using Resource and not ResponseEntity.Upper
Not totally accurate. ResponseEntity is a Spring Web wrapper that lets you also specify the HTTP response code as well as headers. But the underlying return object must extend ResourceSupport (now know as RepresentationModel since spring.io/blog/2019/03/05/spring-hateoas-1-0-m1-releasedItalian

© 2022 - 2024 — McMap. All rights reserved.