1. Metadata і content: два способи роботи
Коли ми вперше додаємо вкладення в API, у голові легко народжується наївна картинка: «Ну є ж файл, отже endpoint має повернути файл». А потім через пів години хочеться ще й список вкладень, потім — екран детального перегляду вкладення (імʼя, розмір, опис), потім — видалення… І раптом виявляється, що «файл» і «інформація про файл» — це два різні сценарії доступу. Це нормально: не ви перші й не ви останні, хто наступає на ці граблі.
Щойно у attachment зʼявляється окремий download endpoint, ця різниця стає особливо помітною: списки й картки працюють із metadata, а завантаження — із самим content.
В одного й того самого вкладення може бути кілька подань. У нашому курсі ми не ускладнюємо все складним узгодженням типу вмісту й не намагаємося «одним URI віддати все на світі», зате робимо чесний і зрозумілий контракт. Тому ми свідомо розділяємо відповіді: один endpoint повертає metadata у JSON, інший endpoint повертає content (бінарне тіло) для завантаження. Ззовні це виглядає як дуже проста ідея, але саме вона зберігає API легким і передбачуваним.
Часті сценарії: metadata vs download
Якщо ви колись користувалися задачником (та хоч би й у корпоративному чаті), ви помічали одну річ: ми значно частіше дивимося список вкладень, ніж реально завантажуємо кожне з них. Список — це швидкий огляд: «що прикріпили?», «як називається?», «якого розміру?», «це PNG чи PDF?», «коли завантажили?». Завантаження — це окрема дія, зазвичай усвідомлена: «Гаразд, мені потрібен саме цей файл».
В API це перетворюється на дуже практичну різницю. Metadata потрібна для відображення інтерфейсу, а інтерфейс зазвичай будується з малих швидких запитів, які можна безболісно повторювати. Якщо в «перегляд metadata» ви випадково підтягнете вміст файла, то будь-який список вкладень стане важкою операцією: замість 20 рядків JSON ви почнете передавати десятки мегабайт. І ось ви вже зробили «застосунок для задач», який відчувається як «застосунок для тестування мережі й терпіння».
Звідси народжується базова архітектурна думка: metadata endpoint має бути легким. Він має бути безпечним для частого використання, для списків і для детального перегляду. А завантаження файла — це окремий сценарій, який варто винести в окремий endpoint. Тоді клієнт може спочатку отримати metadata, показати користувачеві кнопку «Download», і лише за реального бажання користувача завантажити файл — перейти до бінарного вмісту.
2. Файл у DTO: поганий компроміс
Дуже хочеться зробити «універсальну» відповідь виду AttachmentResponse, де є і імʼя файла, і розмір, і… byte[] content. Здається: «Один запит — і в клієнта одразу все». На практиці це не «один запит», а «один дуже проблемний запит».
По-перше, JSON не вміє передавати бінарні дані напряму. Коли ви кладете byte[] у response DTO, Jackson серіалізує це як Base64-рядок. Тобто ви не просто передаєте байти, а ще й перетворюєте їх на текст, який стає більшим приблизно на третину. Для маленьких файлів це ще терпимо, але щойно файл стає «звичайним офісним», ваш API перетворюється на фабрику гігантських рядків.
По-друге, ви починаєте втрачати саму ідею download endpoint як коректної HTTP-відповіді. Замість Content-Type: application/pdf і нормального Content-Disposition ви віддаєте application/json, усередині якого лежить base64. Деякі клієнти зможуть це обробити, але це вже зовсім інша модель, і вона значно менш дружня до світу: до браузера, до curl, до Postman, до сценарію «завантажити й відкрити подвійним клацанням».
По-третє, ви провокуєте проблеми з памʼяттю. Коли ви віддаєте byte[], ви фактично змушуєте сервер повністю матеріалізувати файл у памʼяті, а потім ще й матеріалізувати base64-рядок, який часто ще більший. Це не «складна інфраструктура», а просто погана звичка, яка «в навчальному проєкті спрацьовує», а потім раптово перестає працювати, коли до вашого API приходить перший реальний користувач із реальним файлом.
Погляньмо на антиприклад — як це виглядає в DTO:
package com.example.tasktracker.api.dto.response;
// ❌ Антиприклад: у DTO для JSON кладемо бінарний вміст файла.
// У результаті він піде назовні як Base64-рядок і зробить відповідь важкою.
public record AttachmentWithContentResponse(
String id,
String originalFileName,
byte[] content // ❌ буде base64 у JSON і буде важко
) {}
Проблема тут не в record і не в тому, що Java погана. Проблема в тому, що ми змішали два різні світи: «metadata як JSON» і «content як файл для завантаження». І отримали гібрид, який незручний одразу всім.
Правильна публічна відповідь для metadata виглядає інакше: це інформація про файл, але не сам файл.
package com.example.tasktracker.api.dto.response;
import java.time.Instant;
// ✅ DTO для metadata: лише те, що потрібно UI/клієнту для списку й картки вкладення.
// Жодних байтів файла всередині.
public record AttachmentResponse(
String id, // публічний id вкладення
String taskId, // належність до задачі (контекст підресурсу)
String originalFileName, // імʼя, яке бачив користувач під час завантаження
String contentType, // MIME-тип (наприклад, application/pdf)
long size, // розмір файла в байтах
String description, // опис/коментар до вкладення
Instant uploadedAt // коли завантажили
) {}
Такий DTO легкий, його зручно повертати в списку, його приємно читати людині, а клієнт може будувати UI без того, щоб щоразу платити мережевою ціною «ніби завантажив файл».
3. Один endpoint для JSON і файла: плутанина
Після усвідомлення «байти в JSON — погано» зʼявляється друга хитра думка: «Гаразд, тоді нехай буде один endpoint GET /attachments/{id}, але якщо клієнт хоче завантажити файл — він надішле якийсь параметр або заголовок, і сервер відповість файлом. А якщо не хоче — сервер поверне JSON». Технічно так можна. Але в навчальному проєкті, та й часто в комерційному також, це створює більше плутанини, ніж користі.
Можна намагатися робити це через query parameter на кшталт ?download=true. Можна намагатися робити це через заголовок Accept, де клієнт каже Accept: application/json або Accept: application/octet-stream. Але тоді ваш контракт стає неочевидним: один і той самий URL починає поводитися по-різному, і клієнту потрібно памʼятати «таємне заклинання», щоб отримати потрібну відповідь.
У практичній розробці це призводить до двох типів болю. По-перше, люди починають помилятися: забувають параметр, отримують JSON замість файла, починають парсити файл як JSON і дивуються, чому «зламався парсер». По-друге, зʼявляються напівпрацюючі клієнти: один клієнт завантажує, інший отримує metadata, а третій узагалі ламається, тому що в нього десь на рівні HTTP-клієнта автоматично проставляється Accept: application/json.
У нашому курсі ми хочемо API, який читається очима й майже не вимагає вгадування. Тому ми обираємо просту й прозору модель: metadata endpoint і окремий download endpoint. Так, це ще один URI. Зате це мінус десять запитань на кшталт «чому воно так дивно працює».
Невелика таблиця, щоб зафіксувати різницю в одному місці:
| Що ми хочемо отримати | Endpoint | Content-Type відповіді | Тіло відповіді | Навіщо це потрібно |
|---|---|---|---|---|
| Метадані вкладення | GET /api/v1/tasks/{taskId} /attachments/{attachmentId} | application/json | JSON DTO | Показати імʼя, розмір, опис, дату завантаження |
| Завантажити файл | GET /api/v1/tasks/{taskId} /attachments/{attachmentId}/download | application/octet-stream | бінарне тіло (Resource) | Реально отримати вміст файла |
Ця схема проста й самодокументована: навіть не знаючи проєкту, ви за URI розумієте, де metadata, а де контент.
4. storageKey: внутрішня деталь
Тепер давайте поговоримо про ще одну спокусу: «Ну раз у нас є якийсь шлях до файла, давайте просто повернемо його клієнту». Наприклад: «Ось metadata, а ось storageKey або path, за ним завантажите». Здається, що це зручно. Насправді це майже гарантовано перетворюється на витік інфраструктури назовні.
storageKey — це внутрішня річ. Його призначення — допомогти серверу знайти фізичний файл у сховищі. Клієнту не потрібно знати, як саме ви зберігаєте файл: у папці, у підпапці, за UUID, за хешем, за датою, у трьох різних каталогах… Клієнту це нецікаво. Клієнту цікаві дві речі: «що це за файл?» і «як його завантажити або видалити через API?».
Тому в нашій внутрішній моделі storageKey може бути, але в public DTO — ні. Для ілюстрації покажімо, як може виглядати внутрішня модель metadata. Нам тут важлива сама ідея: поле є, але назовні не виходить.
package com.example.tasktracker.domain.model;
import java.time.Instant;
// Внутрішня модель: зберігає все, що потрібно серверу для роботи з файлом.
public class AttachmentMetadata {
private final String id;
private final String taskId;
// ✅ Лише для сервера: за цим ключем ми знайдемо файл у сховищі.
// ❌ У public DTO це поле не має потрапляти.
private final String storageKey;
private final String originalFileName;
private final String contentType;
private final long size;
private final String description;
private final Instant uploadedAt;
public AttachmentMetadata(String id, String taskId, String storageKey,
String originalFileName, String contentType,
long size, String description, Instant uploadedAt) {
this.id = id;
this.taskId = taskId;
this.storageKey = storageKey;
this.originalFileName = originalFileName;
this.contentType = contentType;
this.size = size;
this.description = description;
this.uploadedAt = uploadedAt;
}
public String getStorageKey() { return storageKey; }
// Інші гетери опущено задля стислості прикладу:
// getId(), getTaskId(), getOriginalFileName(), getContentType(),
// getSize(), getDescription(), getUploadedAt(), ...
}
А тепер ключовий момент: навіть якщо storageKey є всередині, mapper не має його «протягувати» в DTO. Це той самий момент, де DTO справді захищають зовнішній контракт: вони не дають вам випадково відкрити клієнту двері в підсобку, де лежать ваші швабри, дроти й магічні кнопки.
Міні-маппер для відповіді — сильно спрощений, щоб не розповзатися по коду:
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.response.AttachmentResponse;
import com.example.tasktracker.domain.model.AttachmentMetadata;
public final class AttachmentMapper {
private AttachmentMapper() {
// Утилітний клас: не створюємо екземплярів.
}
public static AttachmentResponse toResponse(AttachmentMetadata m) {
// Важливо: storageKey тут навмисно НЕ бере участі в мапінгу.
// Клієнт має бачити лише публічний контракт, а не деталі зберігання.
return new AttachmentResponse(
m.getId(),
m.getTaskId(),
m.getOriginalFileName(),
m.getContentType(),
m.getSize(),
m.getDescription(),
m.getUploadedAt()
);
}
}
Так, у вашій моделі, найімовірніше, більше полів і гетерів — це нормально. Важливо інше: storageKey залишається всередині. Клієнт ніколи не має бачити, як сервер влаштований зсередини. Клієнт має бачити контракт.
5. Два endpoint’и для одного attachmentId
Тепер зберемо все в одну картину саме на рівні API. Для клієнта вкладення — це підресурс задачі. Тобто «вкладення» завжди живе в контексті «задачі», і URI відображає цю ієрархію.
Metadata endpoint:
import com.example.tasktracker.api.dto.response.AttachmentResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
// ✅ Endpoint для metadata: повертаємо JSON DTO (легка відповідь для UI).
@GetMapping("/api/v1/tasks/{taskId}/attachments/{attachmentId}")
public AttachmentResponse getAttachment(@PathVariable String taskId,
@PathVariable String attachmentId) {
// Тут ми НЕ завантажуємо файл і НЕ повертаємо байти — лише metadata.
return attachmentService.getAttachment(taskId, attachmentId);
}
Download endpoint:
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
// ✅ Endpoint для завантаження: повертаємо бінарний контент (Resource).
@GetMapping("/api/v1/tasks/{taskId}/attachments/{attachmentId}/download")
public ResponseEntity<Resource> download(@PathVariable String taskId,
@PathVariable String attachmentId) {
// Успішний сценарій: бінарне тіло + потрібні заголовки (Content-Type/Disposition).
// Негативний сценарій: може повернутися ProblemDetail у JSON (помилка контракту).
return attachmentService.download(taskId, attachmentId);
}
Зверніть увагу на важливу деталь: один і той самий attachmentId повʼязує metadata і download. Для клієнта це «один ресурс», просто два способи його читати. Клієнт може спочатку показати metadata, а потім, по кліку користувача, перейти на /download.
Щоб цей звʼязок відчувався ще простіше, корисно уявити потік як невелику схему. Вона дуже приземлена: ми не обговорюємо тут деталі зберігання, лише межі відповідальності.
flowchart TD
Client["Клієнт"] -->|GET метадані| MetaEndpoint["/attachments/{id} (JSON)"]
Client -->|GET завантаження| DownloadEndpoint["/attachments/{id}/download (бінарне тіло)"]
MetaEndpoint --> Service["AttachmentService"]
DownloadEndpoint --> Service
Service --> Repo["AttachmentRepository (metadata)"]
Service --> Storage["Сховище (content)"]
Repo --> Service
Storage --> Service
З погляду проєктування контракту тут добре те, що endpoints за призначенням очевидні. JSON endpoint повертає JSON. Download endpoint повертає файл. І ніхто не намагається вгадувати, у якому режимі перебуває сервер.
6. Контракт помилок для metadata і download
Ми вже побудували єдиний error contract через ProblemDetail, і нам важливо не зруйнувати його файловими сценаріями. Розділення metadata і content у цьому сенсі теж допомагає, тому що кожен endpoint залишається однотипним за формою відповіді.
Metadata endpoint у успішному сценарії повертає JSON, а в негативному сценарії повертає application/problem+json. Це звичний патерн для будь-якого нашого JSON endpointʼа.
Download endpoint в успішному сценарії повертає бінарне тіло. Але в негативному сценарії він теж може повернути ProblemDetail як JSON — це нормально: якщо вкладення не знайдено або задача не знайдена, ви не можете завантажити те, чого немає. І замість «напівфайла» або порожньої відповіді ви віддаєте зрозумілу помилку в тому самому форматі, що й решта API.
Якщо ж ви робите один endpoint, який «іноді JSON, іноді файл», то в негативних сценаріях стає складно пояснити, якої саме відповіді чекати. Клієнт починає жити у світі if/else: «якщо статус 200 — це файл, якщо 404 — це JSON, якщо 415 — це теж JSON, але я очікував файл…» І чим більше таких умов, тим швидше клієнт перетворюється на комбайн для обробки винятків, а не на нормальний застосунок.
Розділення робить це спокійнішим: клієнт знає, що /download — це «файл, якщо все гаразд», і «ProblemDetail, якщо ні». А /attachments/{id} — це «metadata, якщо все гаразд», і «ProblemDetail, якщо ні». Це дві прості моделі, з якими і клієнту, і тестам жити помітно легше.
7. Типові помилки під час розділення metadata і content
Помилка №1: спроба зробити «універсальний DTO», у який додають byte[] content.
Зазвичай це починається невинно: «ну нехай буде поле, раптом знадобиться». Потім виявляється, що цей DTO використовують і для списку, і для детального перегляду, і раптом кожен список вкладень починає тягнути за собою потенційно величезні base64-рядки. Навіть якщо ви «за замовчуванням content не заповнюєте», ви все одно тримаєте в моделі ідею, яка рано чи пізно вистрілить. Краще одразу закріпити дисципліну: metadata DTO не містить бінарних даних.
Помилка №2: витік storageKey або шляху до файла назовні.
Внутрішній ключ зберігання — це інфраструктурна деталь. Щойно ви повернули його клієнту, ви зацементували спосіб зберігання як частину контракту. Гірше того, ви показали клієнту те, що йому не потрібно знати, а іноді це ще й виглядає як «посилання на ваш диск». Тримайте такі поля строго у внутрішній моделі, а назовні віддавайте лише attachmentId і правильні endpoint’и.
Помилка №3: змішування metadata і download в одному endpointʼі «за настроєм».
Коли один і той самий URL починає поводитися по-різному — залежно від query param або Accept — зростає ризик помилок, а контракт стає «із секретами». Для навчального й практичного API краще два явні endpoint’и. Ви не втратите функціональність, але сильно виграєте в передбачуваності.
Помилка №4: повертати metadata з /download і вважати, що «це теж нормально».
Іноді роблять так: «якщо файла немає, повернемо metadata, щоб клієнт хоч щось отримав». Це погана ідея: клієнт прийшов за бінарним вмістом, а отримав JSON. Навіть якщо це зручно, це ламає очікування і змушує клієнта писати зайву захисну логіку. Краще або повернути файл, або чесно повернути ProblemDetail.
Помилка №5: зберігати в metadata DTO занадто багато зайвого.
Metadata — це не звалище всієї інформації, яка є в системі. Це те, що потрібно клієнту для сценаріїв списку й перегляду вкладення. Якщо ви почнете додавати туди внутрішні поля, debug-дані, технічні прапорці, у вас знову зʼявиться ризик «випадкового public контракту» та зростання звʼязаності. DTO мають залишатися дисциплінованими й нудними — нудьга тут, як не дивно, ознака якості.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ