Rest API: Hateoas в приложении на Java Spring

И очередная статья из цикла про мой учебный проект, где я делаю простенький REST API и заодно изучаю Spring. В работе музыкальный интернет-магазин, где можно будет «купить» диски, пластинки и т. д. Эта статья начинается с момента, когда я уже спроектировал базу данных, справился с сущностями и репозиториями – data-layer. Также в прошлой статье я занимался классами сервисов и контроллеров, которые позволяют клиенту взаимодействовать с данными. Теперь нужно сделать API более RESTовым и реализовать принцип HATEOAS.

Сначала напомню, где можно найти прошлые статьи:

Отношения One To Many в проекте Java Spring (Hibernate)

Составной первичный ключ в JPA/ Hibernatе

Обработка исключений в Spring REST приложении: @RestControllerAdvice и @ExceptionHandler

Сервисы и контроллеры в Spring REST проектах: аннотации @Service и @RestController

Итак, предлагаю начать с самого понятия HATEOAS. Так как сам я ещё не эксперт в этой области, сильно умничать не буду, а сошлюсь на работу Роя Филдинга, где он описал REST сервисы. Мне её ещё предстоит прочитать, но, думаю, изобретатель REST и в частности HATEOAS лучше меня расскажет всю теорию и идею. Возможно, как я прочитаю и получу больше практического опыта, обновлю статью более умными словами, но пока постараюсь попроще.

HATEOAS это аббревиатура Hypermedia AThe Engine OApplication State, взятая как раз из работы Роя – одно из ограничений REST. То есть без hateoas и рест не рест на самом деле.

Смысл в том, чтобы решить проблему с ссылками и позволить клиенту не знать о том, где находятся ресурсы и что с ними можно делать. Это API должен направлять клиента и вместе с данными давать указания о том, что с ней можно делать.

Пока что мой API возвращает обычный JSON. При запросе информации и релизах певицы Адель в магазине получим список дисков и пластинок:

[
    {
        "releaseId": 1,
        "releaseDate": "2021-11-18T15:00:00.000+00:00",
        "format": "CD",
        "notes": null,
        "label": "Columbia",
        "price": 550,
        "img": "https://upload.wikimedia.org/wikipedia/ru/7/76/Adele_-_30.png",
        "artistName": "Adele",
        "albumTitle": "30"
    },
    {
        "releaseId": 2,
        "releaseDate": "2021-11-18T15:00:00.000+00:00",
        "format": "LP",
        "notes": "White",
        "label": "Columbia",
        "price": 2500,
        "img": "https://upload.wikimedia.org/wikipedia/ru/7/76/Adele_-_30.png",
        "artistName": "Adele",
        "albumTitle": "30"
    }
]

HATEOAS с помощью языка Hal (который выглядит совсем не страшно) должен добавить к этой информации ещё и ссылки, которые покажут клиенту, что можно с каждым релизом делать. Например, добавить в корзину (если приложение разрабатывается для покупателей) или редактировать информацию (если приложение нужно администратору магазина).

{
    "_embedded": {
        "releaseList": [
            {
                "releaseId": 1,
                "releaseDate": "2021-11-18T15:00:00.000+00:00",
                "format": "CD",
                "notes": null,
                "label": "Columbia",
                "price": 550,
                "img": "https://upload.wikimedia.org/wikipedia/ru/7/76/Adele_-_30.png",
                "artistName": "Adele",
                "albumTitle": "30",
                "_links": {
                    "self": {
                        "href": "http://localhost:8090/store/1"
                    },
                    "Info about the album": {
                        "href": "http://localhost:8090/store/albums/1"
                    },
                    "List of all releases to buy in the store": {
                        "href": "http://localhost:8090/store"
                    }
                }
            },
            {
                "releaseId": 2,
                "releaseDate": "2021-11-18T15:00:00.000+00:00",
                "format": "LP",
                "notes": "White",
                "label": "Columbia",
                "price": 2500,
                "img": "https://upload.wikimedia.org/wikipedia/ru/7/76/Adele_-_30.png",
                "artistName": "Adele",
                "albumTitle": "30",
                "_links": {
                    "self": {
                        "href": "http://localhost:8090/store/2"
                    },
                    "Info about the album": {
                        "href": "http://localhost:8090/store/albums/1"
                    },
                    "List of all releases to buy in the store": {
                        "href": "http://localhost:8090/store"
                    }
                }
            }
        ]
    },
    "_links": {
        "self": {
            "href": "http://localhost:8090/store/artists/1/releases"
        },
        "Info about the artist": {
            "href": "http://localhost:8090/store/artists/1"
        }
    }
}

В этом примере я хочу, чтобы к каждому релизу прилагались ссылки на просмотр информации об альбоме, а весь список сопровождался ссылкой на просмотр информации об артисте. Надеюсь, это имеет смысл. В любом случае, пока что более функциональных идей у меня нет.

Предлагаю теперь перейти к изучению Spring HATEOAS

Добавление зависимости в pom.xml

Так как я работаю со Spring Boot, то мне хватит добавить всего лишь пару строчек:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

Также можно заменить эту зависимость на пару других:

<dependency>
    <groupId>org.springframework.hateoas</groupId>
    <artifactId>spring-hateoas</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.plugin</groupId>
    <artifactId>spring-plugin-core</artifactId>
</dependency>

Необходимую версию в тэге <version> можно выбрать самостоятельно (взять последнюю, например) на maven.org.

EntityModel и CollectionModel и подготовка методов Rest-контроллера

Если раньше контроллеры возвращали нужную информацию, которую сервисы когда-то достали из репозиториев, то теперь нам нужно обернуть все эти данные в класс EntityModel, если на руках у нас один объект, или в CollectionModel, если у нас целая коллекция.

Для каждого метода нужно поменять тип данных, которые он возвращает. Если был класс T, то теперь станет EntityModel<T>. Ниже пример с переделкой метода, который возвращает один альбом.

    @GetMapping({"/store/albums/{id}",
            "/store/artists/{idArtist}/albums/{id}"})
    public Album one(@PathVariable long id) {
        return service.getAlbumById(id);
    }

   @GetMapping({"/store/albums/{id}",
            "/store/artists/{idArtist}/albums/{id}"})
    public EntityModel<Album> one(@PathVariable long id) {
        // тело метода напишу позже
    }

А вместо списка List<Album>, который раньше возвращал метод all(), теперь заголовок будет выглядеть следующим образом:

    @GetMapping("/store/albums")
    public CollectionModel<EntityModel<Album>> all() {
          // тело метода напишу позже
    }

То есть теперь вместо списка или другой коллекции будет CollectionModel, внутри которой будут те самые EntityModel<T>.

Интерфейс RepresentationModelAssembler и ссылки на ресурсы

Теперь нам нужно как-то преобразить альбомы в EntityModel<Album>, а артистов – в EntityModel<Artist>, и так далее. Лучше всего в этом поможет интерфейс RepresentationModelAssembler. У него есть целых два параметра (<T, D extends RepresentationModel<?>> ), которые как раз указывают на исходный тип данных и на то, что мы должны в итоге получить. И один метод, который нужно реализовать:

D toModel(T entity);

Им я и займусь.

Итак, для каждого контроллера нужно сделать свой ассемблер. И в методе toModel нужно сделать EntityModel. Делается она с помощью статичного метода of(T content, Link… links). Как видно, в него нужно передать сам объект, который мы превратим в модель, и набор ссылок – их количество может быть любым.

Ссылки можно создать отдельно и потом впихнуть их в новый объект, но я всё сделал сразу в одном месте. Помогает мне в этом WebMvcLinkBuilder и его метод linkTo. LinkTo может ссылаться на конкретный метод из контроллера, а чтобы как-то ссылку обозвать, нужно использовать метод withRel(«Name») или withSelfRel(), если ссылка ведёт на этот же ресурс.

В итоге получаем вот такой ассемблер для альбомов:

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
@Component
public class AlbumModelAssembler implements RepresentationModelAssembler<Album, EntityModel<Album>> {

    @Override
    public EntityModel<Album> toModel(Album entity) {
        return EntityModel.of(entity,
                linkTo(methodOn(AlbumController.class).one(entity.getAlbumId())).withSelfRel(),
                linkTo(methodOn(ReleaseController.class).allByAlbum(entity.getAlbumId()))
                       .withRel("List of all releases in the store for this album"),
                linkTo(methodOn(AlbumController.class)
                       .allByArtist(entity.getArtist().getArtistId()))
                       .withRel("List of all albums in the store by this artist"),
                linkTo(methodOn(ArtistController.class).one(entity.getArtist().getArtistId()))
                       .withRel("Info about the artist"),
                linkTo(methodOn(AlbumController.class).all())
                       .withRel("List of all albums in the store")
                );
    }
}

Тогда тело метода one из класса Album тогда будет выглядеть так:

    @GetMapping({"/store/albums/{id}",
            "/store/artists/{idArtist}/albums/{id}"})
    public EntityModel<Album> one(@PathVariable long id) {
        return assembler.toModel(service.getAlbumById(id));
    }

Всего одна строчка – не намного больше кода, чем было в прошлый раз.

Конечно, можно обойтись и без ассемблера. Тогда кода будет больше. Ниже на примере метода all() показываю, как ещё и с коллекциями работать.

  @GetMapping("/store/albums")
    public CollectionModel<EntityModel<Album>> all() {
        List<EntityModel<Album>> albums = service.getAllAlbums().stream()
                .map(assembler::toModel)
                .collect(Collectors.toList());

        return CollectionModel.of(albums,
                linkTo(methodOn(AlbumController.class).all()).withSelfRel());
    }

Сначала нужно перегнать каждый объект в EntityModel с помощью уже созданного ассемблера. А потом вызвать метод of уже CollectionModel. Он принимает в качестве аргументов нечто Iterable, и мой список вполне неплохо реализует этот интерфейс.

ResponseEntity вместо void

Но не все методы возвращают данные. Есть void-методы, которые соответствовали запросам POST или PUT или PATCH или даже DELETE. В общем, случаев хватает. Рассмотрим, что делать на примере метода, который отвечает за удаление альбома из базы. Так он выглядел раньше:

    @DeleteMapping("/store/artists/{idArtist}/albums/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteAlbum(@PathVariable long id, @PathVariable long idArtist) {
        artistService.getArtistById(idArtist).removeAlbum(service.getAlbumById(id));
        service.deleteAlbum(id);
    }

Теперь нам нужно вернуть не void, а ResponseEntity, в которую можно впихнуть и код ответа сервера. В данном случае контента не будет (мы ведь его только что удалили), поэтому мы просто сообщаем об этом.

    @DeleteMapping({"/store/albums/{id}/delete",
            "/store/artists/{idArtist}/albums/{id}/delete"})
    public ResponseEntity<?> deleteAlbum(@PathVariable long id) {
        service.deleteAlbum(id);
        return ResponseEntity.noContent().build();
    }

Но мы можем отправить любой ответ. Может, вам нужно что-то подтвердить? Тогда сущность будет выглядеть так:

   return ResponseEntity.ok("Everything is fine!");

А может, вам нужно отдельно добавить хэдер и тело:

 return ResponseEntity.ok()
        .header("My Header", "value")
        .body("My body");

Больше примеров в следующем разделе.

Обновлённые REST-контроллеры

Кратенько я пробежался по созданию моделей, ассемблеров, ссылкам и общей идее hateoas. Ниже представляю обновлённые классы. Сравнить их можно с результатом в прошлом туториале.

Начну с артистов.

/* list of imports */ 

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

@RestController
public class ArtistController {

    private final ArtistService service;
    private final ArtistModelAssembler assembler;
    public ArtistController(ArtistService service, ArtistModelAssembler assembler) {
        this.service = service;
        this.assembler = assembler;
    }

    @GetMapping("/store/artists")
    public CollectionModel<EntityModel<Artist>> all() {
        List<EntityModel<Artist>> artists = service.getAll().stream()
                .map(assembler::toModel)
                .collect(Collectors.toList());

        return CollectionModel.of(artists,
                linkTo(methodOn(ArtistController.class)).withSelfRel());
    }

    @GetMapping("/store/artists/{id}")
    public EntityModel<Artist> one(@PathVariable long id) {
        return assembler.toModel(service.getArtistById(id));
    }

    @PostMapping("/store/artists")
    public ResponseEntity<?> addArtist(@RequestBody Artist artist) {
        EntityModel<Artist> modelArtist = assembler.toModel(service.saveArtist(artist));

        return ResponseEntity //
                .created(modelArtist.getRequiredLink(IanaLinkRelations.SELF).toUri()) //
                .body(modelArtist);
    }

    @PatchMapping("/store/artists/{id}/edit")
    public ResponseEntity<?> editArtists(@RequestBody Artist artist, @PathVariable long id) {
        EntityModel<Artist> modelArtist = assembler.toModel(service.editArtist(artist, id));
        return ResponseEntity.ok(modelArtist.getRequiredLink(IanaLinkRelations.SELF).toUri());
    }

    @DeleteMapping("/store/artists/{id}/delete")
    public ResponseEntity<?> deleteArtist(@PathVariable long id){
        service.deleteArtist(id);
        return ResponseEntity.noContent().build();
    }
}

Соответствует классу данный ассемблер:

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

@Component
public class ArtistModelAssembler implements RepresentationModelAssembler<Artist, EntityModel<Artist>> {

    @Override
    public EntityModel<Artist> toModel(Artist entity) {
        return EntityModel.of(entity,
                linkTo(methodOn(ArtistController.class)
                    .one(entity.getArtistId())).withSelfRel(),
                linkTo(methodOn(ArtistController.class)
                    .all()).withRel("List of all artists"),
                linkTo(methodOn(AlbumController.class)
                    .allByArtist(entity.getArtistId()))
                    .withRel("List of albums in the store by this artist"),
                linkTo(methodOn(ReleaseController.class)
                    .allByArtist(entity.getArtistId()))
                    .withRel("List of releases in the store by this artist")
        );
    }
}

Далее переходим к альбомам, о которых я больше всего говорил в этой статье.

/* list of imports */ 

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

@RestController
public class AlbumController {

    private final AlbumService service;
    private final ArtistService artistService;
    private final AlbumModelAssembler assembler;

    public AlbumController(AlbumService service, ArtistService artistService, AlbumModelAssembler assembler) {
        this.service = service;
        this.artistService = artistService;
        this.assembler = assembler;
    }

    @GetMapping("/store/albums")
    public CollectionModel<EntityModel<Album>> all() {
        List<EntityModel<Album>> albums = service.getAllAlbums().stream()
                .map(assembler::toModel)
                .collect(Collectors.toList());

        return CollectionModel.of(albums,
                linkTo(methodOn(AlbumController.class).all()).withSelfRel());
    }

    @GetMapping({"/store/albums/{id}",
            "/store/artists/{idArtist}/albums/{id}"})
    public EntityModel<Album> one(@PathVariable long id) {
        return assembler.toModel(service.getAlbumById(id));
    }

    @GetMapping("/store/artists/{idArtist}/albums/")
    public CollectionModel<EntityModel<Album>> allByArtist(@PathVariable long idArtist) {
        List<EntityModel<Album>> albums = service.getAllAlbumsByArtist(idArtist).stream()
                .map(assembler::toModel)
                .collect(Collectors.toList());

        return CollectionModel.of(albums,
                linkTo(methodOn(AlbumController.class).allByArtist(idArtist)).withSelfRel(),
                linkTo(methodOn(ArtistController.class).one(idArtist))
                      .withRel("Info about the artist"),
                linkTo(methodOn(AlbumController.class).all())
                      .withRel("List of all albums in the store")
        );
    }

    @PostMapping("/store/artists/{idArtist}/albums/")
    public ResponseEntity<?> addAlbum(@RequestBody Album album, @PathVariable long idArtist) {
        artistService.getArtistById(idArtist).addAlbum(album);
        EntityModel<Album> modelAlbum = assembler.toModel(service.saveAlbum(album));

        return ResponseEntity
                .created(modelAlbum.getRequiredLink(IanaLinkRelations.SELF).toUri())
                .body(modelAlbum);
    }

    @PatchMapping({"/store/albums/{id}/edit",
            "/store/artists/{idArtist}/albums/{id}/edit"})
    public ResponseEntity<?> editAlbum(@PathVariable long id, @RequestBody Album album) {
        EntityModel<Album> editedAlbum = assembler.toModel(service.editAlbum(album, id));

        return ResponseEntity
                .ok(editedAlbum.getRequiredLink(IanaLinkRelations.SELF).toUri());
    }

    @DeleteMapping({"/store/albums/{id}/delete",
            "/store/artists/{idArtist}/albums/{id}/delete"})
    public ResponseEntity<?> deleteAlbum(@PathVariable long id) {
        service.deleteAlbum(id);

        return ResponseEntity.noContent().build();
    }

}

И ассемблер:

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

@Component
public class AlbumModelAssembler implements RepresentationModelAssembler<Album, EntityModel<Album>> {

    @Override
    public EntityModel<Album> toModel(Album entity) {
        return EntityModel.of(entity,
                linkTo(methodOn(AlbumController.class).one(entity.getAlbumId())).withSelfRel(),
                linkTo(methodOn(ReleaseController.class).allByAlbum(entity.getAlbumId()))
                     .withRel("List of all releases in the store for this album"),
                linkTo(methodOn(AlbumController.class)
                     .allByArtist(entity.getArtist().getArtistId()))
                     .withRel("List of all albums in the store by this artist"),
                linkTo(methodOn(ArtistController.class)
                     .one(entity.getArtist().getArtistId()))
                     .withRel("Info about the artist"),
                linkTo(methodOn(AlbumController.class).all())
                     .withRel("List of all albums in the store")
                );
    }
}

Ну и раз в прошлых статьях я всегда публиковал ещё и релизы, то здесь тоже нельзя их упустить.

/* list of imports */
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

@RestController
public class ReleaseController {

    private final ReleaseService service;
    private final AlbumService albumService;
    private final ReleaseModelAssembler assembler;

    public ReleaseController(ReleaseService service, AlbumService albumService, ReleaseModelAssembler assembler) {
        this.service = service;
        this.albumService = albumService;
        this.assembler = assembler;
    }

    @GetMapping("/store")
    public CollectionModel<EntityModel<Release>> all() {
        List<EntityModel<Release>> releases = service.getAll().stream()
                .map(assembler::toModel).collect(Collectors.toList());

        return CollectionModel.of(releases,
                linkTo(methodOn(ReleaseController.class).all()).withSelfRel()
        );
    }

    @GetMapping("/store/artists/{artistId}/releases")
    public CollectionModel<EntityModel<Release>> allByArtist(@PathVariable long artistId) {
        List<EntityModel<Release>> releases = service.getReleasesByArtistId(artistId).stream()
                .map(assembler::toModel).collect(Collectors.toList());

        return CollectionModel.of(releases,
                linkTo(methodOn(ReleaseController.class).allByArtist(artistId)).withSelfRel(),
                linkTo(methodOn(ArtistController.class).one(artistId)).withRel("Info about the artist"),
                linkTo(methodOn(ReleaseController.class).all()).withRel("List of all releases in the store")
        );
    }

    @GetMapping({"/store/albums/{albumId}/releases",
            "/store/artists/{artistId}/albums/{albumId}/releases"})
    public CollectionModel<EntityModel<Release>> allByAlbum(@PathVariable long albumId){
        List<EntityModel<Release>> releases = service.getReleasesByAlbumId(albumId).stream()
                .map(assembler::toModel).collect(Collectors.toList());

        return CollectionModel.of(releases,
                linkTo(methodOn(ReleaseController.class).allByAlbum(albumId)).withSelfRel(),
                linkTo(methodOn(AlbumController.class).one(albumId))
                      .withRel("Info about the album"),
                linkTo(methodOn(ReleaseController.class).all())
                      .withRel("List of all releases in the store")
        );
    }

    @GetMapping({"/store/{id}",
            "/store/artists/{artistId}/albums/{albumId}/releases/{id}"})
    public EntityModel<Release> one(@PathVariable long id) {
        return assembler.toModel(service.getReleaseById(id));
    }

    @PostMapping({"/store/albums/{albumId}/releases",
            "/store/artists/{artistId}/albums/{albumId}/releases"})
    public ResponseEntity<?> addRelease(@RequestBody Release release, @PathVariable long albumId) {
        release.setAlbum(albumService.getAlbumById(albumId));
        EntityModel<Release> releaseModel = assembler.toModel(service.save(release));

        return ResponseEntity
                .created(releaseModel.getRequiredLink(IanaLinkRelations.SELF).toUri())
                .body(releaseModel);
    }

    @PutMapping({"/store/{id}/release",
            "/store/albums/{albumId}/releases/{id}/release",
            "/store/artists/{artistId}/albums/{albumId}/releases/{id}/release"})
    public ResponseEntity<?> releaseIt(@PathVariable long id) {
        service.release(id);

        return ResponseEntity.ok().build();
    }

    @PatchMapping( {"/store/{id}/edit",
            "/store/albums/{albumId}/releases/{id}/edit",
            "/store/artists/{artistId}/albums/{albumId}/releases/{id}/edit"})
    public ResponseEntity<?> editRelease(@PathVariable long id, @RequestBody Release release) {
        EntityModel<Release> editedRelease = assembler.toModel(service.edit(release, id));

        return ResponseEntity
                .ok(editedRelease.getRequiredLink(IanaLinkRelations.SELF).toUri());
    }

    @DeleteMapping({"/store/{id}/delete",
            "/store/albums/{albumId}/releases/{id}/delete",
            "/store/artists/{artistId}/albums/{albumId}/releases/{id}/delete"})
    public ResponseEntity<?> deleteRelease(@PathVariable long id) {
        service.delete(id);

        return ResponseEntity.noContent().build();
    }
}

У него тоже есть ассемблер.


import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

@Component
public class ReleaseModelAssembler implements RepresentationModelAssembler<Release, EntityModel<Release>> {

    @Autowired
    private final ReleaseService service;

    public ReleaseModelAssembler(ReleaseService service) {
        this.service = service;
    }

    @Override
    public EntityModel<Release> toModel(Release entity) {
        long artistId = service.getArtistIdByReleaseId(entity.getReleaseId());

        return EntityModel.of(entity,
                linkTo(methodOn(ReleaseController.class)
                      .one(entity.getReleaseId()))
                      .withSelfRel(),
              linkTo(methodOn(AlbumController.class)
                      .one(entity.getAlbum().getAlbumId()))
                      .withRel("Info about the album"),
                linkTo(methodOn(ReleaseController.class)
                      .allByAlbum(entity.getAlbum().getAlbumId()))
                      .withRel("List of releases in the store for this album"),
                linkTo(methodOn(ArtistController.class)
                      .one(artistId))
                      .withRel("Info about the artist"),
                linkTo(methodOn(ReleaseController.class)
                      .allByArtist(artistId))
                      .withRel("List of releases in the store for this artist"),
                linkTo(methodOn(ReleaseController.class)
                      .all()).withRel("List of all releases to buy in the store")
        );
    }
}

На этом всё, будем считать, что я закончил цикл статей с этим проектом. Не исключено, что я позже вернусь к этим темам, но уже с другими примерами и другими задачами. Ну и с обновлёнными и дополненными знаниями. Удачи вам в изучении Джавы. И мне тоже удачи.

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s