JavaRush /Курси /Spring REST & MVC /Metadata і content: два способи доступу

Metadata і content: два способи доступу

Spring REST & MVC
Рівень 27 , Лекція 1
Відкрита

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 мають залишатися дисциплінованими й нудними — нудьга тут, як не дивно, ознака якості.

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