JavaRush /Курсы /Spring REST & MVC /Теги как lookup endpoint

Теги как lookup endpoint

Spring REST & MVC
25 уровень , 3 лекция
Открыта

1. Общий список тегов для клиента

Если бы backend жил в вакууме, мы могли бы сказать: «Теги — это просто List<String> внутри Task, вот и всё». Но API — это контракт для клиента, а клиент обычно хочет удобные штуки: фильтр по тегу, автодополнение, выпадающий список «какие теги вообще существуют», чтобы пользователь не писал bugfix в одном месте и bug-fix в другом. То есть теги становятся частью UX, а значит, вам нужен предсказуемый способ их получить.

После комментариев виден ещё один тип расширения API. Не всё должно становиться либо полем у Task, либо новой коллекцией под ней. Иногда клиенту нужен read-only список значений, который помогает фильтровать, подсказывать варианты и не собирать справочник из задач на своей стороне.

Представьте фронтенд: у него есть экран «Список задач» с фильтрами. Один из фильтров — “Tag”. Фронтенд может, конечно, сделать хитрый трюк: запросить GET /api/v1/tasks на тысячу элементов, потом собрать все теги, потом “distinct + sort” уже у себя. Но это сразу даёт три проблемы.

Первая проблема — лишний трафик и лишняя логика на клиенте. Мы просим у сервера задачи, хотя нам нужны только теги. Вторая проблема — непредсказуемость. Пагинация, фильтры, права доступа (в нашем курсе их нет, но в жизни будут) — и внезапно клиент собрал «не все теги», а только те, что попали на первую страницу. Третья проблема — ответственность разъезжается: часть «логики контракта» внезапно живёт на клиенте, и у вас появляется два разных “источника правды” о том, как собирать и сортировать теги.

Поэтому логика взрослая и простая: теги остаются частью задач, но мы добавляем lookup endpoint, который отдаёт “справочный” список уже существующих тегов. Он закрывает UX‑потребность, не раздувая домен.

2. Теги как value: без отдельного CRUD

Когда разработчик видит «повторяющиеся значения» — рука иногда сама тянется к созданию сущности: Tag с id, репозиторием, CRUD‑контроллером и отдельной вселенной ошибок. Это особенно типично после первых проектов на JPA, где всё кажется набором таблиц. Но здесь важно вовремя остановиться и спросить: есть ли у тега самостоятельный жизненный цикл в нашем проекте? Может ли тег существовать независимо от задач? Нужно ли его редактировать, удалять, “администрировать” отдельно?

В нашем Task Tracker API ответ честный: теги — это значения, “наклейки на задаче”, а не самостоятельные бизнес‑объекты. Они появляются и исчезают как следствие создания/обновления задач. Если делать полный CRUD для тегов, вы мгновенно получите лишние договорённости: что будет, если удалить тег, который уже стоит на задачах? Удаляем и из задач? Запрещаем удалять? А если переименовать тег — переименовываем во всех задачах? Это не невозможно, но это другая сложность, и она не помогает нашей цели дня: держать API ресурсным и компактным.

Очень полезно сравнить два подхода по цене и смыслу:

Подход Что получает клиент Что платит backend Когда оправдан
Полный Tag CRUD (POST/PUT/DELETE /tags) Управление справочником тегов как отдельной подсистемой Новые правила консистентности, новые ошибки, новые сценарии конфликтов, больше документации и тестов Когда теги реально управляются отдельно (есть админка, модерация, описания, иконки и т.п.)
Lookup endpoint GET /tags Список существующих тегов для UI и фильтров Минимальная логика: собрать, уникализировать, отсортировать Когда теги — просто строки внутри другого ресурса

То есть lookup — это не «урезанный CRUD». Это другой тип endpoint’а: маленький supporting ресурс, который помогает клиенту ориентироваться в значениях, не создавая отдельной сущности там, где она не нужна.

3. Контракт GET /api/v1/tags

Сейчас мы зафиксируем контракт так, чтобы он был не “примерно как получится”, а чётко понятный и повторяемый. Нам нужен endpoint GET /api/v1/tags, который возвращает список тегов, встречающихся в задачах. Важно, что это read-only endpoint, он не создаёт и не меняет данные — классический safe GET. Это хорошо влияет и на восприятие, и на возможность кешировать результат (в нашем курсе кеширование не делаем, но мысль правильная).

Ключевые решения контракта здесь такие. Во-первых, статус успешного ответа — 200 OK, даже если тегов пока нет. Пустой список — нормальный результат, а не ошибка: отсутствие тегов не означает, что endpoint “не найден”. Во-вторых, список должен быть детерминированным: одинаковый набор данных должен давать одинаковый порядок значений. Для этого мы сортируем теги. В-третьих, список должен быть уникальным: без повторов, иначе UI превращается в комедию (“bug”, “bug”, “bug”…).

С комментариями мы могли позволить себе raw array: это маленькая коллекция внутри конкретной задачи, и дополнительных metadata там обычно не ждут. У /tags другая роль — это глобальный supporting lookup для UI и фильтров, поэтому envelope здесь выглядит надёжнее.

По response shape есть два пути: вернуть “голый” JSON‑массив ["bug","java"] или вернуть небольшой envelope, например { "items": [...] }. В модуле про стабильный JSON‑контракт мы уже обсуждали, почему envelope часто выигрывает: если вы захотите добавить метаданные (например, total), вы сможете сделать это, не ломая клиентов. Поэтому дальше я покажу вариант с envelope. Он чуть длиннее на 3 символа, зато приятнее как контракт.

Пример ответа:

{
  "items": ["backend", "bug", "java", "urgent"]
}

И ещё одно важное правило: источником правды для тегов являются задачи, а не “справочник ради справочника”. Значит, endpoint GET /tags будет собирать теги из TaskRepository, не создавая отдельный TagRepository.

4. Реализация: TagControllerTagServiceTaskRepository

Теперь перейдём к коду так, чтобы он естественно встраивался в нашу архитектуру: контроллер тонкий, бизнес-логика (пусть и простая) — в сервисе, данные берём через репозиторий. Даже если кажется, что “да тут всего одна строчка стрима”, лучше всё равно держать дисциплину слоёв. Иначе через неделю у вас будет контроллер на 200 строк с flatMap, сортировками и “ну тут же маленькая штука была”.

Схема вызова получается такой:

flowchart TD
  A["HTTP GET /api/v1/tags"] --> B["TagController"]
  B --> C["TagService"]
  C --> D["TaskRepository"]
  D --> C
  C --> B
  B --> E["200 OK + TagLookupResponse"]

DTO ответа: TagLookupResponse

Начнём с модели ответа. Разместить её логично в com.example.tasktracker.api.dto.response.

import java.util.List;

/**
 * DTO для lookup-ответа по тегам.
 * Важно: это read-only представление для клиента, не доменная сущность.
 */
public record TagLookupResponse(List<String> items) {
}

Это намеренно минимальный DTO: список строк. При желании сюда легко добавляются metadata вроде total, но сейчас они не нужны.

Контроллер: TagController

Контроллер — максимально тонкий: принял запрос, вызвал сервис, вернул DTO. Никаких стримов и “distinct() прямо в контроллере”.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/tags") // supporting-ресурс глобального уровня
public class TagController {

    private final TagService tagService;

    public TagController(TagService tagService) {
        // Внедряем сервис через конструктор: контроллер сам не должен «знать», как собирать теги
        this.tagService = tagService;
    }

    @GetMapping
    public TagLookupResponse getAll() {
        // Возвращаем read-only lookup: список существующих тегов из задач
        return tagService.getAllTags();
    }
}

Обратите внимание на несколько моментов. Путь начинается сразу с /api/v1/tags, чтобы клиенту не пришлось помнить “а это у нас под /tasks или отдельно?”. Это действительно supporting ресурс глобального уровня: он помогает для фильтров задач, но не является подресурсом конкретной задачи.

Сервис: TagService

В TagService мы берём все задачи и собираем из них теги. Да, в in-memory мире “взять все задачи” звучит нормально. В реальном проекте с БД вы бы не делали “всё в память”, но сейчас наша цель — API‑контракт и читаемая логика, а не оптимизация для миллионов записей.

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class TagService {

    private final TaskRepository taskRepository;

    public TagService(TaskRepository taskRepository) {
        // Сервис зависит от репозитория: сервис не должен сам хранить коллекции задач
        this.taskRepository = taskRepository;
    }

    public TagLookupResponse getAllTags() {
        // Репозиторий «достаёт данные» (теги), сервис «оформляет ответ» под API-контракт
        List<String> tags = taskRepository.findAllTags();
        return new TagLookupResponse(tags);
    }
}

Здесь я специально сделал интересный ход: TagService вызывает taskRepository.findAllTags(). Это не единственный вариант. Можно было бы вызвать findAll() и собирать теги в сервисе. Но findAllTags() делает наш код понятнее: репозиторий отвечает за “как достать данные”, сервис — за “как оформить результат для API”. А сбор тегов из хранилища — это всё-таки ближе к “доставанию данных”, чем к “бизнес‑логике”.

Если у вас сейчас репозиторий не имеет такого метода — это нормально. Мы можем его добавить, и in-memory реализация легко это поддержит.

Репозиторий: метод findAllTags() (in-memory)

Покажу идею на уровне интерфейса:

import java.util.List;

public interface TaskRepository {

    List<Task> findAll();

    /**
     * Возвращает все теги, встречающиеся в задачах.
     * Важно: контракт метода должен соответствовать контракту API (уникальность/порядок решаем ниже).
     */
    List<String> findAllTags();
}

А теперь пример реализации для in-memory репозитория (супер упрощённо, чтобы увидеть механику). Допустим, у нас есть коллекция tasks.

import java.util.List;

public class InMemoryTaskRepository implements TaskRepository {

    private final List<Task> tasks;

    public InMemoryTaskRepository(List<Task> tasks) {
        // Храним задачи в памяти (для учебного варианта)
        this.tasks = tasks;
    }

    @Override
    public List<String> findAllTags() {
        // «Сырая» версия: просто разворачиваем список тегов из всех задач
        // Уникальность и сортировка добавятся следующим шагом
        return tasks.stream()
                .flatMap(t -> t.getTags().stream())
                .toList();
    }
}

Эта версия пока “сырая”: она не делает distinct() и не сортирует. Мы осознанно оставим это на следующий раздел, потому что именно там и живут типичные ошибки, из-за которых lookup endpoint внезапно начинает вести себя как генератор случайных чисел.

5. Уникальность и сортировка тегов

На бумаге “вернуть список тегов” выглядит как одна строчка. На практике именно в таких местах API начинает постепенно обрастать мелкими неприятностями. То порядок ответов “прыгает”, то появляются ["Java","java"], то теги внезапно содержат пробелы, и в UI возникает шедевр: " urgent" и "urgent" как будто разные значения. Поэтому сейчас мы сделаем ту часть, которая превращает сырую выборку в нормальный контракт.

Нормализация: trim() и toLowerCase()

Начнём с того, что тег — это пользовательский ввод. А пользовательский ввод, как известно, умеет в неожиданные пробелы. Поэтому почти всегда полезно нормализовать строки. Самый понятный минимум — trim() и toLowerCase().

private String normalize(String tag) {
    // Убираем пробелы по краям и приводим к нижнему регистру для case-insensitive семантики
    return tag.trim().toLowerCase();
}

Почему toLowerCase()? Потому что в требованиях проекта у нас есть правило: теги должны быть уникальны в рамках задачи без учёта регистра. Раз мы уже живём в этой семантике, логично и lookup делать так же. Иначе человек увидит два варианта одного и того же тега и задастся философским вопросом: “а какой из них настоящий?”.

Собираем: flatMap() → normalize → distinct()sorted()

Теперь соберём всё в один понятный pipeline. Я покажу реализацию в InMemoryTaskRepository#findAllTags() (или, если вы предпочитаете, это можно держать в сервисе — логика будет та же).

import java.util.List;

@Override
public List<String> findAllTags() {
    return tasks.stream()
            .flatMap(t -> t.getTags().stream()) // 1) вытаскиваем теги из всех задач в один поток
            .map(this::normalize)               // 2) нормализуем пользовательский ввод
            .distinct()                         // 3) убираем повторы после нормализации
            .sorted()                           // 4) делаем порядок детерминированным для клиента
            .toList();
}

Здесь важно понять порядок операций. Мы сначала вытаскиваем все теги (flatMap()), потом нормализуем (map()), потом делаем уникальность (distinct()), потом сортируем (sorted()). Если перепутать, получится странность. Например, если distinct() сделать до нормализации, то "Java" и "java" не будут считаться одинаковыми, и вы всё равно получите дубликаты.

Сортировка как часть контракта

Сортировка делает ответ детерминированным. Без сортировки порядок тегов будет зависеть от внутреннего порядка задач, от seed data, от того, как вы добавляли элементы в список, а иногда ещё и от того, какую коллекцию вы использовали (например, HashSet может менять порядок между запусками). Для клиента это боль: UI будет “мигать” — теги в выпадающем списке сегодня в одном порядке, завтра в другом.

С сортировкой контракт становится предсказуемым: “алфавитный порядок, всегда одинаково”. И это сразу улучшает UX: пользователь быстрее находит нужный тег, а автотесты фронтенда перестают падать из-за рандомного порядка.

Если хотите чуть более дружелюбную сортировку (без сюрпризов в регистре), можно отсортировать case-insensitive:

import java.util.Comparator;

// Явно показываем сортировку компаратором (в нашем случае после lower-case это уже не обязательно)
.sorted(Comparator.naturalOrder())

Но в нашем случае мы уже нормализовали в lower-case, так что обычный sorted() работает прекрасно.

Пустой результат — это успех

Ещё раз закрепим семантику: если задач нет или ни у одной задачи нет тегов, GET /api/v1/tags должен вернуть:

{
  "items": []
}

и статус 200 OK.

Это важно психологически. 404 говорит: “ресурс не существует”. А у нас ресурс существует, просто сейчас он пустой. Это нормально.

6. Проверка контракта через .http

После любого нового endpoint’а очень полезно сделать быстрый «смоук‑тест руками». Не потому что мы не любим тесты (мы их полюбим позже), а потому что человеку нужно увидеть, что контракт реально выглядит так, как задумано. Обычно это 30 секунд, которые экономят 30 минут “почему фронт ругается”.

Пример запроса в .http файле:

### Tags lookup
# Проверяем supporting endpoint, который отдаёт справочный список тегов
GET http://localhost:8080/api/v1/tags
Accept: application/json

Ожидаемый ответ (пример):

{
  "items": ["backend", "bug", "java", "urgent"]
}

И теперь — важная практическая связка. Клиент получил список тегов, показал пользователю dropdown, пользователь выбрал, скажем, bug. Дальше клиент идёт в наш list endpoint задач, который уже умеет фильтр по тегу:

### Tasks filtered by tag
# Используем выбранный тег как параметр фильтра в списке задач
GET http://localhost:8080/api/v1/tasks?tag=bug&page=0&size=20&sort=updatedAt,desc
Accept: application/json

И вот эта пара endpoint’ов выглядит для клиента как нормальная экосистема: есть справочник значений и есть фильтрация по ним. При этом доменная модель проекта не раздулась, потому что Tag не стал отдельной сущностью.

7. Типичные ошибки при lookup тегов

Когда делаешь lookup endpoint, легко попасть в ловушку “ну это же мелочь”. Но именно из мелочей и складывается ощущение, что API «то нормальное, то какое-то странное». Ошибки ниже встречаются чаще всего, и почти все они лечатся одной вещью: осознанностью контракта и небольшим количеством дисциплины в коде.

Ошибка №1: превращать теги в полноценный CRUD просто потому, что “так привычнее”.
Очень соблазнительно сделать POST /tags, DELETE /tags/{id}, PUT /tags/{id} — кажется, что вы “доделали домен”. На деле вы добавили новую сущность с независимым жизненным циклом и кучу конфликтных сценариев, которых не просили. Если теги — это значения в задаче, то lookup endpoint закрывает потребность клиента гораздо дешевле.

Ошибка №2: возвращать недетерминированный порядок (то так, то сяк).
Если вы собираете теги в HashSet и потом делаете toList(), порядок может быть разным между запусками. Если вы собираете “как получилось” из задач, порядок будет зависеть от seed data и от того, как вы добавляли задачи. Клиенту это неприятно, а разработчику — ещё неприятнее отлаживать “плавающее” поведение. Стабильная сортировка делает контракт предсказуемым.

Ошибка №3: делать distinct() до нормализации и получать дубли вроде "Java" и "java".
На уровне Java это два разных String, и distinct() их не склеит. Если в проекте семантика тегов case-insensitive (а у нас именно так), то нормализация должна происходить раньше уникализации. Хороший порядок: normalize → distinct → sort.

Ошибка №4: возвращать 404 Not Found, когда тегов нет.
404 — это “ресурс отсутствует”. Но /api/v1/tags как ресурс существует всегда: просто иногда он возвращает пустой список. Пустой список — успешный и ожидаемый результат, особенно в начале жизни приложения или на “чистом” окружении.

Ошибка №5: хардкодить список тегов в контроллере.
Иногда хочется “сделать быстро”: return List.of("bug", "urgent"). Это ломает идею источника правды и превращает теги в отдельный справочник, который живёт сам по себе и расходится с реальными данными в задачах. Lookup endpoint ценен именно тем, что он отражает существующие задачи, а не фантазии контроллера о том, какие теги “должны быть”.

1
Задача
Spring REST & MVC, 25 уровень, 3 лекция
Недоступна
Lookup endpoint со списком тегов
Lookup endpoint со списком тегов
1
Задача
Spring REST & MVC, 25 уровень, 3 лекция
Недоступна
Нормализация тегов перед lookup-ответом
Нормализация тегов перед lookup-ответом
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ