Руководство по HTTP-серверу¶
Это руководство знакомит с основным процессом создания HTTP API в Kora. В нем разбирается, как @HttpController и @HttpRoute превращают методы Java в HTTP-конечные точки, как @Json, @Path
и @Query
связывают запросы с типизированным кодом приложения, и как явные API ответов и исключений дают каждому маршруту понятное HTTP-поведение. Вы также увидите, как граф зависимостей Kora, собираемый во
время компиляции, соединяет
контроллеры, прикладные сервисы, репозитории, преобразователи JSON, конфигурацию и сервер Undertow в одно запускаемое приложение.
Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Java HTTP Server App.
Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Kotlin HTTP Server App.
Что вы создадите¶
К концу руководства у вас будут:
UserControllerс CRUD-маршрутами- DTO запросов и ответов
- хранящий данные в памяти
UserRepository UserService, который содержит прикладную логику- публичный API на порту
8080 - приватный API управления на порту
8085
Что вам понадобится¶
- JDK 17 или новее
- Gradle 7+
- текстовый редактор или среда разработки
- пройденное руководство JSON Processing with Kora
Требования¶
Обязательная основа
Это руководство предполагает, что вы прошли JSON Processing with Kora и у вас есть рабочий проект Kora с доступным сопоставлением JSON DTO.
Если вы еще не прошли руководство по JSON, сначала сделайте это, потому что оно уже опирается на начальное руководство и дает этому HTTP API нужные шаблоны сериализации JSON.
Обзор¶
HTTP-серверы Kora строятся вокруг простой идеи: обычные методы могут стать HTTP-конечными точками, когда их транспортный контракт объявлен явно. Вы пишете классы контроллеров, аннотируете маршруты и параметры, а Kora во время компиляции генерирует код обработки запросов.
Это означает, что HTTP API в Kora строится не на низкоуровневом разборе запросов. Он строится на типизированных сигнатурах методов и аннотациях, которые описывают, как HTTP-данные сопоставляются с кодом приложения.
Контроллеры как транспортные адаптеры¶
Контроллер — это HTTP-граница приложения. Он должен понимать маршруты, тела запросов, переменные пути, параметры строки запроса, коды состояния и заголовки. Он не должен навсегда становиться местом, где живет каждое правило хранения или бизнес-правило. Именно поэтому это руководство постепенно разделяет ответственности контроллера, сервиса и репозитория.
Аннотации Kora описывают, как HTTP-данные входят в методы контроллера и выходят из них:
@HttpControllerпомечает класс как HTTP-контроллер@HttpRouteобъявляет HTTP-метод и путь@Jsonсопоставляет JSON-тела запросов и ответов@Pathсопоставляет заполнители маршрута с параметрами метода@Queryсопоставляет значения строки запроса с параметрами метода
Явное HTTP-поведение¶
Простые методы могут возвращать DTO напрямую, но настоящим API часто нужно больше контроля. HttpResponseEntity<T> позволяет маршруту вернуть тело с конкретным кодом состояния или
заголовками. HttpServerResponse полезен для ответов без JSON-тела, например 204 No Content. HttpServerResponseException дает прямой способ завершить запрос понятной HTTP-ошибкой.
Эти типы сохраняют HTTP-поведение видимым в контроллере, а не прячут коды состояния внутри не относящегося к этому сервисного кода.
Слои приложения¶
Руководство начинается с одного метода контроллера, затем вводит хранение данных и прикладную логику как отдельные ответственности. Репозиторий отвечает за доступ к данным. Сервис отвечает за прикладное поведение. Контроллер отвечает за HTTP-представление. Это разделение намеренно небольшое, но именно такую форму позже переиспользуют руководства по базам данных, проверке данных, кешированию, устойчивости и наблюдаемости.
Практический путь такой:
- добавить модули HTTP-сервера и JSON
- создать DTO запросов и ответов
- открыть первый JSON-маршрут
- добавить сопоставление параметров пути и строки запроса
- ввести слои репозитория и сервиса
- возвращать явные статусы, заголовки и HTTP-ошибки
Зависимости¶
Обновите build.gradle:
Модули¶
Обновите src/main/java/ru/tinkoff/kora/guide/httpserver/Application.java:
package ru.tinkoff.kora.guide.httpserver;
import ru.tinkoff.kora.application.graph.KoraApplication;
import ru.tinkoff.kora.common.KoraApp;
import ru.tinkoff.kora.config.hocon.HoconConfigModule;
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule;
import ru.tinkoff.kora.json.module.JsonModule;
import ru.tinkoff.kora.logging.logback.LogbackModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowHttpServerModule { // <----- Подключили модуль
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
Обновите src/main/kotlin/ru/tinkoff/kora/guide/httpserver/Application.kt:
package ru.tinkoff.kora.guide.httpserver
import ru.tinkoff.kora.application.graph.KoraApplication
import ru.tinkoff.kora.common.KoraApp
import ru.tinkoff.kora.config.hocon.HoconConfigModule
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule
import ru.tinkoff.kora.json.module.JsonModule
import ru.tinkoff.kora.logging.logback.LogbackModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowHttpServerModule // <----- Подключили модуль
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
DTO¶
Прежде чем добавлять маршруты, нам нужны формы данных, которые мы хотим принимать и возвращать.
Создайте src/main/java/ru/tinkoff/kora/guide/httpserver/dto/UserRequest.java:
package ru.tinkoff.kora.guide.httpserver.dto;
import ru.tinkoff.kora.json.common.annotation.Json;
@Json
public record UserRequest(String name, String email) {}
Создайте src/main/java/ru/tinkoff/kora/guide/httpserver/dto/UserResponse.java:
Создайте src/main/kotlin/ru/tinkoff/kora/guide/httpserver/dto/UserRequest.kt:
package ru.tinkoff.kora.guide.httpserver.dto
import ru.tinkoff.kora.json.common.annotation.Json
@Json
data class UserRequest(
val name: String,
val email: String
)
Создайте src/main/kotlin/ru/tinkoff/kora/guide/httpserver/dto/UserResponse.kt:
UserRequest представляет входящий JSON от клиента.
UserResponse представляет JSON, который ваш API отправляет обратно.
Начинать с DTO удобно, потому что сигнатура контроллера уже получает устойчивые именованные типы вместо безымянных словарей или сырых строк.
Создание пользователя¶
Теперь создадим первый контроллер и первый маршрут. На этом этапе мы пока не будем ничего сохранять. Цель этого шага — понять, как Kora сопоставляет HTTP-запрос с методом контроллера.
Создайте src/main/java/ru/tinkoff/kora/guide/httpserver/controller/UserController.java:
package ru.tinkoff.kora.guide.httpserver.controller;
import java.time.LocalDateTime;
import java.util.UUID;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.server.common.annotation.HttpController;
import ru.tinkoff.kora.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public UserResponse createUser(@Json UserRequest request) {
System.out.printf("Received createUser request: name=%s, email=%s%n", request.name(), request.email());
return new UserResponse(
UUID.randomUUID().toString(),
request.name(),
request.email(),
LocalDateTime.now());
}
}
Создайте src/main/kotlin/ru/tinkoff/kora/guide/httpserver/controller/UserController.kt:
package ru.tinkoff.kora.guide.httpserver.controller
import java.time.LocalDateTime
import java.util.UUID
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.server.common.annotation.HttpController
import ru.tinkoff.kora.json.common.annotation.Json
@Component
@HttpController
class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): UserResponse {
println("Received createUser request: name=${request.name}, email=${request.email}")
return UserResponse(
UUID.randomUUID().toString(),
request.name,
request.email,
LocalDateTime.now()
)
}
}
Разберем, что здесь происходит:
-
@ComponentKora должна создать этот класс и поместить его в граф зависимостей. -
@HttpControllerЭтот класс содержит HTTP-маршруты. Kora сканирует его и генерирует связку HTTP-обработчиков. -
@HttpRoute(method = HttpMethod.POST, path = "/users")Этот метод должен обрабатыватьPOST /users. -
@Jsonна методе Kora должна использовать преобразователь данных со специальной меткой@Json, чтобы сериализовать возвращаемое значение в JSON. -
@Jsonна параметре Kora должна использовать преобразователь данных со специальной меткой@Json, чтобы десериализовать тело запроса из JSON вUserRequest.
На этом этапе маршрут уже ощущается как настоящий API, но он еще ничего не запоминает. Каждый вызов создает новый объект ответа и сразу возвращает его.
Попробуйте:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}'
Получение пользователя¶
Следующий естественный маршрут — getUser. Но как только мы добавляем его, мы сталкиваемся с важным вопросом проектирования: где живут пользователи после того, как createUser вернул ответ?
Пока мы добавим маршрут и намеренно вернем 404, чтобы показать, что контроллер уже умеет выражать сбой на уровне HTTP.
Обновите UserController.java:
package ru.tinkoff.kora.guide.httpserver.controller;
import java.time.LocalDateTime;
import java.util.UUID;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.common.annotation.Path;
import ru.tinkoff.kora.http.server.common.HttpServerResponseException;
import ru.tinkoff.kora.http.server.common.annotation.HttpController;
import ru.tinkoff.kora.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public UserResponse createUser(@Json UserRequest request) {
System.out.printf("Received createUser request: name=%s, email=%s%n", request.name(), request.email());
return new UserResponse(
UUID.randomUUID().toString(),
request.name(),
request.email(),
LocalDateTime.now());
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
public UserResponse getUser(@Path String userId) {
throw HttpServerResponseException.of(404, "User not found");
}
}
Обновите UserController.kt:
package ru.tinkoff.kora.guide.httpserver.controller
import java.time.LocalDateTime
import java.util.UUID
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.common.annotation.Path
import ru.tinkoff.kora.http.server.common.HttpServerResponseException
import ru.tinkoff.kora.http.server.common.annotation.HttpController
import ru.tinkoff.kora.json.common.annotation.Json
@Component
@HttpController
class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): UserResponse {
println("Received createUser request: name=${request.name}, email=${request.email}")
return UserResponse(
UUID.randomUUID().toString(),
request.name,
request.email,
LocalDateTime.now()
)
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
fun getUser(@Path userId: String): UserResponse {
throw HttpServerResponseException.of(404, "User not found")
}
}
Здесь появляются две новые идеи:
-
@Path String userIdKora берет часть{userId}из пути маршрута и передает ее в метод. -
HttpServerResponseExceptionЭто простой способ сказать: «этот запрос должен завершиться такой HTTP-ошибкой».
Этот шаг намеренно неполный. Теперь у нас достаточно поведения контроллера, чтобы увидеть, зачем нужна отдельная абстракция хранения.
Репозиторий пользователей¶
Теперь добавим слой репозитория. Репозиторий отвечает за сохранение и получение данных. В этом руководстве мы используем хранящую данные в памяти карту, потому что так пример проще запускать, но сама абстракция позже позволит нам перейти на настоящую базу данных.
Сначала нам нужны только две операции:
- сохранить пользователя
- получить пользователя по идентификатору
Создайте src/main/java/ru/tinkoff/kora/guide/httpserver/repository/UserRepository.java:
package ru.tinkoff.kora.guide.httpserver.repository;
import java.util.Optional;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
public interface UserRepository {
String save(String name, String email);
Optional<UserResponse> findById(String id);
}
Создайте src/main/java/ru/tinkoff/kora/guide/httpserver/repository/InMemoryUserRepository.java:
package ru.tinkoff.kora.guide.httpserver.repository;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
@Component
public final class InMemoryUserRepository implements UserRepository {
private final Map<String, UserResponse> users = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
@Override
public String save(String name, String email) {
String id = String.valueOf(idGenerator.getAndIncrement());
users.put(id, new UserResponse(id, name, email, LocalDateTime.now()));
return id;
}
@Override
public Optional<UserResponse> findById(String id) {
return Optional.ofNullable(users.get(id));
}
}
Создайте src/main/kotlin/ru/tinkoff/kora/guide/httpserver/repository/UserRepository.kt:
package ru.tinkoff.kora.guide.httpserver.repository
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
interface UserRepository {
fun save(name: String, email: String): String
fun findById(id: String): UserResponse?
}
Создайте src/main/kotlin/ru/tinkoff/kora/guide/httpserver/repository/InMemoryUserRepository.kt:
package ru.tinkoff.kora.guide.httpserver.repository
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
@Component
class InMemoryUserRepository : UserRepository {
private val users = ConcurrentHashMap<String, UserResponse>()
private val idGenerator = AtomicLong(1)
override fun save(name: String, email: String): String {
val id = idGenerator.getAndIncrement().toString()
users[id] = UserResponse(id, name, email, LocalDateTime.now())
return id
}
override fun findById(id: String): UserResponse? = users[id]
}
Репозиторий ничего не знает об HTTP. Он знает только, как сохранять и загружать данные пользователей. Такое разделение важно, потому что задачи хранения и задачи HTTP меняются по разным причинам.
Контроллер к репозиторию¶
Теперь, когда у нас есть хранилище, можно вернуться к контроллеру и заставить createUser и getUser действительно работать вместе.
Обновите UserController.java:
package ru.tinkoff.kora.guide.httpserver.controller;
import java.util.Optional;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
import ru.tinkoff.kora.guide.httpserver.repository.UserRepository;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.common.annotation.Path;
import ru.tinkoff.kora.http.server.common.HttpServerResponseException;
import ru.tinkoff.kora.http.server.common.annotation.HttpController;
import ru.tinkoff.kora.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public UserResponse createUser(@Json UserRequest request) {
String id = userRepository.save(request.name(), request.email());
return userRepository.findById(id)
.orElseThrow(() -> new IllegalStateException("Saved user not found"));
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
public UserResponse getUser(@Path String userId) {
return userRepository.findById(userId)
.orElseThrow(() -> HttpServerResponseException.of(404, "User not found"));
}
}
Обновите UserController.kt:
package ru.tinkoff.kora.guide.httpserver.controller
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
import ru.tinkoff.kora.guide.httpserver.repository.UserRepository
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.common.annotation.Path
import ru.tinkoff.kora.http.server.common.HttpServerResponseException
import ru.tinkoff.kora.http.server.common.annotation.HttpController
import ru.tinkoff.kora.json.common.annotation.Json
@Component
@HttpController
class UserController(
private val userRepository: UserRepository
) {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): UserResponse {
val id = userRepository.save(request.name, request.email)
return userRepository.findById(id)
?: error("Saved user not found")
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
fun getUser(@Path userId: String): UserResponse {
return userRepository.findById(userId)
?: throw HttpServerResponseException.of(404, "User not found")
}
}
Это первый момент, когда API становится сохраняющим состояние. Теперь можно вызвать createUser, получить идентификатор и затем использовать этот идентификатор в getUser.
CRUD репозиторий¶
API уже работает для создания и получения. Прежде чем добавлять новые HTTP-маршруты, сначала сделаем абстракцию хранения способной выполнить полный CRUD-поток:
- получить список пользователей
- обновлять пользователей
- удалять пользователей
Так репозиторий остается сосредоточенным только на операциях хранения. Контроллер начнет использовать эти операции в следующем разделе, после того как мы введем сервисный слой между HTTP-маршрутизацией и хранением.
Расширьте UserRepository.java:
package ru.tinkoff.kora.guide.httpserver.repository;
import java.util.List;
import java.util.Optional;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
public interface UserRepository {
List<UserResponse> findAll();
Optional<UserResponse> findById(String id);
String save(String name, String email);
boolean update(String id, String name, String email);
boolean deleteById(String id);
}
Расширьте InMemoryUserRepository.java:
package ru.tinkoff.kora.guide.httpserver.repository;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
@Component
public final class InMemoryUserRepository implements UserRepository {
private final Map<String, UserResponse> users = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
@Override
public List<UserResponse> findAll() {
return new ArrayList<>(users.values());
}
@Override
public Optional<UserResponse> findById(String id) {
return Optional.ofNullable(users.get(id));
}
@Override
public String save(String name, String email) {
String id = String.valueOf(idGenerator.getAndIncrement());
users.put(id, new UserResponse(id, name, email, LocalDateTime.now()));
return id;
}
@Override
public boolean update(String id, String name, String email) {
return users.computeIfPresent(id,
(k, v) -> new UserResponse(k, name, email, v.createdAt())) != null;
}
@Override
public boolean deleteById(String id) {
return users.remove(id) != null;
}
}
Расширьте UserRepository.kt:
package ru.tinkoff.kora.guide.httpserver.repository
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
interface UserRepository {
fun findAll(): List<UserResponse>
fun findById(id: String): UserResponse?
fun save(name: String, email: String): String
fun update(id: String, name: String, email: String): Boolean
fun deleteById(id: String): Boolean
}
Расширьте InMemoryUserRepository.kt:
package ru.tinkoff.kora.guide.httpserver.repository
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
@Component
class InMemoryUserRepository : UserRepository {
private val users = ConcurrentHashMap<String, UserResponse>()
private val idGenerator = AtomicLong(1)
override fun findAll(): List<UserResponse> = users.values.toList()
override fun findById(id: String): UserResponse? = users[id]
override fun save(name: String, email: String): String {
val id = idGenerator.getAndIncrement().toString()
users[id] = UserResponse(id, name, email, LocalDateTime.now())
return id
}
override fun update(id: String, name: String, email: String): Boolean {
val current = users[id] ?: return false
users[id] = UserResponse(id, name, email, current.createdAt)
return true
}
override fun deleteById(id: String): Boolean = users.remove(id) != null
}
На этом этапе репозиторий умеет хранить, выводить список, обновлять и удалять пользователей, но HTTP API все еще открывает только маршруты из предыдущего раздела. Дальше мы добавим сервисный слой и затем подключим полное CRUD-поведение к контроллеру.
Сервисный слой¶
Во многих приложениях контроллер считается слоем представления, а сервисный слой содержит прикладную логику. Это особенно часто встречается в приложениях в стиле MVC и в сервисах, которые позже обрастают правилами, интеграциями и точками переиспользования.
Теперь в репозитории есть все операции хранения, которые нужны API. Сервисный слой превращает эти операции в прикладное поведение:
- создает пользователей из DTO запросов
- сортирует и разбивает список в памяти на страницы
- сопоставляет результаты обновления и удаления из репозитория с бизнес-ошибками
После этого контроллер может оставаться сосредоточенным на HTTP-маршрутизации, привязке запросов, кодах ответов и заголовках.
Создайте src/main/java/ru/tinkoff/kora/guide/httpserver/service/UserService.java:
package ru.tinkoff.kora.guide.httpserver.service;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
import ru.tinkoff.kora.guide.httpserver.repository.UserRepository;
import ru.tinkoff.kora.http.server.common.HttpServerResponseException;
@Component
public final class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public UserResponse createUser(UserRequest request) {
var generatedId = userRepository.save(request.name(), request.email());
return new UserResponse(generatedId, request.name(), request.email(), LocalDateTime.now());
}
public Optional<UserResponse> getUser(String id) {
return userRepository.findById(id);
}
public List<UserResponse> getUsers(int page, int size, String sort) {
return userRepository.findAll().stream()
.sorted(getComparator(sort))
.skip((long) page * size)
.limit(size)
.toList();
}
public UserResponse updateUser(String id, UserRequest request) {
boolean updated = userRepository.update(id, request.name(), request.email());
if (!updated) {
throw HttpServerResponseException.of(404, "User not found");
}
return new UserResponse(id, request.name(), request.email(), LocalDateTime.now());
}
public void deleteUser(String id) {
boolean deleted = userRepository.deleteById(id);
if (!deleted) {
throw HttpServerResponseException.of(404, "User not found");
}
}
private Comparator<UserResponse> getComparator(String sort) {
return switch (sort.toLowerCase()) {
case "name" -> Comparator.comparing(UserResponse::name);
case "email" -> Comparator.comparing(UserResponse::email);
case "createdat" -> Comparator.comparing(UserResponse::createdAt);
default -> Comparator.comparing(UserResponse::name);
};
}
}
Создайте src/main/kotlin/ru/tinkoff/kora/guide/httpserver/service/UserService.kt:
package ru.tinkoff.kora.guide.httpserver.service
import java.time.LocalDateTime
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
import ru.tinkoff.kora.guide.httpserver.repository.UserRepository
import ru.tinkoff.kora.http.server.common.HttpServerResponseException
@Component
class UserService(
private val userRepository: UserRepository
) {
fun createUser(request: UserRequest): UserResponse {
val generatedId = userRepository.save(request.name, request.email)
return UserResponse(generatedId, request.name, request.email, LocalDateTime.now())
}
fun getUser(id: String): UserResponse? = userRepository.findById(id)
fun getUsers(page: Int, size: Int, sort: String): List<UserResponse> {
return userRepository.findAll()
.sortedWith(getComparator(sort))
.drop(page * size)
.take(size)
}
fun updateUser(id: String, request: UserRequest): UserResponse {
val updated = userRepository.update(id, request.name, request.email)
if (!updated) {
throw HttpServerResponseException.of(404, "User not found")
}
return UserResponse(id, request.name, request.email, LocalDateTime.now())
}
fun deleteUser(id: String) {
val deleted = userRepository.deleteById(id)
if (!deleted) {
throw HttpServerResponseException.of(404, "User not found")
}
}
private fun getComparator(sort: String): Comparator<UserResponse> {
return when (sort.lowercase()) {
"name" -> compareBy(UserResponse::name)
"email" -> compareBy(UserResponse::email)
"createdat" -> compareBy(UserResponse::createdAt)
else -> compareBy(UserResponse::name)
}
}
}
Контроллер и сервис¶
Теперь контроллер может открыть полный CRUD API, не владея хранением данных или прикладной логикой. Он принимает HTTP-запросы, привязывает параметры маршрута и строки запроса, делегирует
работу UserService и выбирает форму HTTP-ответа для каждого маршрута.
Этот шаг также добавляет оставшиеся HTTP-специфичные части:
@Queryсопоставляет значения строки запроса, например?page=0&size=10&sort=name, с параметрами контроллера@Nullableпомечает необязательные параметры строки запросаHttpResponseEntity<T>возвращает JSON-тело вместе с явным кодом состояния или заголовкамиHttpServerResponseвозвращает ответы без JSON-тела, например204 No Content
Перепишите UserController.java, чтобы он делегировал работу сервису:
package ru.tinkoff.kora.guide.httpserver.controller;
import jakarta.annotation.Nullable;
import java.time.Instant;
import java.util.List;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest;
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse;
import ru.tinkoff.kora.guide.httpserver.service.UserService;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.HttpResponseEntity;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.common.annotation.Path;
import ru.tinkoff.kora.http.common.annotation.Query;
import ru.tinkoff.kora.http.common.body.HttpBody;
import ru.tinkoff.kora.http.common.header.HttpHeaders;
import ru.tinkoff.kora.http.server.common.HttpServerResponse;
import ru.tinkoff.kora.http.server.common.HttpServerResponseException;
import ru.tinkoff.kora.http.server.common.annotation.HttpController;
import ru.tinkoff.kora.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
public UserResponse getUser(@Path String userId) {
return userService.getUser(userId)
.orElseThrow(() -> HttpServerResponseException.of(404, "User not found"));
}
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
public List<UserResponse> getUsers(
@Nullable @Query("page") Integer page,
@Nullable @Query("size") Integer size,
@Nullable @Query("sort") String sort) {
int pageNum = page == null ? 0 : page;
int pageSize = size == null ? 10 : size;
String sortBy = sort == null ? "name" : sort;
return userService.getUsers(pageNum, pageSize, sortBy);
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public HttpResponseEntity<UserResponse> createUser(@Json UserRequest request) {
UserResponse user = userService.createUser(request);
return HttpResponseEntity.of(201, HttpHeaders.of(), user);
}
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
public HttpResponseEntity<UserResponse> updateUser(@Path String userId, @Json UserRequest request) {
UserResponse updated = userService.updateUser(userId, request);
return HttpResponseEntity.of(200, HttpHeaders.of("X-Updated-At", Instant.now().toString()), updated);
}
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
public HttpServerResponse deleteUser(@Path String userId) {
userService.deleteUser(userId);
return HttpServerResponse.of(204, HttpBody.empty());
}
}
Перепишите UserController.kt, чтобы он делегировал работу сервису:
package ru.tinkoff.kora.guide.httpserver.controller
import jakarta.annotation.Nullable
import java.time.Instant
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.httpserver.dto.UserRequest
import ru.tinkoff.kora.guide.httpserver.dto.UserResponse
import ru.tinkoff.kora.guide.httpserver.service.UserService
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.HttpResponseEntity
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.common.annotation.Path
import ru.tinkoff.kora.http.common.annotation.Query
import ru.tinkoff.kora.http.common.body.HttpBody
import ru.tinkoff.kora.http.common.header.HttpHeaders
import ru.tinkoff.kora.http.server.common.HttpServerResponse
import ru.tinkoff.kora.http.server.common.HttpServerResponseException
import ru.tinkoff.kora.http.server.common.annotation.HttpController
import ru.tinkoff.kora.json.common.annotation.Json
@Component
@HttpController
class UserController(
private val userService: UserService
) {
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
fun getUser(@Path userId: String): UserResponse {
return userService.getUser(userId)
?: throw HttpServerResponseException.of(404, "User not found")
}
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
fun getUsers(
@Nullable @Query("page") page: Int?,
@Nullable @Query("size") size: Int?,
@Nullable @Query("sort") sort: String?
): List<UserResponse> {
val pageNum = page ?: 0
val pageSize = size ?: 10
val sortBy = sort ?: "name"
return userService.getUsers(pageNum, pageSize, sortBy)
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): HttpResponseEntity<UserResponse> {
val user = userService.createUser(request)
return HttpResponseEntity.of(201, HttpHeaders.of(), user)
}
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
fun updateUser(@Path userId: String, @Json request: UserRequest): HttpResponseEntity<UserResponse> {
val updated = userService.updateUser(userId, request)
return HttpResponseEntity.of(200, HttpHeaders.of("X-Updated-At", Instant.now().toString()), updated)
}
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
fun deleteUser(@Path userId: String): HttpServerResponse {
userService.deleteUser(userId)
return HttpServerResponse.of(204, HttpBody.empty())
}
}
Это финальная структура, которую использует запускаемое сопровождающее приложение. Поведение не изменилось, но архитектура стала чище:
- контроллер = HTTP-представление
- репозиторий = абстракция хранения
- сервис = прикладная логика
Конфигурация¶
Теперь, когда структура приложения готова, можно подключить саму конфигурацию HTTP-сервера.
Создайте или обновите src/main/resources/application.conf:
Полный справочник по конфигурации смотрите в HTTP-сервер и журналирование SLF4J.
httpServer {
publicApiHttpPort = 8080 //(1)!
privateApiHttpPort = 8085 //(2)!
telemetry.logging.enabled = true //(3)!
}
logging {
levels {
"ROOT": "WARN" //(4)!
"ru.tinkoff.kora": "INFO" //(5)!
}
}
- Публичный HTTP-порт по умолчанию, используемый конечными точками приложения.
- Приватный HTTP-порт по умолчанию, используемый пробами, метриками и конечными точками управления.
- Включает возможность для этого раздела конфигурации.
- Уровень логирования для
ROOT. - Уровень логирования для
ru.tinkoff.kora.
httpServer:
publicApiHttpPort: 8080 #(1)!
privateApiHttpPort: 8085 #(2)!
telemetry:
logging:
enabled: true #(3)!
logging:
levels:
ROOT: "WARN" #(4)!
"ru.tinkoff.kora": "INFO" #(5)!
- Публичный HTTP-порт по умолчанию, используемый конечными точками приложения.
- Приватный HTTP-порт по умолчанию, используемый пробами, метриками и конечными точками управления.
- Включает возможность для этого раздела конфигурации.
- Уровень логирования для
ROOT. - Уровень логирования для
ru.tinkoff.kora.
Это дает два порта:
8080для основного API приложения8085для конечных точек управления, таких как готовность и живучесть
Такое разделение полезно в настоящих системах, потому что проверки состояния и эксплуатационные конечные точки обычно держат отдельно от публичного бизнес-трафика.
Проверка приложения¶
Проверки публичного API:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}'
curl http://localhost:8080/users/1
curl "http://localhost:8080/users?page=0&size=10&sort=name"
curl -X PUT http://localhost:8080/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "Updated Name", "email": "updated@example.com"}'
curl -X DELETE http://localhost:8080/users/1
Проверки приватного API:
Лучшие практики¶
- Держите методы контроллера тонкими, когда проект перерастает простые обработчики.
- Используйте репозитории для задач хранения, а сервисы — для прикладной логики.
- Используйте
HttpResponseEntity, когда нужны явные коды состояния или заголовки. - Выбрасывайте
HttpServerResponseException, когда контроллеру или сервису нужно показать чистую HTTP-ошибку.
Итоги¶
Вы постепенно построили HTTP API на Kora:
- сначала один маршрут без постоянного хранения
- затем второй маршрут, который показал необходимость хранилища
- затем абстракцию репозитория с реализацией в памяти
- затем контракт репозитория, расширенный до полного CRUD
- и наконец сервисный слой плюс маршруты контроллера, которые открывают полный API
Ключевые понятия¶
- HTTP-маршрутизация Kora с
@HttpRoute - сопоставление JSON-запросов и JSON-ответов через
@Json - сопоставление запросов через
@Pathи@Query - управление ответами через
HttpResponseEntity - сигнализация HTTP-ошибок через
HttpServerResponseException - разные ответственности контроллера, репозитория и сервиса
Устранение неполадок¶
Сервер не запускается:
- Проверьте доступность портов
8080и8085. - Убедитесь, что
ApplicationвключаетUndertowHttpServerModuleиHoconConfigModule.
getUser всегда возвращает 404:
- Проверьте, что
createUserиgetUserуже подключены к слою репозитория. - Убедитесь, что вызываете
getUserс идентификатором, который действительно вернулcreateUser.
Необязательные параметры строки запроса обрабатываются неправильно:
- В Java используйте nullable-обертки с
@Nullable @Query, напримерIntegerиString. - Избегайте
Optional<T>в параметрах строки запроса контроллера.
Сборка зависает или неожиданно падает:
- Выполните
./gradlew --stop, затем повторите.
Что дальше?¶
- JSON Processing, чтобы сделать сопоставление DTO HTTP-запросов и ответов явным.
- Валидация, чтобы добавить проверки на границе того же HTTP API.
- База данных JDBC или База данных Cassandra, чтобы заменить репозиторий в памяти настоящим постоянным хранением.
- Продвинутый HTTP-сервер, когда базовая CRUD-форма станет понятной.
- HTTP-клиент, когда вы захотите, чтобы другое приложение Kora вызывало этот API.
Помощь¶
Если вы столкнулись с проблемами:
- сравните с Kora Java HTTP Server App и Kora Kotlin HTTP Server App
- проверьте документацию HTTP-сервера
- проверьте документацию JSON