Spring-boot + REST + HATEOAS + HAL
Spring-boot + REST + HATEOAS + HAL
我按照 spring.io Pivotal 教程获得了带有 MySQL 数据库的 REST API,并且进展顺利。但是,我发现了一个我无法配置或变通的行为。
当我使用内置功能从 PagingAndSortingRepository 检索资源时,生成的 REST 自动分页并封装有有用的 HAL 链接(_links、self、search、链接资源等)。我想用那个。
当我实现我的控制器来自定义 PostMapping 行为并引入完整性检查、验证等时,GetMapping 停止工作。所以我重新实现了一个利用我的服务层的 GetMapping。
不幸的是,这样做破坏了之前提供的 HATEOAS。
我想要的是能够自定义 PostMapping,但保留与默认值完全相同的 GetMapping。如果可能的话,我很想避免自己编写它,因为我知道框架可以提供它。
有什么办法吗?
控制器:
@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);
}
}
结果 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": "rpaulson@papermillsoapcompany.com",
"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
}
]
默认 JSON(注意 _embedded、_links 等)。这就是我想要的结果。
{
"_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" : "rpaulson@papermillsoapcompany.com",
"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
}
}
您对 ResponseEntity 的控制很糟糕,所以它不再为您自动构建。这意味着您必须使用 LinkBuilder 或 ControllerLinkBuilder 来构建与您自己的 ResponseEntity 关联的链接。
快速示例:
@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());
}
}
来自这个最优秀的图:https://dzone.com/articles/applying-hateoas-to-a-rest-api-with-spring-boot
要让 Spring 为您完成大部分工作,您应该使用注释 @RepositoryRestController
。更多解释在 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).
为了简化 HATEOAS 资源的生成(使用您提到的 _links
),您可以利用 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
您控制器上的 return 类型不是 Spring HATEOAS ResourceSupport
类型,因此永远不会调用它的 HAL 序列化程序。
您应该 return Resource<PartyMember>
(对于单个项目)或 Resources<Resource<PartyMember>>
(对于列表)。将您的控制器构造为 return 这些。
进行这些更改,然后可重用的概念 ResourceAssembler<PartyMember, Resource<PartyMember>>
将非常有意义,因为它可以将服务层中找到的 PartyMember
个对象转换为 Resource<PartyMember>
个用于呈现超媒体。
要了解基于 REST 的服务的这种演变,请查看本教程(我在几个月前重写以正确显示 Spring HATEAOS 的用法)=> https://spring.io/guides/tutorials/rest/
我按照 spring.io Pivotal 教程获得了带有 MySQL 数据库的 REST API,并且进展顺利。但是,我发现了一个我无法配置或变通的行为。
当我使用内置功能从 PagingAndSortingRepository 检索资源时,生成的 REST 自动分页并封装有有用的 HAL 链接(_links、self、search、链接资源等)。我想用那个。
当我实现我的控制器来自定义 PostMapping 行为并引入完整性检查、验证等时,GetMapping 停止工作。所以我重新实现了一个利用我的服务层的 GetMapping。
不幸的是,这样做破坏了之前提供的 HATEOAS。
我想要的是能够自定义 PostMapping,但保留与默认值完全相同的 GetMapping。如果可能的话,我很想避免自己编写它,因为我知道框架可以提供它。
有什么办法吗?
控制器:
@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);
}
}
结果 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": "rpaulson@papermillsoapcompany.com",
"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
}
]
默认 JSON(注意 _embedded、_links 等)。这就是我想要的结果。
{
"_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" : "rpaulson@papermillsoapcompany.com",
"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
}
}
您对 ResponseEntity 的控制很糟糕,所以它不再为您自动构建。这意味着您必须使用 LinkBuilder 或 ControllerLinkBuilder 来构建与您自己的 ResponseEntity 关联的链接。
快速示例:
@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());
}
}
来自这个最优秀的图:https://dzone.com/articles/applying-hateoas-to-a-rest-api-with-spring-boot
要让 Spring 为您完成大部分工作,您应该使用注释 @RepositoryRestController
。更多解释在 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).
为了简化 HATEOAS 资源的生成(使用您提到的 _links
),您可以利用 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
您控制器上的 return 类型不是 Spring HATEOAS ResourceSupport
类型,因此永远不会调用它的 HAL 序列化程序。
您应该 return Resource<PartyMember>
(对于单个项目)或 Resources<Resource<PartyMember>>
(对于列表)。将您的控制器构造为 return 这些。
进行这些更改,然后可重用的概念 ResourceAssembler<PartyMember, Resource<PartyMember>>
将非常有意义,因为它可以将服务层中找到的 PartyMember
个对象转换为 Resource<PartyMember>
个用于呈现超媒体。
要了解基于 REST 的服务的这种演变,请查看本教程(我在几个月前重写以正确显示 Spring HATEAOS 的用法)=> https://spring.io/guides/tutorials/rest/