JavaRush /Курсы /Spring REST & MVC /Upload attachments: контракт API

Upload attachments: контракт API

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

1. Контракт upload: собираем вручную

Теперь, когда MultipartFile, Part и @RequestPart уже не выглядят шаманством, пора зафиксировать канонический upload-контракт проекта. Multipart-модель у нас уже есть: один запрос, отдельные части file и metadata, binding по именам частей. Здесь важен следующий шаг: превратить эту механику в конкретный endpoint Task Tracker API, у которого понятны URI, состав запроса и ответ.

Путь и метод: /tasks/{taskId}/attachments + POST

Сначала кажется, что URI для файла — это что-то отдельное и особенное. Но в REST-мышлении файл — это просто часть предметной области, то есть ресурс. В нашем проекте файл не существует «в вакууме»: он всегда прикрепляется к конкретной задаче. Значит, это подресурс (supporting subresource) задачи, а не независимая сущность верхнего уровня.

Поэтому путь остаётся ресурсным и читаемым: мы «заходим» в конкретную задачу по taskId, а затем работаем с коллекцией её вложений. Коллекция — это /attachments. Добавление нового элемента в коллекцию в REST обычно выражается как POST в эту коллекцию. То есть «создать новое вложение у задачи» = POST /tasks/{taskId}/attachments.

И это не просто эстетика. Это даёт клиенту ясную модель: «если я хочу работать с вложениями конкретной задачи — я всегда начинаю с /tasks/{taskId}». Такой контракт проще документировать, проще тестировать и банально проще объяснять (в том числе себе прошлому, который писал код в пятницу вечером).

Небольшая схема, чтобы «увидеть глазами» ресурсную модель:

flowchart LR
  A["Task (taskId)"] --> B["Attachments collection"]
  B --> C["Attachment metadata (attachmentId)"]

  A ---|URI| AURI["/api/v1/tasks/{taskId}"]
  B ---|URI| BURI["/api/v1/tasks/{taskId}/attachments"]
  C ---|URI| CURI["/api/v1/tasks/{taskId}/attachments/{attachmentId}"]

Сегодня мы реализуем именно создание: POST в BURI.

2. Части multipart: file и metadata

Для этого endpoint’а сохраняем две части, и обе уже знакомы. file несёт бинарное содержимое файла. metadata несёт JSON с тем, чем реально управляет клиент. Базовую multipart-логику мы уже зафиксировали; здесь важно привязать её к конкретной операции «создать attachment у задачи».

Такая пара частей удобна потому, что роли у них разные. У file есть размер, имя и Content-Type; у metadata — структура и validation. Поэтому metadata должна приходить как JSON-часть с Content-Type: application/json, чтобы Spring сразу связал её с DTO, а не заставлял нас парсить строки руками.

С точки зрения клиента запрос выглядит как «один конверт, внутри два вложения»:

- вложение №1: metadata (JSON),
- вложение №2: file (бинарное содержимое + атрибуты).

И оба эти вложения вместе образуют одну операцию: «создать attachment». Имена file и metadata здесь уже не пример, а фиксированная часть публичного API.

3. DTO metadata: AttachmentUploadMetadataRequest

В upload-сценарии особенно легко случайно нарушить дисциплину DTO. Например, хочется сказать: «Ну раз мы всё равно делаем метаданные, давайте туда положим originalFileName, size, contentType…». И вот тут нужно сделать паузу и вспомнить основу курса: request DTO должен содержать только то, чем реально управляет клиент.

originalFileName, size, contentType — это не «то, что клиент вводит». Это свойства файла, которые сервер может получить из file part. Если мы позволим клиенту прислать их в metadata, мы создадим ложную свободу: клиент сможет написать, что файл весит 1 байт, а реально прислать 20 МБ. Хорошо ли это? Спойлер: нет.

Поэтому наш request DTO будет минимальным. По ТЗ проекта у нас есть поле description с ограничением по длине.

import jakarta.validation.constraints.Size;

public record AttachmentUploadMetadataRequest(
        // Описание вложения, вводимое пользователем (может быть null)
        @Size(max = 255) String description
) {
}

Обратите внимание на две мелочи, которые дают взрослый результат. Во-первых, description может быть null, потому что описание необязательное. Во-вторых, мы сразу ставим constraint — это часть контракта, и Spring сможет валидировать JSON-часть в multipart через @Valid (сейчас мы это подключим в контроллере).

Полезно держать в голове небольшую «границу полей» для upload:

Поле Откуда берётся Почему так
description из metadata (JSON) клиент реально управляет этим
originalFileName из file (MultipartFile) это атрибут загружаемого файла
contentType из file (MultipartFile) это технический факт о части запроса
size из file (MultipartFile) сервер сам видит размер
uploadedAt на сервере server-managed время
id на сервере server-managed идентификатор

Если вы когда-нибудь ловили баг «клиент подменил id», то вы уже знаете, почему эта таблица — не занудство, а страховка от приключений.

4. Команда upload: AttachmentUploadCommand

Для внешнего контракта этого endpoint’а достаточно одной внутренней мысли: контроллер не должен протаскивать MultipartFile в сервис как есть. Ему удобнее собрать обычный Java-объект с данными операции и делегировать дальше.

Здесь нам не нужна полная архитектурная раскладка. Достаточно увидеть форму входа, с которой сервису уже удобно работать:

public record AttachmentUploadCommand(
        // Идентификатор задачи, к которой прикрепляем файл
        String taskId,
        // Атрибуты файла (берём из MultipartFile)
        String originalFileName,
        String contentType,
        long size,
        // Содержимое файла (в учебном проекте читаем целиком в память)
        byte[] content,
        // Описание из metadata-части
        String description
) {
}

Этого уже достаточно, чтобы не смешивать HTTP-детали с самой операцией upload.

5. Контроллер: @RequestPart + consumes=multipart/form-data

Контроллерный метод для upload должен быть максимально читабельным, потому что это часть публичного контракта. Если метод выглядит как заклинание на древнем языке, клиенту от этого легче не станет (а вам станет сложнее). Поэтому мы делаем метод коротким и предсказуемым: путь, taskId, две части file и metadata, делегирование сервису.

Начнём с основы: сам endpoint и его сигнатура.

import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

@PostMapping(
        path = "/api/v1/tasks/{taskId}/attachments",
        consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
public void upload(
        @PathVariable String taskId,
        @RequestPart("file") MultipartFile file, // файл как отдельная часть multipart
        @Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // JSON-часть с метаданными
) {
    // дальше соберём команду и делегируем
}

Сразу видно всё важное: где лежит taskId, как называются части запроса, какая часть валидируется как DTO. И обратите внимание: мы используем именно @RequestPart, а не @RequestBody. Потому что @RequestBody работает для одного тела, а у нас частей несколько.

Теперь добавим делегирование в сервис и корректный ответ 201 Created. Чтобы это было полезно клиенту, обычно хорошо вернуть metadata созданного вложения и добавить Location header. Для этого нам нужен response DTO (без бинарного содержимого).

6. Response DTO: только metadata

В upload-сценарии легко сделать странный ответ: например, вернуть файл обратно в ответе, потому что «ну раз уж он есть». Но это обычно не нужно и быстро превращает API в тяжёлую и неудобную штуку. Клиенту чаще нужно получить «что создали» — то есть метаданные и идентификатор вложения.

Поэтому мы вводим AttachmentResponse как response DTO. Он будет содержать информацию о созданном вложении.

import java.time.Instant;

public record AttachmentResponse(
        String id,
        String taskId,
        String originalFileName,
        String contentType,
        long size,
        String description,
        Instant uploadedAt
) {
}

Эта модель хорошо ложится на здравый смысл. Клиент сразу знает attachmentId и может дальше работать с вложением (например, отобразить его в UI списком вложений). А бинарное содержимое файла — это другая история и другой endpoint, который мы сегодня не трогаем.

Чтобы не смешивать слои, сервис будет возвращать внутреннюю модель (например, AttachmentMetadata), а контроллер будет делать маппинг в AttachmentResponse.

7. Полный поток: command → service → 201 Created

Снаружи успешный upload выглядит очень просто: мы создаём новое attachment у конкретной задачи. Значит, клиент должен увидеть 201 Created, Location на созданный ресурс и metadata-ответ, с которым можно жить дальше в UI и других вызовах. Бинарное содержимое в ответе нам здесь не нужно.

Внутри этого достаточно минимальной схемы: контроллер собирает AttachmentUploadCommand, сервис выполняет операцию и возвращает внутреннюю metadata-модель, а контроллер маппит её в AttachmentResponse. Под AttachmentMetadata здесь достаточно понимать внутреннюю metadata-модель вложения — не публичный response DTO.

import java.io.IOException;
import java.net.URI;

import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@PostMapping(
        path = "/api/v1/tasks/{taskId}/attachments",
        consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
        produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<AttachmentResponse> upload(
        @PathVariable String taskId,
        @RequestPart("file") MultipartFile file,
        @Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata
) throws IOException {
    AttachmentUploadCommand cmd = new AttachmentUploadCommand(
            taskId,
            file.getOriginalFilename(),
            file.getContentType(),
            file.getSize(),
            file.getBytes(),
            metadata.description()
    );

    // Сервис возвращает внутреннюю metadata-модель созданного вложения
    AttachmentMetadata created = attachmentService.upload(cmd);
    AttachmentResponse response = attachmentMapper.toResponse(created);

    URI location = URI.create("/api/v1/tasks/" + taskId + "/attachments/" + created.id());
    return ResponseEntity.created(location).body(response);
}

Здесь важна именно семантика ответа: ResponseEntity.created(location) фиксирует 201 Created и Location, а тело ответа остаётся JSON-метаданными созданного вложения.

8. Пример multipart-запроса для .http

Когда endpoint готов, следующий вопрос студента обычно звучит так: «Окей, а как это вообще отправлять?». И это честный вопрос. Multipart не так визуально прост, как JSON. Поэтому полезно иметь пример ручного запроса, который вы можете держать в requests.http.

Вот пример в стиле IntelliJ HTTP Client (с явными частями metadata и file). Да, он выглядит «многословно», но в этом и смысл: вы видите контракт глазами.

POST http://localhost:8080/api/v1/tasks/11111111-1111-1111-1111-111111111111/attachments
Content-Type: multipart/form-data; boundary=BOUNDARY

--BOUNDARY
Content-Disposition: form-data; name="metadata"
Content-Type: application/json

{
  "description": "Логи (да, я тоже не люблю читать логи, но что поделать)"
}
--BOUNDARY
Content-Disposition: form-data; name="file"; filename="notes.txt"
Content-Type: text/plain

< ./sample-files/notes.txt
--BOUNDARY--

Здесь есть две вещи, за которые чаще всего «спотыкаются». Первая — имя части: оно должно совпасть с @RequestPart("metadata") и @RequestPart("file"). Вторая — Content-Type для части metadata. Если вы отправите метаданные как text/plain, Spring не сможет автоматически связать их с DTO, и вы получите ошибку binding/conversion.

9. Типичные ошибки при загрузке вложений

Ошибка №1: делать path глагольным, например /api/v1/uploadAttachmentToTask.
Это выглядит как «команда», а не как работа с ресурсом. Да, это «работает», но ломает читаемость и REST-модель. В нашем проекте вложения — подресурс задачи, поэтому корректный и устойчивый путь — /api/v1/tasks/{taskId}/attachments, а операция создания — обычный POST в коллекцию.

Ошибка №2: складывать в AttachmentUploadMetadataRequest поля size, contentType, originalFileName.
Это частая попытка «сделать DTO богаче», но она делает контракт слабее. Клиент может прислать одно, а файл окажется другим. Эти поля сервер обязан определять из file part, иначе вы сами себе организуете несогласованность данных и ещё будете её героически чинить.

Ошибка №3: принимать JSON-метаданные как String и вручную парсить их через ObjectMapper прямо в контроллере.
Иногда так делают «потому что не получилось иначе», но чаще это просто непонимание, что @RequestPart("metadata") умеет связывать JSON-часть с DTO через Jackson. Ручной парсинг быстро разрастается, усложняет обработку ошибок и превращает контроллер в mini-framework.

Ошибка №4: протаскивать MultipartFile в сервисный слой.
Сервис, который принимает MultipartFile, становится зависим от Spring MVC. В результате бизнес-логика хуже тестируется и хуже переиспользуется. Гораздо здоровее, когда контроллер превращает multipart вход в обычный Java-объект (команду), а сервис работает с ней как с прикладными данными.

Ошибка №5: возвращать 200 OK просто потому что «я всегда так делаю».
Создание нового вложения — это создание нового ресурса, значит базовый корректный ответ — 201 Created. А если вы ещё и возвращаете Location, клиенту проще жить: он сразу понимает, где лежит созданное вложение в API.

1
Задача
Spring REST & MVC, 26 уровень, 2 лекция
Недоступна
Создание вложения с `201 Created` и `Location`
Создание вложения с `201 Created` и `Location`
1
Задача
Spring REST & MVC, 26 уровень, 2 лекция
Недоступна
Команда загрузки и metadata-ответ
Команда загрузки и metadata-ответ
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ