JavaRush /Курси /Spring Boot /@RestController і пе...

@RestController і перші JSON API

Spring Boot
Рівень 10 , Лекція 3
Відкрита

1. Контролер: тонкий вебрівень поверх service

Коли вже зрозуміло, як HTTP-запит доходить до методу контролера, можна спокійно збирати справжній контролер каталогу. Але дуже легко переплутати ролі й почати писати бізнес-логіку прямо в контролері, бо «ну раз запит уже тут…». На цьому етапі ми свідомо проводимо межу: контролер — це адаптер, а не мозок застосунку.

Якщо спростити до чесної інженерної схеми, у нас є шари, і кожен розвʼязує своє завдання. Контролер розвʼязує завдання «як прочитати запит і що повернути». Сервіс розвʼязує завдання «що вважати правильною відповіддю за правилами застосунку». Репозиторій розвʼязує завдання «звідки взяти дані (у нашому випадку — з памʼяті)».

flowchart TD
    A["HTTP-клієнт: браузер/curl/Postman"] --> B["Вбудований Tomcat"]
    B --> C[DispatcherServlet]
    C --> D[CourseCatalogController]
    D --> E[CourseCatalogService]
    E --> F[CourseCatalogRepository]
    F --> G["(дані в памʼяті)"]

Важливий момент: ми не створюємо контролер через new. Ми не викликаємо його методи вручну. Він живе в Spring-контексті, а викликає його Spring MVC. Тому, якщо ви колись відчуєте бажання зробити new CourseCatalogController(...), зупиніться, вдихніть і згадайте: це вже не консольний застосунок, це «доросла» система, де життям обʼєктів керує контейнер.

І ще одна межа, яку корисно тримати в голові: контролер — це місце, де зʼявляються HTTP-деталі (шляхи, параметри, статуси). Сервіс — це місце, де зʼявляються предметні деталі (курси, featured, published). Змішувати ці два світи можна, але вийде «суп», який складно підтримувати: туди постійно почнуть прилипати нові параметри, перевірки, винятки й «а давайте тут ще один фільтр». Ми саме вчимося робити навпаки.

2. @RestController і тіло відповіді

До цього місця вже видно головне: контролер каталогу має не шукати HTML-сторінки, а віддавати назовні дані сервісу. Тому базова анотація тут — @RestController: вона каже Spring MVC, що значення, яке повертає метод, потрібно писати прямо в тіло HTTP-відповіді.

Якби ми використовували @Controller, то рядковий return за замовчуванням трактувався б як імʼя подання (view). Для catalog-service це не те, що потрібно: ми будуємо JSON-сервіс лише для читання, і вебрівень у нас має повертати дані, а не рендерити сторінки. Точковий варіант із @ResponseBody теж існує, але для такого контролера простіше й чесніше одразу поставити @RestController на клас. З цією опорою можна одразу збирати CourseCatalogController: вебрівень читає HTTP-вхід і віддає назовні дані сервісу.

3. Каркас CourseCatalogController

Тепер ми зберемо головний клас сьогоднішньої лекції — контролер каталогу. Тут важливо не лише «які анотації написати», а й як зберегти читабельність. Найчастіша дрібна проблема новачка: один і той самий префікс URL повторюється в кожному методі, а залежності підтягуються хаотично. Ми зробимо акуратно: загальний префікс винесемо на рівень класу, а залежність уведемо через конструктор.

Домовімося про базовий URL. Ми хочемо, щоб усі кінцеві точки каталогу жили під одним «будинком» — /api/catalog. Тоді маршрути читаються очима: «це API, це каталог, це курси». І якщо завтра зʼявляться ще кінцеві точки, вони не розлетяться по проєкту, як шкарпетки після прання.

(на початку файла буде package com.example.catalogservice.catalog.web;)

import com.example.catalogservice.catalog.service.CourseCatalogService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController // Контролер, який віддає дані (зазвичай JSON), а не HTML-сторінки
@RequestMapping("/api/catalog") // Загальний префікс для всіх методів цього контролера
public class CourseCatalogController {

    private final CourseCatalogService service; // Залежність від бізнес-логіки (service), а не від repository

    public CourseCatalogController(CourseCatalogService service) {
        this.service = service; // Впровадження через конструктор: залежність обовʼязкова і видима
    }
}

Тут одразу кілька важливих речей, які варто проговорити вголос (так, можна й пошепки, якщо ви в офісі).

По-перше, @RestController робить цей клас Spring-біном, а Spring MVC побачить його і зареєструє маршрути. По-друге, @RequestMapping("/api/catalog") — це не «магія», а просто префікс: до нього додаватимуться шляхи методів. По-третє, constructor injection — це наш базовий стиль: залежність CourseCatalogService стає обовʼязковою і видимою. Якщо сервіс не створився, застосунок упаде на старті, а не «десь потім».

І дуже важлива архітектурна звичка: контролер залежить від сервісу, а не від репозиторію. Репозиторій — це вже «внутрішня кухня». Контролер — це «офіціант». Офіціант не повинен заходити на кухню і сам смажити котлети. Інакше він стає не офіціантом, а дуже втомленою людиною.

4. Список курсів: GET /api/catalog/courses

Перша кінцева точка майже завжди найприємніша, бо дає відчуття «це справді працює». Але саме на ній частіше за все роблять шкідливу звичку: починають прямо в контролері фільтрувати, сортувати, обмежувати, і в результаті контролер перетворюється на міні-сервіс. Ми робимо рівно одну річ: приймаємо запит і делегуємо.

У нашому випадку список курсів — це GET /api/catalog/courses. Повертатимемо список CourseCard. На цьому етапі нам не потрібно жодних DTO, жодних «обгорток на кшталт { data: ... }» і тим більше жодних ручних рядків "{\"id\":...}". Просто повертаємо Java-обʼєкти — і MVC сам перетворить їх на JSON через HttpMessageConverter.

Всередині CourseCatalogController додаємо метод:

import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.List;

@GetMapping("/courses") // GET /api/catalog/courses (префікс береться з @RequestMapping на класі)
public List<CourseCard> findAll() {
    // Контролер не фільтрує і не сортує: він просто делегує в сервіс
    return service.findAll();
}

Якщо ви зараз спитаєте: «А де тут JSON?» — відповідь буде чесною: «Усередині Spring MVC і Jackson». Саме в цьому й сенс стартера та підходу Spring Boot: ви пишете бізнес-код, а інфраструктура займається перетворенням форматів.

Щоб було зовсім наочно, уявімо, що service.findAll() повертає два курси. Тоді JSON-відповідь буде виглядати приблизно так (форматування може відрізнятися, але ідея така):

[
  { "id": "spring-boot", "slug": "spring-boot", "title": "Spring Boot" },
  { "id": "spring-core", "slug": "spring-core", "title": "Spring Core" }
]

І ще один важливий нюанс: цей метод не повинен друкувати в консоль, не повинен збирати рядки й не повинен ловити винятки «про всяк випадок». Якщо щось зламається, Boot поверне стандартну помилку. Наша задача зараз — зробити перший чистий вхід з вебрівня до сервісу.

5. Курс за slug і 404

Список — це добре, але справжній каталожний сервіс зазвичай уміє віддавати конкретну сутність за ключем. У нашому випадку ключ — це slug (людинозрозумілий ідентифікатор в URL). І тут зʼявляється важлива практична деталь: що робити, якщо курс не знайдено? Можна, звісно, «повернути null і сподіватися на краще», але краще так не робити — у надії погана статистика в продакшені.

У мінімальному варіанті ми хочемо коректний HTTP-статус 404 Not Found. Ми не проєктуємо повноцінний контракт помилок і не пишемо глобальну обробку винятків, але статус 404 — це базова ввічливість API: «такого ресурсу немає».

Зробімо так: сервіс буде повертати Optional<CourseCard>, а контролер перетворить його на ResponseEntity.

Метод у контролері:

import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@GetMapping("/courses/{slug}") // GET /api/catalog/courses/{slug}
public ResponseEntity<CourseCard> findBySlug(@PathVariable String slug) {
    // Якщо курс знайдено -> 200 OK + JSON-тіло. Якщо ні -> 404 Not Found без тіла.
    return service.findBySlug(slug)
            .map(ResponseEntity::ok) // Успішна відповідь: статус 200
            .orElseGet(() -> ResponseEntity.notFound().build()); // Ресурс не знайдено: статус 404
}

Зверніть увагу на дві речі. Перша: @PathVariable String slug — це звʼязування частини шляху {slug} з аргументом методу. Друга: ми явно говоримо, яку відповідь повернути, якщо курс знайдено (200 OK + тіло), і яку, якщо не знайдено (404 без тіла).

А щоб ця історія працювала, сервіс має вміти повертати Optional. Наприклад, у CourseCatalogService це може виглядати так (це лише ідея, а не «єдина правильна релігія»):

import com.example.catalogservice.catalog.domain.CourseCard;

import java.util.Optional;

public Optional<CourseCard> findBySlug(String slug) {
    // Сервіс не «вигадує» HTTP-статуси: він просто повідомляє, знайшли ми курс чи ні
    return repository.findBySlug(slug);
}

У підсумку контролер лишається тонким, а рішення «є/немає» лежить там, де йому комфортно: сервіс і репозиторій знають дані, контролер знає HTTP-відповідь.

6. Featured: GET /api/catalog/featured

Тепер додамо маршрут, який добре показує ідею «один сервіс — кілька зрозумілих входів». Featured-підбірка — це не новий тип даних. Це той самий CourseCard, лише відібраний за правилом «featured = true». І важливий момент: правило відбору — це бізнес-логіка каталогу, а не HTTP-деталь. Тому фільтрація має жити в сервісі, а контролер — лише викликати потрібний метод.

Маршрут буде такий: GET /api/catalog/featured. За змістом це «представлення» каталогу: ті курси, які ми хочемо показати як рекомендовані.

Метод контролера:

import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.List;

@GetMapping("/featured") // GET /api/catalog/featured
public List<CourseCard> featured() {
    // Логіку «що таке featured» тримаємо в сервісі, тут тільки HTTP-вхід
    return service.findFeatured();
}

А ось приклад того, як це може бути реалізовано в сервісі (і це якраз демонстрація «логіка в service, а не в controller»):

import com.example.catalogservice.catalog.domain.CourseCard;

import java.util.List;

public List<CourseCard> findFeatured() {
    // Фільтрація за бізнес-правилом: featured = true
    return repository.findAll().stream()
            .filter(CourseCard::featured) // Беремо тільки рекомендовані курси
            .toList();
}

Так, це маленький Stream. Так, він читається нормально. І так, це саме те місце, де фільтрація доречна: сервіс відповідає за «що таке featured».

Важливо: ми поки що не обговорюємо, скільки елементів повертати, і звідки береться ліміт. На поточному етапі нам достатньо самої ідеї: окрема кінцева точка, окремий метод сервісу, зрозуміла JSON-відповідь.

7. Серіалізація CourseCard у JSON

Коли ви вперше повертаєте List<CourseCard> із контролера, виникає відчуття, що десь усередині Spring сидить маленький гном і пише JSON вручну. Насправді все простіше й нудніше (а нудне — це зазвичай добре): Spring MVC вибирає відповідний HttpMessageConverter, і для JSON зазвичай використовується Jackson. Jackson дивиться на ваш обʼєкт як на звичайний Java-обʼєкт: поля, getter-и, компоненти record — і перетворює його на JSON.

Щоб побачити це наочно, досить мінімального CourseCard. Наприклад, у доменному пакеті це може бути record (records для «карток» підходять чудово: дані незмінні, структура зрозуміла, зайвих сеттерів немає).

import java.time.LocalDate;

// Доменна модель «картки курсу», яку ми напряму серіалізуємо в JSON
public record CourseCard(
        String id,          // Внутрішній ідентифікатор курсу
        String slug,        // Людинозрозумілий ключ для URL (наприклад, spring-boot)
        String title,       // Назва курсу
        boolean featured,   // Ознака «рекомендований»
        LocalDate launchDate // Дата запуску (зазвичай стане рядком на кшталт "2026-04-01" у JSON)
) { }

Якщо такий обʼєкт повернути з контролера, JSON матиме поля id, slug, title, featured, launchDate. Зазвичай дати серіалізуються в рядок, наприклад "2026-04-01". На цьому етапі ми не налаштовуємо формат, не пишемо кастомні серіалізатори і не додаємо анотації Jackson «про всяк випадок». Наша мета — базова передбачуваність: повертаємо Java-обʼєкт, отримуємо JSON.

Корисно тримати просту відповідну табличку, щоб не дивуватися:

Java-тип У що перетворюється в JSON
String рядок
int,
long
,
BigDecimal
число
boolean true/false
List<T> масив
record / POJO обʼєкт
{ ... }
null null (якщо поле присутнє)

І ще одна практична рекомендація: на ранньому етапі краще повертати «чисті» обʼєкти даних. Якщо ви почнете повертати щось на кшталт Optional<CourseCard> просто з контролера, воно може серіалізуватися неочікувано (або просто виглядати дивно для клієнта). Тому Optional — чудовий тип усередині сервісів, але назовні (у HTTP) краще віддавати або конкретний обʼєкт, або ResponseEntity.

8. Типові помилки під час роботи з контролерами

Коли ви починаєте писати контролери, помилки зазвичай двох категорій: «не зрозумів, як працює Spring MVC» і «зрозумів, але вирішив зробити по-своєму, бо так швидше». Друга категорія зазвичай наздоганяє болючіше, але перша трапляється частіше, тож почнемо з неї.

Помилка №1: використовувати @Controller і дивуватися, чому не приходить JSON.
Новачок пише метод, повертає List<CourseCard>, запускає, робить запит — і бачить дивну поведінку (або помилку про view). Причина майже завжди в тому, що @Controller без @ResponseBody орієнтований на подання. Для JSON-відповідей у нашому сервісі базова анотація — @RestController, і вона ж робить намір коду очевидним.

Помилка №2: складна логіка всередині контролера «бо тут же все поруч».
Руки самі тягнуться зробити stream().filter(...).sorted(...).limit(...) прямо в кінцевій точці, особливо коли фільтр простенький. Проблема не в стрімі, а в тому, що контролер починає знати про бізнес-правила. Через пару днів туди додасться другий фільтр, потім третій, потім «якщо такий параметр, то ось так», і контролер перетворюється на міні-потвору, яку неможливо читати. Правильна звичка — у контролері лише отримати вхідні параметри і викликати метод сервісу.

Помилка №3: field injection (@Autowired на полі) — тихий спосіб втратити контроль над залежностями.
Field injection здається «коротшим», але робить залежності неявними, погіршує тестованість і легко призводить до ситуацій «воно стартує, але незрозуміло чому». Constructor injection, навпаки, змушує залежності бути чесними. У навчальному проєкті це особливо важливо: ми будуємо не тільки код, а й правильні звички.

Помилка №4: повертати Optional назовні як частину HTTP-контракту.
Optional — чудовий інструмент усередині Java-коду, але у вебконтракті він виглядає дивно: клієнту не потрібно знати, що всередині сервісу ви використали Optional. Клієнту потрібен або ресурс (200), або його відсутність (404). Тому Optional краще «розпаковувати» в контролері і відповідати через ResponseEntity.

Помилка №5: намагатися «зробити красиво» і одразу побудувати DTO-шар, обгортки та універсальні відповіді.
На цьому першому вебрівні це виглядає як «я дорослий розробник», але зазвичай перетворюється на «я щойно написав собі мікрофреймворк поверх Spring». Зараз наша мета — підняти мінімальний, чесний сервіс: GET-маршрути, зрозумілі URL, прості доменні обʼєкти в JSON. Краса тут — у ясності, а не в кількості абстракцій.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ