Спроектировать и реализовать REST API для сущностей "Книги" и "Авторы"

5. Спроектировать и реализовать REST API для сущностей "Книги" и "Авторы"

Условие задачи:
Необходимо спроектировать REST API для работы с книгами и авторами.

В базе данных используются таблицы:

books
authors
books_authors

Для книг необходимо реализовать:

  • получение всех книг;

  • получение книги по id;

  • создание книги;

  • обновление книги;

  • удаление книги.

Также нужно описать API в формате:

GET /some/path 200;

Связь между книгами и авторами — Many-to-Many.


Спойлеры к решению

Подсказки
💡 Для книг и авторов создай отдельные JPA-сущности.
💡 Связь можно описать через @ManyToMany и таблицу books_authors.
💡 В REST API лучше использовать DTO, а не возвращать JPA-сущности напрямую.
💡 При создании и обновлении книги получай авторов по переданным authorIds.
💡 Для создания возвращай 201 Created, для удаления — 204 No Content.
💡 Если книга не найдена — возвращай 404 Not Found.

Решение

REST API:

GET    /api/books       200;
GET    /api/books/{id}  200;
POST   /api/books       201;
PUT    /api/books/{id}  200;
DELETE /api/books/{id}  204;

Сущность автора:

@Entity
@Table(name = "authors")
public class AuthorEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // getters / setters
}

Сущность книги:

@Entity
@Table(name = "books")
public class BookEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private Integer publishYear;

    @ManyToMany
    @JoinTable(
            name = "books_authors",
            joinColumns = @JoinColumn(name = "book_id"),
            inverseJoinColumns = @JoinColumn(name = "author_id")
    )
    private Set<AuthorEntity> authors = new HashSet<>();

    // getters / setters
}

DTO для создания и обновления:

public record BookRequestDto(
        @NotBlank String title,
        @NotNull Integer publishYear,
        @NotNull Set<Long> authorIds
) {
}

DTO ответа:

public record BookResponseDto(
        Long id,
        String title,
        Integer publishYear,
        Set<Long> authorIds
) {
}

Репозитории:

public interface BookRepository
        extends JpaRepository<BookEntity, Long> {
}
public interface AuthorRepository
        extends JpaRepository<AuthorEntity, Long> {
}

Сервис:

@Service
@Transactional
public class BookService {

    private final BookRepository bookRepository;
    private final AuthorRepository authorRepository;

    public BookService(
            BookRepository bookRepository,
            AuthorRepository authorRepository
    ) {
        this.bookRepository = bookRepository;
        this.authorRepository = authorRepository;
    }

    @Transactional(readOnly = true)
    public List<BookResponseDto> getAll() {
        return bookRepository.findAll()
                .stream()
                .map(this::toResponse)
                .toList();
    }

    @Transactional(readOnly = true)
    public BookResponseDto getById(long id) {
        return toResponse(findBook(id));
    }

    public BookResponseDto create(BookRequestDto request) {
        BookEntity book = new BookEntity();

        book.setTitle(request.title());
        book.setPublishYear(request.publishYear());
        book.setAuthors(findAuthors(request.authorIds()));

        return toResponse(bookRepository.save(book));
    }

    public BookResponseDto update(
            long id,
            BookRequestDto request
    ) {
        BookEntity book = findBook(id);

        book.setTitle(request.title());
        book.setPublishYear(request.publishYear());
        book.setAuthors(findAuthors(request.authorIds()));

        return toResponse(book);
    }

    public void delete(long id) {
        BookEntity book = findBook(id);
        bookRepository.delete(book);
    }

    private BookEntity findBook(long id) {
        return bookRepository.findById(id)
                .orElseThrow(() ->
                        new NoSuchElementException(
                                "Book not found: " + id
                        )
                );
    }

    private Set<AuthorEntity> findAuthors(
            Set<Long> authorIds
    ) {
        List<AuthorEntity> authors =
                authorRepository.findAllById(authorIds);

        if (authors.size() != authorIds.size()) {
            throw new NoSuchElementException(
                    "One or more authors not found"
            );
        }

        return new HashSet<>(authors);
    }

    private BookResponseDto toResponse(BookEntity book) {
        Set<Long> authorIds = book.getAuthors()
                .stream()
                .map(AuthorEntity::getId)
                .collect(Collectors.toSet());

        return new BookResponseDto(
                book.getId(),
                book.getTitle(),
                book.getPublishYear(),
                authorIds
        );
    }
}

Контроллер:

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping
    public List<BookResponseDto> getAll() {
        return bookService.getAll();
    }

    @GetMapping("/{id}")
    public BookResponseDto getById(
            @PathVariable long id
    ) {
        return bookService.getById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public BookResponseDto create(
            @Valid @RequestBody BookRequestDto request
    ) {
        return bookService.create(request);
    }

    @PutMapping("/{id}")
    public BookResponseDto update(
            @PathVariable long id,
            @Valid @RequestBody BookRequestDto request
    ) {
        return bookService.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(
            @PathVariable long id
    ) {
        bookService.delete(id);
    }
}

Обработка 404:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(NoSuchElementException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String handleNotFound(
            NoSuchElementException exception
    ) {
        return exception.getMessage();
    }
}

При создании и обновлении книги список authorIds преобразуется в набор сущностей AuthorEntity, а Hibernate самостоятельно поддерживает записи в таблице books_authors.

Для операций, изменяющих книгу и её связи с авторами, используется одна транзакция.