Создаём 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.
Модель данных, я думаю, многим уже знакомая — PetClinic. Нас будут сегодня интересовать владельцы домашних животных.
На самом деле у нас уже есть контроллер для Owner«а, но, как мы можем заметить, это MVC контроллер, а не REST.
Наконец, для работы с окружением в проекте используются несколько docker-compose.yaml
файлов. Нам сегодня пригодится только dev
файл с PostgreSQL, Kafka и инструментами для работы с ними.
Постановка задачи
Ну что, погнали! Создадим новый REST контроллер, назовем его OwnerRestController
и укажем пакет. Request path укажем по всем канонам REST«а rest/owners
.
Контроллер готов.
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)
Чуть позже мы воспользуемся некоторыми из них. Но я бы хотел также обратить ваше внимание на действие Delegate from… (1)
Конечно, API — это далеко не всегда CRUD. Зачастую — это делегация кастомных методов из сервисов и репозиториев. Amplicode помогает и тут, позволяя делегировать метод из любого бина в контроллер.
Давайте первый метод, а именно метод получения записи по id, для нашего контроллера реализуем через действие делегации.
Делегация метода findById
Бин, из которого мы будем делегировать метод, автоматически выбран именно тот, что мне и нужно — это OwnerRepository
(1).
Найдем метод findById
(2) и изменим название на getOne
(3). Прямо из этого диалога мы можем тонко настроить, как именно будет выглядеть наш эндпоинт, какой у него будет маппинг, тип и параметры запроса, а также возвращаемый тип.
Самое классное — это то, что прямо отсюда мы можем создать DTO (4). Давайте так и поступим, ведь использовать сущность в качестве возвращаемого типа в методах контроллера — дурной тон.
Моделька может поменяться в любой момент и не хотелось бы, чтобы изменения в ней влияли также и на работу нашего API. К тому же, так мы обезопасим себя от дополнительных запросов в БД для загрузки информации об ассоциативных связях. Для того, чтобы создать DTO, нужно воспользоваться кнопкой настройки маппинга (4):
После чего мы попадем в диалог, где мы можем настроить маппинг под требуемые задачи:
Создадим OwnerDto
:
Выберем для неё все базовые поля:
Также создадим MapStruct
маппер для простого преобразования сущностей в DTO и обратно. Сделать это можно кнопкой создания MapStruct
интерфейса напротив соответствующего поля:
Откроется диалог создания MapStruct
интерфейса, где можно указать пакет для будущего интерфейса, его название и source root. Оставим поля заполненными по умолчанию и нажмем Ок.
Перед нами вновь диалог настройки маппера, но уже с указанием MapStruct
интерфейса. Мы успешно заполнили все поля. Ок.
Наконец, отметим чекбокс unwrap optional, чтобы Amplicode сгенерировал код для получения значение из Optional
«а.
Отлично! Первый метод готов.
Amplicode сгенерировал нам не только сам метод в контроллере, но и MapStruct
маппер:
А также DTOшку со всеми полями, которые мы выбирали. Довольно круто, что для DTOшки он даже перенес все валидации, которые указаны у нас в модели.
Давайте немного подправим сгенерированный метод под нашу бизнес логику. В случае, если запись с указанным id
не будет найдена, будем выбрасывать 404
и уведомлять пользователя о том, что именно произошло.
Эндпоинт для получения всех записей
Следующим создадим метод для получения всех записей. На этот раз обратимся к действию CRUD methods. Сделать это можно, например, через меню Generate.
Здесь как раз есть метод Get All
с пагинацией, фильтрацией и сортировкой. Именно то, что нам и надо.
Возвращать всю информацию о конкретном пользователе, в целом, кажется нормальной идеей, с учетом того, что мы в дальнейшем напишем метод для обновления имеющихся записей и нам надо откуда-то узнать, а какие вообще данные там изначально лежат.
Но вот возвращать сразу все записи со всей персональной информацией о владельцах животных кажется не лучшей затеей. Поэтому давайте для этого эндпоинта создадим другую DTO:
Которую назовём OwnerMinimalDto
(1). Маппер выберем уже существующий (2). И в набор полей не будем включать адрес и номер телефона (3).
Теперь перейдем к созданию фильтра.
Я бы хотел выбирать записи по firstName
, lastName
и city
. Для всех параметров фильтрации будем игнорировать регистр, поиск по firstName
и lastName
будем выполнять по условию startsWith
, а city
по полному соответствию.
Вуаля! Метод готов:
Как и новый метод в маппере:
OwnerFilter с использованием спецификаций:
и новая DTOшка:
Эндпоинт для создания создания нового Owner«а
Следующим давайте реализуем метод создания нового Owner
«а. На этот раз давайте сделаем это через Gutter-иконку:
Здесь дело пойдет совсем быстро. Все необходимые элементы у нас уже есть, просто выберем нужную DTOшку и нажмём Ок.
Amplicode сгенерил нам метод в контроллере:
А также добавил метод toEntity
в наш маппер.
Сам метод получился довольно простой. Маппим DTOшку на сущность, сохраняем в базу и результат сохранения маппим обратно на DTO, затем возвращаем пользователю.
Единственное, что стоит упомянуть, так это то, что так как в нашем сервисе id
назначается автоматически базой данных, давайте проверим, что пользователь не передаёт id
вручную, и если он делает это, то будем выбрасывать исключение с соответствующим сообщением.
Эндпоинт для полного обновления Owner«а
Перейдём к методу полного обновления.
Снова Amplicode всё сделал за нас:
В том числе и сгенерил метод для MapStruct
маппера.
Остается только убедиться, что значения id
в pathVariable
и requestBody
совпадают.
Единственно отличие от метода создания заключается в предварительной проверке того, что запись, которую хочет обновить пользователь, действительно существует. Далее используем MapStruct
'овский метод updateWithNull
, сохраняем в базу и результат возвращаем обратно.
Эндпоинт для частичного обновления Owner«а
Настало время реализовать не самый тривиальный метод — метод частичного обновления. Стоит понимать, что реализуя PATCH, надо держать в голове следующий нюанс: нам нужно уметь отличить, действительно ли пользователь хочет обновить какое-либо из значений на null
, либо же он просто не указал значение для одного из полей нашей DTO. Посмотрим, как с этим справится Amplicode.
Что ж, он справился вполне неплохо!
Вместо DTO ожидается JsonNode
(1), поэтому набор полей которые явно передал пользователь, мы будем знать со 100%-ой гарантией. Нам остается только воспользоваться этой информацией, чтобы сначала обновить DTOшку (2), которую мы сами создадим, получив актуальную информацию о владельце из БД (3), а затем использовать эту обновленную DTOшку для обновления информации о владельце (4).
Почему именно так, а не иначе? А потому что сделать по-другому мы здесь просто не можем. Ведь если мы решим обновлять сущность напрямую из patchNode
, то мы бы никак не смогли ограничить пользователя в полях, которые ему доступны для обновления. Зная модель, он бы мог передать абсолютно любое название поля и, если оно есть в модели, обновить его.
Мы такой патч не хотим и поэтому сначала прогоняем JsonNode
через DTOшку, в которой жестко зафиксирован набор полей доступных для обновления. И только затем обновляем сущность, отталкиваясь от значений в DTO.
Остается только проверить, что если id
в теле запроса не null
, то он должен совпадать со значением из pathVariable
.
Эндпоинт для удаления Owner«а
Наконец, последний метод, который нам понадобится — DELETE.
Ну здесь совсем ничего менять не пришлось. Amplicode сгенерировал именно то, что нам полностью и подходит.
HTTP Client
Давайте взглянем на все 6 эндпоинтов, которые у нас получились в Amplicode Explorer«е:
Остается только их проверить.
Давайте запустим все необходимые сервисы для нашего приложения,
А также стартанём само приложение.
Начиная с версии Amplicode 2024.3, у нас есть возможность выполнять HTTP запросы прямо из IDE!
Перейдем к эндпоинту получения записи по id
и сгенерируем HTTP-запрос.
Идет вставка изображения…
Подробнее про HTTP клиент я расскажу в одной из следующих статей. Сейчас только отмечу, что это решение на Kotlin скриптах, которое под капотом использует REST-Assured.
Благодаря такой комбинации, мы можем довольно элегантно описывать HTTP запросы на Kotlin и общаться с нашим приложением.
Как видите, запись с ID
1 успешно нам вернулась.
Аналогично мы могли бы проверить и все остальные эндпоинты.
Идет вставка изображения…
Но в действительности, более правильной проверкой в данном случае будет написание тестов. Amplicode может помочь нам и здесь!
Генерируем тесты вместе с Amplicode
Давайте сгенерируем SpringWeb
тесты для всех эндпоинтов нашего контроллера.
Жмём Ок и тесты готовы!
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
'ы и подготовить тестовые данные. На самом деле, в плане тестирования стоит ещё затронуть способы настройки окружения под тесты и некоторые другие нюансы. Но так как это не совсем тема этой статьи, заполним наши тесты тестовыми данными за кулисами. Полный код уже готовых тестов представлен по ссылке.
Чтож, все тесты зелёные!
Значит, мы корректно реализовали наш CRUD REST API!
Заключение
Сегодня мы узнали, как с помощью Amplicode можно легко и просто реализовать REST API в Spring-приложениях!
Подписывайтесь на наши Telegram и YouTube, чтобы не пропустить новые материалы про Amplicode, Spring и связанные с ним технологии!
А если вы хотите попробовать Amplicode в действии — то можете установить его абсолютно бесплатно уже сейчас, как в IntelliJ IDEA/GigaIDE, так и в VS Code.