Руководство по HTTP-серверу¶
Это руководство знакомит с основным процессом создания HTTP API в Kora. В нем разбирается, как @HttpController и @HttpRoute превращают обычные методы в 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 25 или новее
- Gradle 9+
- Текстовый редактор или IDE
- Пройденное руководство Работа с JSON в Kora
Артефакты Kora 2.0 собраны под Java 25, поэтому JDK, которым компилируется приложение, должен быть версии 25 или новее.
Требования¶
Необходимая база
Руководство предполагает, что вы прошли Работа с JSON в Kora и у вас есть рабочий проект Kora с настроенным маппингом JSON-DTO.
Если руководство по JSON еще не пройдено, начните с него: оно уже опирается на вводное руководство и дает этому HTTP API нужные приемы сериализации JSON.
Обзор¶
HTTP-серверы Kora (HTTP) построены на простой идее: обычные методы становятся HTTP-конечными точками, если их транспортный контракт описан явно. Вы пишете классы-контроллеры, размечаете маршруты и параметры аннотациями, а Kora генерирует код обработки запроса во время компиляции.
Это значит, что HTTP API в Kora строится не из низкоуровневого разбора запроса, а из типизированных сигнатур методов и аннотаций, которые описывают, как HTTP-данные отображаются на код приложения.
Контроллеры как транспортные адаптеры¶
Контроллер — это HTTP-граница приложения. Он должен разбираться в маршрутах, телах запросов, переменных пути, параметрах строки запроса, кодах статуса и заголовках. Он не должен навсегда становиться местом, где живут все правила хранения и бизнес-логики. Поэтому руководство постепенно разделяет ответственность контроллера, сервиса и репозитория.
Аннотации Kora описывают, как HTTP-данные попадают в методы контроллера и выходят из них:
@HttpControllerпомечает класс как HTTP-контроллер@HttpRouteобъявляет HTTP-метод и путь@Jsonсвязывает JSON-тела запроса и ответа@Pathотображает плейсхолдеры маршрута в параметры метода@Queryотображает значения строки запроса в параметры метода
Обработка запросов синхронная. Undertow отправляет каждый запрос на виртуальный поток до того, как сгенерированный обработчик вызовет ваш метод, поэтому метод контроллера может свободно
блокироваться: он возвращает результат напрямую и никогда не возвращает CompletionStage, Mono/Flux или suspend-функцию.
Явное HTTP-поведение¶
Простые методы могут возвращать DTO напрямую, но реальным API часто нужен больший контроль. HttpResponseEntity<T> позволяет вернуть тело вместе с конкретным кодом статуса или заголовками.
HttpServerResponse удобен для ответов без JSON-тела, например 204 No Content. HttpServerResponseException дает прямой способ завершить запрос понятной HTTP-ошибкой.
Эти типы оставляют HTTP-поведение видимым в контроллере, а не прячут коды статуса внутри посторонней логики сервисов.
Слои приложения¶
Руководство начинается с одного метода контроллера, а затем выделяет хранение и прикладную логику в отдельные обязанности. Репозиторий владеет доступом к данным. Сервис владеет прикладным поведением. Контроллер владеет HTTP-представлением. Разделение намеренно небольшое, но это та же форма, которую переиспользуют последующие руководства по базам данных, валидации, кэшированию, отказоустойчивости и наблюдаемости.
Практический порядок такой:
- подключить модули HTTP-сервера и JSON
- создать DTO запроса и ответа
- открыть первый JSON-маршрут
- добавить маппинг параметров пути и строки запроса
- ввести слои репозитория и сервиса
- возвращать явные статусы, заголовки и HTTP-ошибки
Зависимости¶
HTTP-сервер живет в модуле http-server-undertow, а поддержка JSON — в json-common. Оба модуля относятся к Kora, поэтому их версии берутся из платформы io.koraframework:kora-bom, а не пишутся
в каждой строке.
Обновите build.gradle:
dependencies {
koraBom platform("io.koraframework:kora-bom:2.0.0.RC1") //(1)!
annotationProcessor "io.koraframework:annotation-processors" //(2)!
implementation "io.koraframework:config-hocon" //(3)!
implementation "io.koraframework:http-server-undertow" //(4)!
implementation "io.koraframework:json-common" //(5)!
implementation "io.koraframework:logging-logback" //(6)!
}
- Kora BOM: согласует версии всех модулей Kora и библиотек, от которых зависит Kora.
- Аннотационный процессор Kora: генерирует граф приложения, модули контроллеров и JSON-читатели/писатели во время компиляции.
- Чтение конфигурации HOCON из
application.conf. - Транспорт HTTP-сервера Undertow.
- Инфраструктура JSON времени компиляции.
- Реализация логирования Logback, встроенная в граф Kora.
Обновите build.gradle.kts:
dependencies {
implementation(platform("io.koraframework:kora-bom:2.0.0.RC1")) //(1)!
ksp("io.koraframework:symbol-processors:2.0.0.RC1") //(2)!
implementation("io.koraframework:config-hocon") //(3)!
implementation("io.koraframework:http-server-undertow") //(4)!
implementation("io.koraframework:json-common") //(5)!
implementation("io.koraframework:logging-logback") //(6)!
}
- Kora BOM: согласует версии всех модулей Kora и библиотек, от которых зависит Kora.
- KSP-процессор Kora: генерирует граф приложения, модули контроллеров и JSON-читатели/писатели во время компиляции.
- Чтение конфигурации HOCON из
application.conf. - Транспорт HTTP-сервера Undertow.
- Инфраструктура JSON времени компиляции.
- Реализация логирования Logback, встроенная в граф Kora.
Модули¶
UndertowPublicHttpServerModule — модуль, который нужно подключить приложению с бизнес-эндпоинтами. Он наследует UndertowSystemHttpServerModule, поэтому одно наследование дает сразу два сервера
в одном процессе: публичный на httpServer.port и системный на httpServer.system.port, который отвечает на запросы readiness, liveness и метрик.
Обновите src/main/java/io/koraframework/guide/httpserver/Application.java:
package io.koraframework.guide.httpserver;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.json.common.JsonModule;
import io.koraframework.logging.logback.LogbackModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowPublicHttpServerModule { // <----- Connected module
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
Обновите src/main/kotlin/io/koraframework/guide/httpserver/Application.kt:
package io.koraframework.guide.httpserver
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.json.common.JsonModule
import io.koraframework.logging.logback.LogbackModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowPublicHttpServerModule // <----- Connected module
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
DTO¶
Прежде чем добавлять маршрут, нужно описать формы данных, которые мы хотим принимать и возвращать.
Создайте src/main/java/io/koraframework/guide/httpserver/dto/UserRequest.java:
package io.koraframework.guide.httpserver.dto;
import io.koraframework.json.common.annotation.Json;
@Json
public record UserRequest(String name, String email) {}
Создайте src/main/java/io/koraframework/guide/httpserver/dto/UserResponse.java:
Создайте src/main/kotlin/io/koraframework/guide/httpserver/dto/UserRequest.kt:
package io.koraframework.guide.httpserver.dto
import io.koraframework.json.common.annotation.Json
@Json
data class UserRequest(
val name: String,
val email: String
)
Создайте src/main/kotlin/io/koraframework/guide/httpserver/dto/UserResponse.kt:
UserRequest описывает входящий JSON от клиента.
UserResponse описывает JSON, который ваш API отдает обратно.
Начинать с DTO удобно: сигнатура контроллера сразу получает стабильные именованные типы вместо безымянных словарей и «сырых» строк.
Создание пользователя¶
Теперь создадим первый контроллер и первый маршрут. На этом шаге мы ничего не сохраняем. Цель — понять, как Kora отображает HTTP-запрос на метод контроллера.
Создайте src/main/java/io/koraframework/guide/httpserver/controller/UserController.java:
package io.koraframework.guide.httpserver.controller;
import java.time.LocalDateTime;
import java.util.UUID;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.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/io/koraframework/guide/httpserver/controller/UserController.kt:
package io.koraframework.guide.httpserver.controller
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.json.common.annotation.Json
import java.time.LocalDateTime
import java.util.UUID
@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. ВHttpMethodлежат стандартные имена HTTP-методов в виде строковых констант. -
@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 io.koraframework.guide.httpserver.controller;
import java.time.LocalDateTime;
import java.util.UUID;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.annotation.Path;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.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: " + userId);
}
}
Обновите UserController.kt:
package io.koraframework.guide.httpserver.controller
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.annotation.Path
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.annotation.Json
import java.time.LocalDateTime
import java.util.UUID
@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: $userId")
}
}
Здесь появляются две новые идеи:
-
@Path String userIdKora берет часть{userId}из пути маршрута и передает ее в метод. -
HttpServerResponseExceptionПростой способ сказать «этот запрос должен завершиться такой HTTP-ошибкой». Само исключение являетсяHttpServerResponse, поэтому сервер пишет его статус и тело без дополнительного преобразования.
Шаг намеренно неполный. Теперь поведения контроллера достаточно, чтобы увидеть, зачем нужна отдельная абстракция хранения.
Репозиторий пользователей¶
Теперь добавим слой репозитория. Репозиторий отвечает за сохранение и получение данных. В этом руководстве используется словарь в памяти — так пример проще запускать, — но сама абстракция позже позволит перейти на настоящую базу данных.
Сначала нужны всего две операции:
- сохранить пользователя
- получить пользователя по ID
Создайте src/main/java/io/koraframework/guide/httpserver/repository/UserRepository.java:
package io.koraframework.guide.httpserver.repository;
import java.util.Optional;
import io.koraframework.guide.httpserver.dto.UserResponse;
public interface UserRepository {
String save(String name, String email);
Optional<UserResponse> findById(String id);
}
Создайте src/main/java/io/koraframework/guide/httpserver/repository/InMemoryUserRepository.java:
package io.koraframework.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 io.koraframework.common.annotation.Component;
import io.koraframework.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/io/koraframework/guide/httpserver/repository/UserRepository.kt:
package io.koraframework.guide.httpserver.repository
import io.koraframework.guide.httpserver.dto.UserResponse
interface UserRepository {
fun save(name: String, email: String): String
fun findById(id: String): UserResponse?
}
Создайте src/main/kotlin/io/koraframework/guide/httpserver/repository/InMemoryUserRepository.kt:
package io.koraframework.guide.httpserver.repository
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserResponse
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
@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 меняются по разным причинам.
Поскольку каждый запрос выполняется на своем виртуальном потоке, хранилище в памяти — это разделяемое состояние: ConcurrentHashMap и AtomicLong выбраны намеренно вместо небезопасных аналогов.
Контроллер к репозиторию¶
Теперь, когда есть хранилище, вернемся к контроллеру и заставим createUser и getUser работать вместе.
Обновите UserController.java:
package io.koraframework.guide.httpserver.controller;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.guide.httpserver.repository.UserRepository;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.annotation.Path;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.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: " + userId));
}
}
Обновите UserController.kt:
package io.koraframework.guide.httpserver.controller
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.guide.httpserver.repository.UserRepository
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.annotation.Path
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.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: $userId")
}
}
Это первый момент, когда API становится stateful. Теперь можно вызвать createUser, получить ID и использовать его в getUser.
UserRepository — интерфейс, а InMemoryUserRepository — реализующий его @Component. Контроллер запрашивает в конструкторе интерфейс, и Kora разрешает это ребро графа во время компиляции: если бы
реализации не было, сборка упала бы с No component found for dependency, а не в рантайме.
CRUD репозиторий¶
API уже умеет создавать и получать. Прежде чем добавлять новые HTTP-маршруты, сделаем абстракцию хранения способной на полный CRUD:
- список пользователей
- обновление пользователей
- удаление пользователей
Так репозиторий остается сосредоточен только на операциях хранения. Контроллер начнет использовать эти операции в следующем разделе, после того как между HTTP-маршрутизацией и хранением появится сервисный слой.
Расширьте UserRepository.java:
package io.koraframework.guide.httpserver.repository;
import java.util.List;
import java.util.Optional;
import io.koraframework.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 io.koraframework.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 io.koraframework.common.annotation.Component;
import io.koraframework.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 io.koraframework.guide.httpserver.repository
import io.koraframework.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 io.koraframework.guide.httpserver.repository
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserResponse
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
@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 {
return users.computeIfPresent(id) { key, current -> UserResponse(key, name, email, current.createdAt) } != null
}
override fun deleteById(id: String): Boolean = users.remove(id) != null
}
На этом этапе репозиторий умеет сохранять, перечислять, обновлять и удалять пользователей, но HTTP API по-прежнему открывает только маршруты из предыдущего раздела. Дальше добавим сервисный слой, а затем подключим к контроллеру полное CRUD-поведение.
Сервисный слой¶
Во многих приложениях контроллер считают слоем представления, а прикладная логика живет в сервисном слое. Это особенно характерно для приложений в стиле MVC и для сервисов, которые со временем обрастают правилами, интеграциями и точками переиспользования.
В репозитории уже есть все операции хранения, нужные API. Сервисный слой превращает их в прикладное поведение:
- создает пользователей из DTO запроса
- сортирует и разбивает на страницы список из памяти
- превращает результаты обновления и удаления в бизнес-ошибки
После этого контроллер может сосредоточиться на HTTP-маршрутизации, связывании запроса, кодах ответа и заголовках.
Создайте src/main/java/io/koraframework/guide/httpserver/service/UserService.java:
package io.koraframework.guide.httpserver.service;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.guide.httpserver.repository.UserRepository;
import io.koraframework.http.server.common.response.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: " + id);
}
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: " + id);
}
}
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/io/koraframework/guide/httpserver/service/UserService.kt:
package io.koraframework.guide.httpserver.service
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.guide.httpserver.repository.UserRepository
import io.koraframework.http.server.common.response.HttpServerResponseException
import java.time.LocalDateTime
@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> =
userRepository.findAll()
.sortedWith(getComparator(sort))
.drop(page * size)
.take(size)
fun updateUser(id: String, request: UserRequest): UserResponse {
if (!userRepository.update(id, request.name, request.email)) {
throw HttpServerResponseException.of(404, "User not found: $id")
}
return UserResponse(id, request.name, request.email, LocalDateTime.now())
}
fun deleteUser(id: String) {
if (!userRepository.deleteById(id)) {
throw HttpServerResponseException.of(404, "User not found: $id")
}
}
private fun getComparator(sort: String): Comparator<UserResponse> = when (sort.lowercase()) {
"name" -> compareBy { it.name }
"email" -> compareBy { it.email }
"createdat" -> compareBy { it.createdAt }
else -> compareBy { it.name }
}
}
UserService бросает HttpServerResponseException для «не найдено», чтобы руководство оставалось коротким. В более крупном приложении сервис обычно бросал бы доменное исключение, а один глобальный
перехватчик переводил бы его в HTTP-статус — именно это строит следующее руководство Продвинутый HTTP-сервер.
Контроллер и сервис¶
Теперь контроллер может открыть полный CRUD API, не владея ни хранением, ни прикладной логикой. Он принимает HTTP-запросы, связывает параметры маршрута и строки запроса, делегирует работу
UserService и выбирает форму HTTP-ответа для каждого маршрута.
На этом шаге добавляются оставшиеся HTTP-специфичные элементы:
@Queryотображает значения строки запроса вида?page=0&size=10&sort=nameв параметры контроллера- nullable-тип параметра помечает необязательный параметр строки запроса
HttpResponseEntity<T>возвращает JSON-тело вместе с явным кодом статуса или заголовкамиHttpServerResponseвозвращает ответы без JSON-тела, например204 No Content
В Java необязательные параметры помечаются JSpecify-аннотацией @Nullable из org.jspecify.annotations; в Kotlin достаточно ? в типе параметра, никакой аннотации не нужно. Параметр @Query,
который не является ни nullable, ни Optional, считается обязательным, и запрос без него получает 400 еще до вызова метода контроллера.
Перепишите UserController.java так, чтобы он делегировал работу сервису:
package io.koraframework.guide.httpserver.controller;
import org.jspecify.annotations.Nullable;
import java.time.Instant;
import java.util.List;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.guide.httpserver.service.UserService;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.HttpResponseEntity;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.annotation.Path;
import io.koraframework.http.common.annotation.Query;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.common.header.HttpHeaders;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.http.server.common.response.HttpServerResponse;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.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: " + userId));
}
@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 io.koraframework.guide.httpserver.controller
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.guide.httpserver.service.UserService
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.HttpResponseEntity
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.annotation.Path
import io.koraframework.http.common.annotation.Query
import io.koraframework.http.common.body.HttpBody
import io.koraframework.http.common.header.HttpHeaders
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponse
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.annotation.Json
import java.time.Instant
@Component
@HttpController
class UserController(
private val userService: UserService
) {
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
fun getUser(@Path userId: String): UserResponse =
userService.getUser(userId) ?: throw HttpServerResponseException.of(404, "User not found: $userId")
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
fun getUsers(
@Query("page") page: Int?,
@Query("size") size: Int?,
@Query("sort") sort: String?
): List<UserResponse> =
userService.getUsers(page ?: 0, size ?: 10, sort ?: "name")
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): HttpResponseEntity<UserResponse> =
HttpResponseEntity.of(201, HttpHeaders.of(), userService.createUser(request))
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
fun updateUser(@Path userId: String, @Json request: UserRequest): HttpResponseEntity<UserResponse> =
HttpResponseEntity.of(
200,
HttpHeaders.of("X-Updated-At", Instant.now().toString()),
userService.updateUser(userId, request)
)
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
fun deleteUser(@Path userId: String): HttpServerResponse {
userService.deleteUser(userId)
return HttpServerResponse.of(204, HttpBody.empty())
}
}
Это финальная структура, которую использует готовое приложение-спутник. Поведение не изменилось, но архитектура стала чище:
- контроллер = HTTP-представление
- репозиторий = абстракция хранения
- сервис = прикладная логика
Обратите внимание, как тип возвращаемого значения каждого маршрута определяет, какой преобразователь Kora ищет во время компиляции:
UserResponseиList<UserResponse>с@JsonтребуютJsonWriterдля типа, который генерирует@Jsonна DTOHttpResponseEntity<UserResponse>переиспользует тот же JSON-писатель и добавляет сверху код статуса и заголовкиHttpServerResponseвозвращается как есть и вообще не нуждается в преобразователе
Конфигурация¶
Теперь, когда структура приложения на месте, можно настроить сам HTTP-сервер.
Создайте или обновите src/main/resources/application.conf:
Полный справочник по конфигурации — в HTTP-сервере и Logging SLF4J.
httpServer {
port = 8080 //(1)!
system.port = 8085 //(2)!
telemetry.logging.enabled = true //(3)!
}
logging {
levels {
"ROOT": "WARN" //(4)!
"io.koraframework": "INFO" //(5)!
}
}
- Публичный HTTP-порт для эндпоинтов приложения (по умолчанию:
8080). - Системный HTTP-порт для проб, метрик и служебных эндпоинтов (по умолчанию:
8085). - Включает логирование запросов публичного HTTP-сервера (по умолчанию:
false). - Уровень логирования корневого логгера.
- Уровень логирования логгеров фреймворка Kora.
httpServer:
port: 8080 #(1)!
system:
port: 8085 #(2)!
telemetry:
logging:
enabled: true #(3)!
logging:
levels:
ROOT: "WARN" #(4)!
"io.koraframework": "INFO" #(5)!
- Публичный HTTP-порт для эндпоинтов приложения (по умолчанию:
8080). - Системный HTTP-порт для проб, метрик и служебных эндпоинтов (по умолчанию:
8085). - Включает логирование запросов публичного HTTP-сервера (по умолчанию:
false). - Уровень логирования корневого логгера.
- Уровень логирования логгеров фреймворка Kora.
Так вы получаете два порта:
8080— основной API приложения8085— системные эндпоинты, такие как readiness и liveness
Такое разделение полезно в реальных системах, потому что проверки здоровья и эксплуатационные эндпоинты обычно отделяют от публичного бизнес-трафика. Оба ключа уже имеют эти значения по умолчанию,
поэтому приложение запускается и вовсе без application.conf; файл становится нужен, как только требуется перенести порт, поднять уровень логирования или прочитать значение из переменной окружения.
Проверка приложения¶
classes — осмысленная первая проверка в Kora: она запускает аннотационный процессор или KSP, генерирует модуль контроллера и граф приложения и падает во время компиляции, если маршрут или
зависимость не удается связать.
Проверки публичного 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:
curl http://localhost:8085/system/readiness
# Expected output: OK
curl http://localhost:8085/system/liveness
# Expected output: OK
Лучшие практики¶
- Держите методы контроллера тонкими, как только проект выходит за рамки тривиальных обработчиков.
- Используйте репозитории для задач хранения, а сервисы — для прикладной логики.
- Используйте
HttpResponseEntity, когда нужны явные коды статуса или заголовки. - Бросайте
HttpServerResponseException, когда контроллеру или сервису нужно вернуть чистую HTTP-ошибку. - Держите методы контроллера синхронными и позволяйте виртуальным потокам Undertow нести блокирующую работу.
- Защищайте любое состояние, разделяемое между запросами, потому что параллельные запросы выполняются на разных потоках.
Итоги¶
Вы построили HTTP API на Kora постепенно:
- сначала один маршрут без хранения
- затем второй маршрут, который показал необходимость хранилища
- затем абстракцию репозитория с реализацией в памяти
- затем контракт репозитория, расширенный до полного CRUD
- и, наконец, сервисный слой и маршруты контроллера, открывающие полный API
Ключевые понятия¶
- маршрутизация HTTP в Kora через
@HttpRoute - маппинг JSON запроса и ответа через
@Json - связывание запроса через
@Pathи@Query - контроль ответа через
HttpResponseEntity - сигнализация HTTP-ошибок через
HttpServerResponseException - разные обязанности контроллера, репозитория и сервиса
- один процесс, два HTTP-сервера: публичный и системный
Устранение неполадок¶
Сервер не стартует:
- Проверьте доступность портов
8080и8085. - Убедитесь, что
ApplicationподключаетUndertowPublicHttpServerModuleиHoconConfigModule.
Компиляция падает с No component found for dependency:
- Типа, указанного в сообщении, нет в графе. Добавьте
@Componentего реализации или предоставьте его методом модуля. - Частый случай — забытый
@ComponentнаInMemoryUserRepositoryилиUserService.
Компиляция падает с JsonWriter<T> was not found:
- DTO, возвращаемое маршрутом, не помечено
@Json, поэтому писатель для него не сгенерирован.
getUser всегда возвращает 404:
- Проверьте, что
createUserиgetUserуже связаны со слоем репозитория. - Убедитесь, что вызываете
getUserс тем ID, который реально вернулcreateUser.
Необязательные параметры строки запроса обрабатываются неверно:
- В Java используйте nullable-обертки с
@Nullable @Query, напримерIntegerиString, и импортируйте@Nullableизorg.jspecify.annotations. - В Kotlin объявляйте тип параметра nullable, например
page: Int?. - Отсутствующий обязательный параметр строки запроса получает
400еще до вызова метода.
Сборка Kotlin падает с Suspend methods are not supported by the HTTP server controller generator:
- Уберите
suspendиз метода контроллера. HTTP-обработчики Kora синхронные и уже выполняются на виртуальном потоке.
Сборка зависает или падает неожиданно:
- Выполните
./gradlew --stopи повторите.
Что дальше?¶
- Работа с JSON — чтобы маппинг DTO запроса и ответа стал явным.
- Валидация — чтобы добавить проверки на границе того же HTTP API.
- База данных JDBC или База данных Cassandra — чтобы заменить репозиторий в памяти настоящим хранилищем.
- Продвинутый HTTP-сервер — когда базовая форма CRUD станет привычной.
- HTTP-клиент — когда захочется вызывать этот API из другого приложения Kora.
Помощь¶
Если возникли сложности:
- сравните с Kora Java HTTP Server App и Kora Kotlin HTTP Server App
- посмотрите документацию по HTTP-серверу
- посмотрите документацию по JSON