1. Attachments как подресурс Task
Если делать attachments «по вдохновению», почти всегда получается API, где клиенту приходится угадывать: то ли это отдельный ресурс, то ли часть задачи, то ли вообще «особая файловая магия». Нам важно собрать attachments как нормальный подресурс Task, чтобы URI, статусы и ответы были предсказуемыми. Это экономит время и вам, и клиентам, и будущим тестам, которые не любят сюрпризы.
Metadata и content мы уже развели, storage у контента тоже появился. Теперь важно собрать из этих кусков нормальный подресурс задачи, чтобы клиент видел не набор случайных endpoint’ов, а цельную attachment-модель.
В Task Tracker API вложения живут в контексте задачи, поэтому базовый путь выглядит так: /api/v1/tasks/{taskId}/attachments. Это сразу сообщает клиенту две вещи. Во‑первых, вложение без задачи не существует (или, по крайней мере, в нашем контракте оно не имеет смысла). Во‑вторых, все not-found сценарии должны учитывать родителя: если задачи нет — это не «пустой список вложений», а честный 404 Not Found для ресурса, от которого зависит подресурс.
И вот здесь проявляется взрослая идея REST: мы не делаем «супер‑endpoint, который умеет всё». Вместо этого мы строим маленький набор согласованных операций, каждая из которых делает одну понятную вещь: список metadata, detail metadata, скачивание content, удаление. И всё это вместе выглядит как единый жизненный цикл подресурса.
Карта endpoint’ов attachments
Прежде чем писать код, полезно один раз зафиксировать «карту местности», чтобы потом не добавить случайно GET /attachments/downloadById (да, такое действительно встречается в природе, и это печально). В нашем проекте attachments — supporting subresource, поэтому набор операций небольшой, но полноценный.
Ниже — компактная таблица контрактов. Она важна не как «зубрёжка», а как проверка согласованности: если вы поменяли один endpoint, не сломали ли вы общую картину.
| Операция | Endpoint | Что возвращаем | Успешный статус | Если taskId не найден | Если attachmentId не найден |
|---|---|---|---|---|---|
| List metadata | GET /api/v1/tasks/{taskId}/attachments | JSON: список AttachmentResponse | 200 OK | 404 Not Found | — |
| Detail metadata | GET /api/v1/tasks/{taskId} /attachments/{attachmentId} | JSON: один AttachmentResponse | 200 OK | 404 Not Found | 404 Not Found |
| Download content | GET /api/v1/tasks/{taskId} /attachments/{attachmentId}/download | бинарное тело (Resource) + headers | 200 OK | 404 Not Found | 404 Not Found |
| Delete attachment | DELETE /api/v1/tasks/{taskId} /attachments/{attachmentId} | пустое тело | 204 No Content | 404 Not Found | 404 Not Found |
Обратите внимание на один очень тонкий момент: GET /tasks/{taskId}/attachments для существующей задачи может вернуть пустой список — и это нормально. Пустая коллекция — не ошибка. Ошибка — это когда не существует родительского ресурса, от которого коллекция зависит. Другими словами, «у задачи пока нет вложений» — это 200 и [], а «задачи нет» — это 404 и ProblemDetail.
2. AttachmentController: контракт и endpoints
Контроллер — это место, где мы описываем HTTP-контракт: пути, методы, статусы и заголовки. Если контроллер начинает знать про Path, Files, директории и «где там на диске лежит папка uploads», он превращается в комбайн, который сложно тестировать и ещё сложнее поддерживать. Здесь полезно сделать его почти скучным. Скучный контроллер — это комплимент.
Начнём с базы: один контроллер на подресурс, один базовый @RequestMapping, и дальше методы, которые аккуратно раскладывают операции. Даже конструктор здесь «для галочки» — чтобы было понятно, что мы работаем через сервис.
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks/{taskId}/attachments")
public class AttachmentController {
// Контроллер не хранит состояние и не знает про файловую систему:
// только принимает HTTP-запрос и делегирует в сервис.
private final AttachmentService attachmentService;
public AttachmentController(AttachmentService attachmentService) {
// Внедряем сервис через конструктор — проще тестировать и явно видно зависимость.
this.attachmentService = attachmentService;
}
}
List: metadata без content
Список вложений — это почти всегда «показать пользователю, что вообще прикреплено». Для этого нужен лёгкий JSON: имя, тип, размер, описание, время загрузки. Сам файл в списке не нужен (и, честно говоря, если вы вернёте файл в списке — у вас будет не список, а маленький DDoS на самого себя).
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping
public List<AttachmentResponse> list(@PathVariable String taskId) {
// Возвращаем только metadata: список должен быть «лёгким», без бинарного содержимого.
return attachmentService.getTaskAttachments(taskId);
}
Если у задачи нет вложений, то клиент увидит []. И это отличный момент: клиенту не нужно писать сложную логику на тему «404 — это пустой список или реально ошибка?». Он видит 200 OK и предсказуемый JSON-массив.
Detail: metadata по attachmentId
Detail endpoint нужен, когда клиент открывает карточку вложения, хочет увидеть описание, размер, контент-тайп и так далее. Тут всё ещё JSON, никакой бинарщины.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{attachmentId}")
public AttachmentResponse getOne(@PathVariable String taskId,
@PathVariable String attachmentId) {
return attachmentService.getAttachment(taskId, attachmentId);
}
Здесь важна дисциплина: если attachmentId не принадлежит taskId, мы возвращаем 404 Not Found, а не «ну мы же нашли attachment по id где-то в системе». Вложение — подресурс, контекст родителя важен.
Download: content endpoint
Внутри общей attachment-картины download остаётся отдельным content-endpoint: metadata и binary response не смешиваются. Контроллеру здесь нужны метаданные для headers и Resource для тела ответа.
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{attachmentId}/download")
public ResponseEntity<Resource> download(@PathVariable String taskId,
@PathVariable String attachmentId) {
// 1) Сначала берём metadata: нужен content-type, длина и оригинальное имя файла.
AttachmentResponse meta = attachmentService.getAttachment(taskId, attachmentId);
// 2) Затем отдельно загружаем бинарный контент (сам файл) из storage.
Resource content = attachmentService.loadAttachmentContent(taskId, attachmentId);
return ResponseEntity.ok()
// Те же два обязательных заголовка: тип контента и подсказка "скачать как файл".
.contentType(resolveMediaType(meta.contentType()))
// contentLength — полезная дополнительная подсказка клиенту, если размер уже известен.
.contentLength(meta.size())
.header(HttpHeaders.CONTENT_DISPOSITION,
buildDisposition(meta.originalFileName()))
.body(content);
}
Сами helper-методы здесь маленькие: один держит fallback на application/octet-stream, второй аккуратно собирает Content-Disposition.
Delete: 204 No Content
Удаление — это write-операция, но успешный ответ не обязан возвращать JSON. Наоборот, хороший REST-стиль здесь простой: удалили — отдали 204 No Content. Никаких "deleted": true, пожалуйста. Пусть API не разговаривает с клиентом в стиле «я всё сделал, честно-честно».
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
@DeleteMapping("/{attachmentId}")
public ResponseEntity<Void> delete(@PathVariable String taskId,
@PathVariable String attachmentId) {
attachmentService.delete(taskId, attachmentId);
return ResponseEntity.noContent().build();
}
И обратите внимание: в контроллере нет ни слова о том, как удаляется физический файл. Контроллеру это неинтересно, как официанту неинтересно, где именно на кухне лежит сковородка. Он должен принять заказ и принести результат.
3. AttachmentService: связка metadata и storage
Сервисный слой в нашем проекте — это место, где живёт прикладная координация: проверить, что задача существует, найти metadata, обратиться к storage, сделать правильный порядок действий при delete. Здесь не должно быть HTTP-деталей (заголовков, статусов), но должно быть «понимание сценария». Это как режиссёр: он не выходит на сцену, но без него спектакль развалится.
Проверка существования задачи как часть подресурсной семантики
Так как attachments — подресурс, почти все операции начинаются одинаково: убедиться, что родитель (taskId) существует. Самый простой путь — иметь метод existsById на task repository (или сервисе задач) и бросать TaskNotFoundException, который уже умеет превращаться в ProblemDetail через ваш глобальный обработчик.
import com.example.tasktracker.domain.exception.TaskNotFoundException;
private void requireTaskExists(String taskId) {
// Подресурс без родителя не имеет смысла: сначала проверяем существование задачи.
if (!taskRepository.existsById(taskId)) {
// Исключение потом превратится в единый ProblemDetail на уровне API.
throw new TaskNotFoundException(taskId);
}
}
Выглядит скучно — и это прекрасно. Чем скучнее проверки, тем меньше шансов однажды отладить «почему у нас attachments отдаются даже для несуществующих задач».
List: существующая задача → список metadata (возможно, пустой)
Теперь сам list. Обратите внимание на нюанс: если задача существует, мы возвращаем список вложений, даже если он пустой. И это важно: API-слой не должен превращать «нет элементов» в ошибку.
import java.util.List;
public List<AttachmentResponse> getTaskAttachments(String taskId) {
// Сначала подтверждаем, что родитель существует — иначе это 404 на задачу.
requireTaskExists(taskId);
// Возвращаем список DTO, скрывая внутреннюю модель/хранилище.
return attachmentRepository.findAllByTaskId(taskId).stream()
.map(attachmentMapper::toResponse)
.toList();
}
Если вы хотите добавить детерминированный порядок (например, по uploadedAt от новых к старым), вы можете вставить .sorted(...). Но даже без этого, в in-memory учебном проекте главное — стабильный контракт и понятная структура.
Detail: существующая задача + существующее вложение → metadata
Detail — та же логика, только мы достаём один элемент и аккуратно бросаем AttachmentNotFoundException, если его нет.
public AttachmentResponse getAttachment(String taskId, String attachmentId) {
requireTaskExists(taskId);
return attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
.map(attachmentMapper::toResponse)
.orElseThrow(() -> new AttachmentNotFoundException(attachmentId));
}
Обратите внимание: мы ищем вложение именно «внутри задачи», а не «в системе вообще». Иначе вы однажды получите ситуацию, когда пользователь попросил /tasks/A/attachments/X, а вы выдали вложение от задачи B. Клиент будет счастлив (временно), вы — нет (долго).
Download: metadata нашли → storage дал Resource → отдаём наверх
Для content у нас есть storage key внутри metadata. Сервису достаточно найти metadata и попросить storage «дай мне ресурс». Контроллер уже решит, какие заголовки поставить.
import org.springframework.core.io.Resource;
public Resource loadAttachmentContent(String taskId, String attachmentId) {
requireTaskExists(taskId);
AttachmentMetadata meta = attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
.orElseThrow(() -> new AttachmentNotFoundException(attachmentId));
return attachmentStorage.loadAsResource(meta.getStorageKey());
}
Delete: удаляем вложение как одну операцию (файл + metadata)
Удаление — самая “координационная” часть. Наша цель: удалить и metadata, и физический файл, воспринимая это как одно действие. Обычно проще сначала удалить файл, а затем metadata. Тогда если удаление файла упало, metadata остаётся, и вы хотя бы можете диагностировать проблему (например, попытаться снова удалить).
public void delete(String taskId, String attachmentId) {
// Нельзя удалять вложение у несуществующей задачи — это не «ничего не произошло», это 404.
requireTaskExists(taskId);
AttachmentMetadata meta = attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
.orElseThrow(() -> new AttachmentNotFoundException(attachmentId));
// Сначала удаляем физический файл: если здесь упадём — metadata останется для диагностики.
attachmentStorage.delete(meta.getStorageKey());
// Потом удаляем metadata из репозитория.
attachmentRepository.delete(taskId, attachmentId);
}
Если storage выбросит исключение, ваш глобальный error handling (который уже существует) должен вернуть корректный ProblemDetail без «абсолютного пути на диске в сообщении». Клиенту не нужно знать, что файл лежал в C:\Users\... или /home/.... Клиенту нужно понять: это техническая ошибка сервера, и он не виноват.
4. In-memory AttachmentRepository для metadata
Поскольку курс сознательно без базы данных, мы храним metadata в памяти. Это нормально и методически правильно, пока мы не начинаем притворяться, что это «почти PostgreSQL». Репозиторий здесь нужен, чтобы удержать границу: сервис не знает, где именно лежит metadata, а контроллер вообще не должен знать, что она хранится в Map.
Минимальный интерфейс репозитория для операций, которые нам нужны в этой лекции, может выглядеть так:
import java.util.List;
import java.util.Optional;
public interface AttachmentRepository {
// Список metadata по задаче: отсутствие вложений — это пустой список, не ошибка.
List<AttachmentMetadata> findAllByTaskId(String taskId);
// Поиск конкретного вложения строго в контексте задачи (taskId + attachmentId).
Optional<AttachmentMetadata> findByTaskIdAndId(String taskId, String attachmentId);
// Удаление metadata (физический файл удаляет storage, не репозиторий).
void delete(String taskId, String attachmentId);
}
Реализация может хранить данные как Map<taskId, Map<attachmentId, AttachmentMetadata>>, потому что это естественно для «подресурсной» модели. Это не единственный вариант, но он очень прозрачен: задача содержит «корзинку» вложений.
Вот маленький фрагмент метода findAllByTaskId, который возвращает список и не падает, если у задачи нет вложений (потому что отсутствие вложений — это не исключение).
import java.util.List;
import java.util.Map;
public List<AttachmentMetadata> findAllByTaskId(String taskId) {
// В in-memory реализации отсутствие ключа = отсутствие вложений, возвращаем пустую коллекцию.
Map<String, AttachmentMetadata> byId = storage.get(taskId);
if (byId == null) {
return List.of(); // пустая коллекция, без драмы
}
// Отдаём копию, чтобы внешний код не мог «случайно» мутировать внутреннее хранилище.
return List.copyOf(byId.values());
}
И ещё один важный момент: этот репозиторий хранит storageKey внутри AttachmentMetadata, но наружу его никто не отдаёт. Маппинг в AttachmentResponse должен этот ключ игнорировать, и лучше всего, если ваш mapper вообще не знает, что это «ключ диска», а воспринимает его как внутреннее поле модели.
5. Семантика ответов: 200, 404, 204
Сильный API отличается от «работающего» тем, что клиенту не приходится угадывать. Для attachments это особенно заметно, потому что у нас есть и коллекция, и элементы, и бинарный download, и delete. Ошибка тут обычно не в коде, а в семантике: человек написал endpoint, он даже «что-то возвращает», но пользоваться этим неудобно.
Давайте зафиксируем несколько «правил здравого смысла», которые делают клиентскую жизнь проще. Для list endpoint’а мы возвращаем 200 OK всегда, когда задача существует. Если вложений нет — это не «ошибка», это «пока пусто». Клиент тогда просто рисует empty state, не лезет в исключения.
Для любого endpoint’а, где в URI присутствует {taskId}, отсутствие задачи — это честный 404 Not Found. И тут важно не пытаться маскировать это в 200 OK с сообщением "task not found". В нашем проекте уже есть global error handling и ProblemDetail, поэтому такое поведение просто становится частью единого контракта ошибок.
Для delete мы используем 204 No Content. Это удобно и для клиента, и для нас: нет тела — нечего парсить. Если клиент хочет убедиться, что вложение исчезло, он может повторно запросить список или metadata, и уже там получить 404.
Вот как выглядит «пустой список вложений» на уровне JSON (ответ list endpoint’а):
[]
И вот пример «удалили успешно» на уровне HTTP: статус есть, тела нет.
HTTP/1.1 204 No Content
Если же вложение или задача не найдены, ваш ProblemDetail-ответ остаётся единым, как и во всём остальном API. Клиент видит одно и то же поле code и умеет обрабатывать это одинаково по всему проекту — и это одна из причин, почему мы вообще заморачивались с единым error contract в модуле 5.
Единый поток: от запроса до файла
Иногда полезно на минуту «вылезти из кода» и посмотреть на сценарий глазами клиента. Он не знает про сервисы и репозитории. Он просто делает запрос и ждёт предсказуемый результат. Если API собран правильно, то клиент может пройти весь жизненный цикл вложения одной логикой: загрузил → увидел в списке → посмотрел metadata → скачал → удалил.
Ниже — очень упрощённая схема download-сценария. Она показывает не все детали Spring MVC, а именно прикладной поток внутри нашего проекта.
sequenceDiagram
participant C as Client
participant AC as AttachmentController
participant AS as AttachmentService
participant AR as AttachmentRepository
participant ST as AttachmentStorage
C->>AC: "GET /tasks/{taskId}/attachments/{attachmentId}/download" %% (8)
AC->>AS: "getAttachment(taskId, attachmentId)" %% (8)
AS->>AR: "findByTaskIdAndId(...)" %% (8)
AR-->>AS: AttachmentMetadata
AS-->>AC: "AttachmentResponse (metadata)"
AC->>AS: "loadAttachmentContent(taskId, attachmentId)" %% (8)
AS->>AR: "findByTaskIdAndId(...)" %% (8)
AR-->>AS: AttachmentMetadata
AS->>ST: "loadAsResource(storageKey)" %% (8)
ST-->>AS: Resource
AS-->>AC: Resource
AC-->>C: "200 OK + headers + binary body" %% (8)
Да, тут видно «два похода в репозиторий». Для in-memory хранилища это вообще не проблема, а по читабельности для учебного проекта — даже плюс: шаги максимально явные. Если однажды захотите оптимизировать, можно сделать сервисный метод, который возвращает и metadata, и resource одним вызовом. Но в учебной версии лучше пусть будет понятно, чем «хитро».
6. Типичные ошибки при подресурсе attachments
Ошибка №1: возвращать 404, когда у задачи нет вложений.
Это очень распространённая путаница «коллекция vs элемент». Отсутствие элементов в коллекции — нормальное состояние, и клиенту проще получить 200 и пустой список, чем каждый раз разбираться, это «ошибка» или «просто пока пусто». 404 уместен, когда нет родительской задачи или конкретного attachment’а.
Ошибка №2: смешивать metadata и content в одном endpoint’е или одном DTO.
Когда один endpoint «иногда отдаёт JSON, а иногда файл», клиенту трудно жить: он должен угадывать по заголовкам, как парсить ответ. А когда вы кладёте byte[] в AttachmentResponse, вы превращаете обычный список вложений в переноску для слона. Разделение на /attachments/{id} и /attachments/{id}/download — простое и надёжное.
Ошибка №3: делать контроллер «файловым менеджером».
Как только в контроллере появляется Paths.get(...), Files.delete(...) и логика «как построить имя файла», вы почти гарантированно потом начнёте копировать это в нескольких местах. Контроллер должен описывать HTTP-контракт и вызывать сервис. Всё, что про хранение, должно жить в storage/infrastructure.
Ошибка №4: отдавать наружу storageKey или путь к файлу.
storageKey — внутренний идентификатор. Клиенту он не нужен, и более того — вреден: он раскрывает устройство вашего storage и может стать случайной зависимостью клиента от вашей внутренней реализации. В публичном API остаются только бизнес‑понятные вещи: originalFileName, contentType, size, uploadedAt.
Ошибка №5: возвращать 200 OK и текст "deleted" после успешного удаления.
Это кажется «дружелюбным», но на практике добавляет шума. Для delete-операций в REST API обычно достаточно 204 No Content. Клиенту проще: не нужно парсить тело, не нужно хранить «формат ответа удаления», и ваш контракт становится проще.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ