Создаём CRUD REST API в Spring Boot быстро и просто вместе с Amplicode

Не так давно на нашем канале вышло видео, в котором Георгий Власов рассказывал, как с помощью Amplicode можно сгенерировать CRUD Rest Controller сразу со всеми необходимыми методами.  

Но создавать что-то с нуля нам приходиться не так часто, как модифицировать, улучшать и дополнять уже имеющийся код.  

Команда Amplicode прекрасно это понимает, и по этой причине в нашем инструменте есть возможность создавать как отдельные CRUD методы для контроллера, так и возможность делегирования методов в контроллер из уже существующих бинов. Благодаря этим фичам, разработка API становится одной из простейших и приятнейших  задач.    

Давайте посмотрим, как эти фичи выглядят на практике!  

Статья также доступна в формате видео на YouTube, VK Видео и RUTUBE, так что можно и смотреть, и читать — как вам удобнее!  

Спойлер

В большинстве случаев я буду использовать изображения для демонстрации фрагментов кода. Такой подход позволит мне выделить важные части и более подробно их объяснить. Если вы хотите проверить всё самостоятельно, запустив код, о котором пойдет речь, то найти его можно на GitHub. 

Обзор приложения 

Начнём с обзора нашего приложения с использованием панели Amplicode Explorer.  

К проекту подключены стартеры Spring Web, Spring Data JPA и Actuators, в качестве системы версионирования баз данных используется Flyway, база данных PostgreSQL, а также используется Kafka. 

dd59eae9beb6e6c36aa5803d8b8540c7.png

Модель данных, я думаю, многим уже знакомая — PetClinic. Нас будут сегодня интересовать владельцы домашних животных. 

b8b9007ae5cc3450028e89ad5e34b2e5.png

 На самом деле у нас уже есть контроллер для Owner«а, но, как мы можем заметить, это MVC контроллер, а не REST. 

166124929993b215c00979dafaa1fdfc.png

Наконец, для работы с окружением в проекте используются несколько docker-compose.yaml файлов. Нам сегодня пригодится только dev файл с PostgreSQL, Kafka и инструментами для работы с ними. 

f57b88baaf3ea5b78758c45e6a434c0d.png

Постановка задачи

Ну что, погнали! Создадим новый REST контроллер, назовем его OwnerRestController и укажем пакет. Request path укажем по всем канонам REST«а rest/owners

fc0ff8381b9055114c6a169b8b4a350e.png

 Контроллер готов.  

package org.springframework.samples.petclinic.owner.rest;  
import org.springframework.web.bind.annotation.RestController; 
import org.springframework.web.bind.annotation.RequestMapping;  

@RestController 
@RequestMapping("/rest/owners") 

public class OwnerRestController {  } 

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

CRUD расшифровывается как Create, Read, Update, Delete. То есть эндпоинтов у нас должно быть 4. Но мы пойдем чуть дальше и реализуем 6 эндпоинтов:

  • 4 стандартных: для создания, получения, обновления и удаления owner«ов

  • два не очень популярных, но тоже часто встречающихся: метод частичного обновления и метод получения всех записей с пагинацией 

Amplicode позволяет нам создать не 4, и даже не 6 методов, а аж 10 различных вариаций методов, которые могут быть полезны для CRUD контроллера. Обратиться к действиям Amplicode можно из:

  • панели Generate (1)

  • Gutter иконки рядом с названием контроллера (2)

  • А также из панели Amplicode Designer (3)

0b41b58949ee34598d50820bcac73b16.png

Чуть позже мы воспользуемся некоторыми из них. Но я бы хотел также обратить ваше внимание на действие Delegate from… (1)

5c07afc4cb28f3a790cea6a486730350.png

Конечно, API — это далеко не всегда CRUD. Зачастую — это делегация кастомных методов из сервисов и репозиториев. Amplicode помогает и тут, позволяя делегировать метод из любого бина в контроллер.  

Давайте первый метод, а именно метод получения записи по id, для нашего контроллера реализуем через действие делегации.

Делегация метода findById

Бин, из которого мы будем делегировать метод, автоматически выбран именно тот, что мне и нужно — это OwnerRepository (1). 

Найдем метод findById (2) и изменим название на getOne (3). Прямо из этого диалога мы можем тонко настроить, как именно будет выглядеть наш эндпоинт, какой у него будет маппинг, тип и параметры запроса, а также возвращаемый тип. 

Самое классное — это то, что прямо отсюда мы можем создать DTO (4). Давайте так и поступим, ведь использовать сущность в качестве возвращаемого типа в методах контроллера — дурной тон.  

Моделька может поменяться в любой момент и не хотелось бы, чтобы изменения в ней влияли также и на работу нашего API. К тому же, так мы обезопасим себя от дополнительных запросов в БД для загрузки информации об ассоциативных связях. Для того, чтобы создать DTO, нужно воспользоваться кнопкой настройки маппинга (4):  

4cf386236cdf078be8c38f4e2c24c4fe.png

После чего мы попадем в диалог, где мы можем настроить маппинг под требуемые задачи:  

5388c887eeb7771af54d66401b4c8d0d.png

 Создадим OwnerDto:

f65172e284f2c18afe6a53752b6cb634.png

 Выберем для неё все базовые поля:  

34ea93fa465e0ac0bc73946b4b7ff033.png

Также создадим MapStruct маппер для простого преобразования сущностей в DTO и обратно. Сделать это можно кнопкой создания MapStruct интерфейса напротив соответствующего поля:

7d1e7f959ce5b25b749728049336eb4b.png

Откроется диалог создания MapStruct интерфейса, где можно указать пакет для будущего интерфейса, его название и source root. Оставим поля заполненными по умолчанию и нажмем Ок. 

88826a32e4ec2b47c2d83d9850e71397.png

Перед нами вновь диалог настройки маппера, но уже с указанием MapStruct интерфейса. Мы успешно заполнили все поля. Ок. 

4ecfd0f009397b6a2b7cf0e39e7b2995.png

Наконец, отметим чекбокс unwrap optional, чтобы Amplicode сгенерировал код для получения значение из Optional«а. 

ef0bde3bc6b50fe0f7d7499fd7c296e6.png

 Отлично! Первый метод готов.  

a488008121890ad2f922f80fb0e08e66.png

Amplicode сгенерировал нам не только сам метод в контроллере, но и MapStruct маппер:

166860bbed1c0829748a8cc0b743a948.png

А также DTOшку со всеми полями, которые мы выбирали. Довольно круто, что для DTOшки он даже перенес все валидации, которые указаны у нас в модели. 

0b96017671eed571f718d20605399d99.png

Давайте немного подправим сгенерированный метод под нашу бизнес логику. В случае, если запись с указанным id не будет найдена, будем выбрасывать 404 и уведомлять пользователя о том, что именно произошло. 

224cae8a6df508fdac3ff800a195443f.png

Эндпоинт для получения всех записей

Следующим создадим метод для получения всех записей. На этот раз обратимся к действию CRUD methods. Сделать это можно, например, через меню Generate

07a13e0e6105a4d28955e501d80712d2.png

Здесь как раз есть метод Get All с пагинацией, фильтрацией и сортировкой. Именно то, что нам и надо.

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

Но вот возвращать сразу все записи со всей персональной информацией о владельцах животных кажется не лучшей затеей. Поэтому давайте для этого эндпоинта создадим другую DTO:  

45c5104fbcda4f9d9083801dfcafeba8.png

Которую назовём OwnerMinimalDto (1). Маппер выберем уже существующий (2). И в набор полей не будем включать адрес и номер телефона (3).  

6e83cdc81ddad0ed8cff8db3e046405e.png

 Теперь перейдем к созданию фильтра.  

d8f92d008a462b8f3ff1bd6ad6b08443.png

Я бы хотел выбирать записи по firstName, lastName и city. Для всех параметров фильтрации будем игнорировать регистр, поиск по firstName и lastName будем выполнять по условию startsWith, а city по полному соответствию. 

87d9c30610284e809f4b7ab64b9873ee.png

 Вуаля! Метод готов:  

389dd45d8014e676ecb6620ff3a13419.png

 Как и новый метод в маппере:  

3a41b647119029d760dccc09fc3a2160.png

 OwnerFilter с использованием спецификаций:  

4c3ca4a5282e6f6c4d601331249bf98a.png

  и новая DTOшка:

8c429cb8851da97387e810533ce0accd.png

Эндпоинт для создания создания нового Owner«а

Следующим давайте реализуем метод создания нового Owner«а. На этот раз давайте сделаем это через Gutter-иконку:  

95bddc8f057e740535c8e17a1d90c19d.png

Здесь дело пойдет совсем быстро. Все необходимые элементы у нас уже есть, просто выберем нужную DTOшку и нажмём Ок.  

1de6402f665794884735f0adeb74fd16.png

 Amplicode сгенерил нам метод в контроллере:  

9553ab5beb5396170349636fe70edb0d.png

 А также добавил метод toEntity в наш маппер.  

a00980e7006247d0b32b3bf30f570845.png

Сам метод получился довольно простой. Маппим DTOшку на сущность, сохраняем в базу и результат сохранения маппим обратно на DTO, затем возвращаем пользователю. 

Единственное, что стоит упомянуть, так это то, что так как в нашем сервисе id назначается автоматически базой данных, давайте проверим, что пользователь не передаёт id вручную, и если он делает это, то будем выбрасывать исключение с соответствующим сообщением. 

0c047c11e1e30247d0a1da1bcea688f7.png

Эндпоинт для полного обновления Owner«а

Перейдём к методу полного обновления.

3b2e2b3535caf129f74670b0e1406df7.pngad54717d49d338eb89838b0ad902b1a6.png

 Снова Amplicode всё сделал за нас:  

cb2e136af688010844ec69affebb6c8d.png

 В том числе и сгенерил метод для MapStruct маппера.  

bf43813e1a7e8c22f017ca36ed27be09.png

Остается только убедиться, что значения id в pathVariable и requestBody совпадают. 

d79772780123576564e8b4e07ae630db.png

Единственно отличие от метода создания заключается в предварительной проверке того, что запись, которую хочет обновить пользователь, действительно существует. Далее используем MapStruct'овский метод updateWithNull, сохраняем в базу и результат возвращаем обратно. 

9888db9573bbafe21c725551596b6c22.png

Эндпоинт для частичного обновления Owner«а

Настало время реализовать не самый тривиальный метод — метод частичного обновления. Стоит понимать, что реализуя PATCH, надо держать в голове следующий нюанс: нам нужно уметь отличить, действительно ли пользователь хочет обновить какое-либо из значений на null, либо же он просто не указал значение для одного из полей нашей DTO. Посмотрим, как с этим справится Amplicode.  

64402d1b3c94365c90dd300626d1db18.pngb488fee91c5fc8e7fe29382fb5753c0e.png

 Что ж, он справился вполне неплохо!   

3f60bcc0b350a399938662562087bf19.png

Вместо DTO ожидается JsonNode (1), поэтому набор полей которые явно передал пользователь, мы будем знать со 100%-ой гарантией. Нам остается только воспользоваться этой информацией, чтобы сначала обновить DTOшку (2), которую мы сами создадим, получив актуальную информацию о владельце из БД (3), а затем использовать эту обновленную DTOшку для обновления информации о владельце (4). 

5600adf6964ab22f57ae7608a78564e0.png

Почему именно так, а не иначе? А потому что сделать по-другому мы здесь просто не можем. Ведь если мы решим обновлять сущность напрямую из patchNode, то мы бы никак не смогли ограничить пользователя в полях, которые ему доступны для обновления. Зная модель, он бы мог передать абсолютно любое название поля и, если оно есть в модели, обновить его.

Мы такой патч не хотим и поэтому сначала прогоняем JsonNode через DTOшку, в которой жестко зафиксирован набор полей доступных для обновления. И только затем обновляем сущность, отталкиваясь от значений в DTO.

Остается только проверить, что если id в теле запроса не null, то он должен совпадать со значением из pathVariable

76c2bf3266d1d6e9e275c5542dfcca2a.png

Эндпоинт для удаления Owner«а

Наконец, последний метод, который нам понадобится — DELETE.  

637f802dfdb86e8826c73675ab5ac703.pngf6919bce6eebf9b07946679c9349ee74.png

Ну здесь совсем ничего менять не пришлось. Amplicode сгенерировал именно то, что нам полностью и подходит.

2fa0a2eef2fbc922178c1d5513221d90.png

HTTP Client

Давайте взглянем на все 6 эндпоинтов, которые у нас получились в Amplicode Explorer«е:   

e9fcbe0553108310b2520376e43def94.png

Остается только их проверить.  

Давайте запустим все необходимые сервисы для нашего приложения,  

6a9aba1457f91ba1d87395b7ca704459.png

 А также стартанём само приложение.

1ab5a812bd60a89a17ef04a27cb89152.png

Начиная с версии Amplicode 2024.3, у нас есть возможность выполнять HTTP запросы прямо из IDE!

Перейдем к эндпоинту получения записи по id и сгенерируем HTTP-запрос. 

Идет вставка изображения...

Идет вставка изображения…

Подробнее про HTTP клиент я расскажу в одной из следующих статей. Сейчас только отмечу, что это решение на Kotlin скриптах, которое под капотом использует REST-Assured.  

Благодаря такой комбинации, мы можем довольно элегантно описывать HTTP запросы на Kotlin и общаться с нашим приложением. 

Как видите, запись с ID 1 успешно нам вернулась.  

6c14a38f3451d7f270105f07477c8869.png

 Аналогично мы могли бы проверить и все остальные эндпоинты.  

Идет вставка изображения...

Идет вставка изображения…

Но в действительности, более правильной проверкой в данном случае будет написание тестов. Amplicode может помочь нам и здесь!   

Генерируем тесты вместе с Amplicode

Давайте сгенерируем SpringWeb тесты для всех эндпоинтов нашего контроллера.  

91f26741cd917bb0d77347bbb07a04f1.png74c287601a03c8f24055b358ecda6567.png

Жмём Ок и тесты готовы!   

OwnerRestControllerTest

package org.springframework.samples.petclinic.owner.rest;

import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.MediaType;
import org.springframework.samples.petclinic.owner.Owner;
import org.springframework.samples.petclinic.owner.OwnerRepository;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
public class OwnerRestControllerTest {
    @ServiceConnection
    @Container
    static final PostgreSQLContainer postgresqlContainer =
            new PostgreSQLContainer<>(DockerImageName.parse("postgres:17-alpine"))
                    .waitingFor(Wait.defaultWaitStrategy());

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private OwnerRepository ownerRepository;

    @Test
    public void create() throws Exception {
        String ownerDto = """
                {
                  "firstName": "John",
                  "lastName": "Doe",
                  "address": "123 Main St",
                  "city": "Anytown",
                  "telephone": "1234567890"
                }""";

        MvcResult mvcResult = mockMvc.perform(post("/rest/owners")
                        .content(ownerDto)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status()
                        .isOk())
                .andExpect(jsonPath("$.id").isNumber())
                .andReturn();

        String jsonResponse = mvcResult.getResponse()
                .getContentAsString();
        Integer id = JsonPath.parse(jsonResponse)
                .read("$.id");

        assertThat(id).isNotNull();

        ownerRepository.findById(id)
                .orElseThrow();

        ownerRepository.deleteById(id);
    }

    @Test
    public void createIdNotNull() throws Exception {
        String ownerDto = """
                {
                  "id": 1,
                  "firstName": "John",
                  "lastName": "Doe",
                  "address": "123 Main St",
                  "city": "Anytown",
                  "telephone": "1234567890"
                }""";

        mockMvc.perform(post("/rest/owners")
                        .content(ownerDto)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest());

        int size = ownerRepository.findAll()
                .size();

        assertThat(size).isEqualTo(0);
    }

    @Test
    @Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    @Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    public void getOne() throws Exception {
        mockMvc.perform(get("/rest/owners/{0}", 1))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.firstName").value("John"))
                .andExpect(jsonPath("$.lastName").value("Doe"));
    }

    @Test
    public void getOneNotFound() throws Exception {
        mockMvc.perform(get("/rest/owners/{0}", 0))
                .andExpect(status()
                        .isNotFound());
    }

    @Test
    @Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    @Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    public void delete() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.delete("/rest/owners/{0}", 1))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.firstName").value("John"))
                .andReturn();
        int size = ownerRepository.findAll()
                .size();

        assertThat(size).isEqualTo(4);
    }

    @Test
    @Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    @Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    public void update() throws Exception {
        String dto = """
                {
                    "id": 1,
                    "firstName": "Johny",
                    "lastName": "Dow-vie",
                    "address": "Spring Avenue",
                    "city": "Spring ville",
                    "telephone": "9999999999"
                }""";

        mockMvc.perform(put("/rest/owners/{0}", 1)
                        .content(dto)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status()
                        .isOk())
                .andExpect(jsonPath("$.firstName").value("Johny"))
                .andExpect(jsonPath("$.lastName").value("Dow-vie"))
                .andExpect(jsonPath("$.city").value("Spring ville"));

        Owner owner = ownerRepository.findById(1)
                .orElseThrow();

        assertThat(owner.getFirstName()).isEqualTo("Johny");
        assertThat(owner.getTelephone()).isEqualTo("9999999999");
        assertThat(owner.getAddress()).isEqualTo("Spring Avenue");
    }

    @Test
    @Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    @Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    public void patch() throws Exception {
        String patchNode = """
                {
                  "firstName": "Johnny",
                  "address": null,
                  "city": "Spring ville"
                }""";

        mockMvc.perform(MockMvcRequestBuilders.patch("/rest/owners/{0}", 1)
                        .content(patchNode)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.firstName").value("Johnny"))
                .andExpect(jsonPath("$.address").value(nullValue()))
                .andExpect(jsonPath("$.city").value("Spring ville"))
                .andExpect(jsonPath("$.lastName").value("Doe"))
                .andExpect(jsonPath("$.telephone").value("555-123-4567"));

        Owner owner = ownerRepository.findById(1)
                .orElseThrow();

        assertThat(owner.getAddress()).isNull();
        assertThat(owner.getFirstName()).isEqualTo("Johnny");
        assertThat(owner.getCity()).isEqualTo("Spring ville");
        assertThat(owner.getTelephone()).isEqualTo("555-123-4567");
        assertThat(owner.getLastName()).isEqualTo("Doe");
    }

    @Test
    @Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    @Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    public void getAll() throws Exception {
        mockMvc.perform(get("/rest/owners")
                        .param("lastNameStarts", "Do"))
                .andExpect(status()
                        .isOk())
                .andExpect(jsonPath("$.content[*].lastName").value(everyItem(is("Doe"))))
                .andExpect(jsonPath("$.content.length()").value(2));
    }
}

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

c1d870153847b21cd4ef1002c3401dac.png

Чтож, все тесты зелёные!   

e0ea7d66cc8ee8091d40123e05d096b2.png

Значит, мы корректно реализовали наш CRUD REST API!   

Заключение

Сегодня мы узнали, как с помощью Amplicode можно легко и просто реализовать REST API в Spring-приложениях!  

6eb141a704c316ade9f6b785d889dcda.png

Подписывайтесь на наши Telegram и YouTube, чтобы не пропустить новые материалы про Amplicode, Spring и связанные с ним технологии!   

А если вы хотите попробовать Amplicode в действии — то можете установить его абсолютно бесплатно уже сейчас, как в IntelliJ IDEA/GigaIDE, так и в VS Code. 

© Habrahabr.ru