CodeGym /Kursy /Spring REST & MVC /Metadata i content: dwa odczyty

Metadata i content: dwa odczyty

Spring REST & MVC
Poziom 27, Lekcja 1
Dostępny

1. Metadata i content: dwa odczyty

Gdy po raz pierwszy dodajemy załączniki do API, w głowie łatwo rodzi się naiwny obraz: „No jest plik, więc endpoint powinien zwrócić plik". A potem po pół godzinie chce się jeszcze listy załączników, potem – ekranu „szczegóły" załącznika (nazwa, rozmiar, opis), potem – usuwania… i nagle okazuje się, że „plik" i „informacja o pliku" to dwa różne scenariusze odczytu. I to normalne: nie jesteś pierwszy ani ostatni, kto na to wpada.

Gdy tylko attachment zyskuje oddzielny download endpoint, ta różnica staje się szczególnie widoczna: listy i karty pracują z metadata, a pobieranie – z samym contentem.

Jeden i ten sam attachment może mieć kilka reprezentacji. W naszym kursie nie robimy złożonego cyrku z content negotiation i nie próbujemy „jednym URI oddawać wszystkiego co się rusza", za to robimy uczciwy i zrozumiały kontrakt. Dlatego świadomie rozdzielamy odpowiedzi: jeden endpoint zwraca metadata w JSON, inny endpoint zwraca content (binarne ciało) do pobrania. Na zewnątrz to wygląda jak bardzo prosta idea, ale właśnie ona sprawia, że API jest „lekkie" i przewidywalne.

Częste scenariusze: metadata vs download

Jeśli kiedykolwiek korzystałeś z task-trackera (choćby w firmowym czacie), zauważałeś jedną rzecz: znacznie częściej patrzymy na listę załączników, niż faktycznie pobieramy każdy z nich. Lista – to szybki przegląd: „co jest dołączone?", „jak się nazywa?", „jakiego rozmiaru?", „to PNG czy PDF?", „kiedy załadowano?". Pobieranie – to oddzielna, zwykle świadoma akcja: „Okej, potrzebuję właśnie tego pliku".

W API zamienia się to w bardzo praktyczną różnicę. Metadata jest potrzebna do wyświetlania interfejsu, a interfejs zazwyczaj buduje się z małych, szybkich żądań, które można powtarzać bez bólu. Jeśli w „przeglądanie metadata" przypadkowo wciągniesz zawartość pliku, to każda lista załączników stanie się ciężką operacją: zamiast 20 linii JSON zaczniesz ganiać dziesiątki megabajtów. I oto zrobiłeś „aplikację do zadań", która zachowuje się jak „aplikacja do testowania sieci i cierpliwości".

Stąd rodzi się podstawowa myśl architektoniczna: metadata endpoint powinien być lekki. Powinien być bezpieczny do częstego używania, do list i do detail-view. A pobieranie pliku – to oddzielny scenariusz, który dobrze wydzielić w oddzielny endpoint. Wtedy klient może najpierw pobrać metadata, pokazać użytkownikowi przycisk „Download", i dopiero przy realnej chęci pobrania przez użytkownika – iść po binarną zawartość.

2. Plik w DTO: zły kompromis

Bardzo chce się zrobić „uniwersalną" odpowiedź w stylu AttachmentResponse, gdzie jest i nazwa pliku, i rozmiar, i… byte[] content. Wydaje się: „Jedno żądanie – i klient od razu ma wszystko". W praktyce to nie jest „jedno żądanie", a „jedno bardzo problematyczne żądanie".

Po pierwsze, JSON nie umie przekazywać danych binarnych bezpośrednio. Gdy kładziesz byte[] w response DTO, Jackson serializuje to jako ciąg Base64. Czyli nie tylko przekazujesz bajty, ale przekazujesz bajty zamienione w tekst, który staje się większy o mniej więcej jedną trzecią. Dla małych plików jest to jeszcze znośne, ale gdy plik staje się „normalnym biurowym", twoje API zamienia się w fabrykę gigantycznych ciągów.

Po drugie, tracisz samą ideę download endpointu jako poprawnej odpowiedzi HTTP. Zamiast Content-Type: application/pdf i normalnego Content-Disposition, oddajesz application/json, wewnątrz którego leży base64. Niektórzy klienci to obsłużą, ale to już zupełnie inna model, znacznie mniej przyjazna dla świata: dla przeglądarki, dla curl, dla Postmana, dla „pobierz i otwórz dwuklikiem".

Po trzecie, prowokujesz problemy z pamięcią. Gdy oddajesz byte[], faktycznie zmuszasz serwer do pełnej materializacji pliku w pamięci, a potem jeszcze materializacji ciągu base64 (który często jest jeszcze większy). To nie jest „złożona infrastruktura", to po prostu zły nawyk, który „w projekcie edukacyjnym ujdzie", a potem nagle przestaje ujść, gdy do twojego API przyjdzie pierwszy prawdziwy użytkownik z prawdziwym plikiem.

Spójrzmy na anty-przykład – jak to wygląda w DTO:

package com.example.tasktracker.api.dto.response;

// Anty-przykład: do DTO dla JSON wkładamy binarną zawartość pliku.
// W efekcie wyjedzie na zewnątrz jako ciąg Base64 i sprawi, że odpowiedź będzie ciężka.
public record AttachmentWithContentResponse(
        String id,
        String originalFileName,
        byte[] content // zostanie base64 w JSON i będzie ciężko
) {}

Problem tu nie w record i nie w tym, że Java jest zła. Problem polega na tym, że pomieszaliśmy dwa różne światy: „metadata jako JSON" i „content jako pobieralny plik". I dostaliśmy hybrydę, która jest niewygodna dla wszystkich.

Właściwy „public response" dla metadata wygląda inaczej: to informacja o pliku, ale nie sam plik.

package com.example.tasktracker.api.dto.response;

import java.time.Instant;

// DTO dla metadata: tylko to, czego potrzebuje UI/klient do listy i karty załącznika.
// Żadnych bajtów pliku wewnątrz.
public record AttachmentResponse(
        String id,                 // publiczny id załącznika
        String taskId,             // przynależność do zadania (kontekst pod-zasobu)
        String originalFileName,   // nazwa, którą widział użytkownik przy uploaderze
        String contentType,        // MIME-typ (np. application/pdf)
        long size,                 // rozmiar pliku w bajtach
        String description,        // opis/komentarz do załącznika
        Instant uploadedAt         // kiedy załadowano
) {}

Takie DTO jest lekkie, przyjemnie zwracać je listą, przyjemnie czytać człowiekowi, i klient może budować UI bez płacenia za każdym razem sieciowej ceny „jakby pobrał plik".

3. Jeden endpoint dla JSON i pliku: zamieszanie

Po uświadomieniu sobie „bajty w JSON – złe" pojawia się kolejna sprytna myśl: „Okej, to niech będzie jeden endpoint GET /attachments/{id}, ale jeśli klient chce pobrać plik – prześle jakiś parametr lub nagłówek, i serwer odpowie plikiem. A jeśli nie – serwer zwróci JSON". Technicznie tak można. Ale w projekcie edukacyjnym (i często komercyjnym też) tworzy to więcej zamieszania niż pożytku.

Można próbować robić to przez query parameter w stylu ?download=true. Można próbować przez nagłówek Accept, gdzie klient mówi Accept: application/json lub Accept: application/octet-stream. Ale wtedy twój kontrakt staje się nieoczywisty: jeden i ten sam URL zaczyna zachowywać się różnie, i klient musi pamiętać „tajne zaklęcie", żeby dostać właściwą odpowiedź.

W praktyce deweloperskiej prowadzi to do dwóch rodzajów bólu. Po pierwsze, ludzie zaczynają się mylić: zapominają parametr, dostają JSON zamiast pliku, zaczynają parsować plik jako JSON i dziwią się, czemu „parser się zepsuł". Po drugie, zaczynają pojawiać się „pół-działające" klienty: jeden pobiera, drugi dostaje metadata, trzeci w ogóle się łamie, bo gdzieś na poziomie klienta HTTP automatycznie jest ustawiany Accept: application/json.

W naszym kursie chcemy API, które czyta się oczami i prawie nie wymaga „zgadywania". Dlatego wybieramy prostą i przejrzystą model: metadata endpoint i oddzielny download endpoint. Tak, to plus jeden URI. Ale to minus dziesięć pytań „dlaczego to tak dziwnie działa".

Mała tabela, żeby ustalić różnicę „w jednym miejscu":

Co chcemy uzyskać Endpoint Content-Type odpowiedzi Ciało odpowiedzi Po co to potrzebne
Metadata załącznika GET /api/v1/tasks/{taskId} /attachments/{attachmentId} application/json JSON DTO Pokazać nazwę, rozmiar, opis, datę uploadu
Pobierz plik GET /api/v1/tasks/{taskId} /attachments/{attachmentId}/download application/octet-stream binarne ciało (Resource) Faktycznie otrzymać zawartość pliku

Ten schemat jest prosty i „samo-dokumentujący": nawet nie znając projektu, po URI rozumiesz, gdzie metadata, a gdzie content.

4. storageKey: wewnętrzny szczegół

Teraz porozmawiajmy o kolejnej pokusie: „No skoro mamy jakąś ścieżkę do pliku, po prostu ją zwróćmy klientowi". Na przykład: „Masz metadata, a tu storageKey lub path – po nim pobierzesz". Wydaje się, że to wygodne. W rzeczywistości prawie na pewno zamienia się to w wyciek infrastruktury na zewnątrz.

storageKey – to wewnętrzna sprawa. Jego przeznaczenie – pomóc serwerowi znaleźć fizyczny plik w magazynie. Klientowi nie trzeba wiedzieć, jak dokładnie przechowujesz plik: w folderze, podfolderze, po UUID, po hashu, po dacie, w trzech różnych katalogach… to klienta nie interesuje. Klienta interesują dwie rzeczy: „co to za plik?" i „jak go pobrać/usunąć przez API?".

Dlatego w naszym wewnętrznym modelu storageKey może być, ale w public DTO – nie. Dla ilustracji pokażemy, jak może wyglądać wewnętrzny model metadata. Ważna jest tu sama idea: pole jest, ale nie wycieka na zewnątrz.

package com.example.tasktracker.domain.model;

import java.time.Instant;

// Wewnętrzny model: przechowuje wszystko, czego serwer potrzebuje do pracy z plikiem.
public class AttachmentMetadata {
    private final String id;
    private final String taskId;

    // Tylko dla serwera: po tym kluczu znajdziemy plik w magazynie.
    // Do public DTO to pole nie powinno trafiać.
    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; }

    // Pozostałe gettery pominięte dla zwięzłości przykładu:
    // getId(), getTaskId(), getOriginalFileName(), getContentType(),
    // getSize(), getDescription(), getUploadedAt(), ...
}

A teraz kluczowy moment: nawet jeśli storageKey jest wewnątrz, mapper nie powinien go „przepychać" do DTO. To ten moment, gdzie DTO naprawdę chroni zewnętrzny kontrakt: nie pozwalają ci przypadkowo otworzyć klientowi drzwi do schowka, gdzie leżą twoje szczotki, kable i magiczne przyciski.

Mini-mapper dla odpowiedzi (bardzo uproszczony, żeby nie rozlewać się po kodzie):

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() {
        // Klasa narzędziowa: nie tworzymy instancji
    }

    public static AttachmentResponse toResponse(AttachmentMetadata m) {
        // Ważne: storageKey celowo NIE bierze udziału w mapowaniu.
        // Klient powinien widzieć tylko publiczny kontrakt, a nie szczegóły przechowywania.
        return new AttachmentResponse(
                m.getId(),
                m.getTaskId(),
                m.getOriginalFileName(),
                m.getContentType(),
                m.getSize(),
                m.getDescription(),
                m.getUploadedAt()
        );
    }
}

Tak, w twoim modelu jest pewnie więcej pól i getterów – to normalne. Ważne jest co innego: storageKey pozostaje wewnątrz. Klient nigdy nie powinien widzieć „jak serwer jest zbudowany od środka". Klient powinien widzieć kontrakt.

5. Dwa endpointy dla jednego attachmentId

Teraz zbierzemy wszystko w jeden obraz właśnie na poziomie API. Dla klienta załącznik to pod-zasób zadania. Czyli „załącznik" zawsze żyje w kontekście „zadania", i URI odzwierciedla tę hierarchię.

Endpoint metadata:

import com.example.tasktracker.api.dto.response.AttachmentResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

// Endpoint dla metadata: zwracamy JSON DTO (lekka odpowiedź dla UI).
@GetMapping("/api/v1/tasks/{taskId}/attachments/{attachmentId}")
public AttachmentResponse getAttachment(@PathVariable String taskId,
                                        @PathVariable String attachmentId) {
    // Tutaj NIE pobieramy pliku i NIE zwracamy bajtów — tylko metadata.
    return attachmentService.getAttachment(taskId, attachmentId);
}

Endpoint download:

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 do pobierania: zwracamy binarny content (Resource).
@GetMapping("/api/v1/tasks/{taskId}/attachments/{attachmentId}/download")
public ResponseEntity<Resource> download(@PathVariable String taskId,
                                         @PathVariable String attachmentId) {
    // Happy-path: binarne ciało + potrzebne nagłówki (Content-Type/Disposition).
    // Negative-path: może wrócić ProblemDetail w JSON (błąd kontraktu).
    return attachmentService.download(taskId, attachmentId);
}

Zwróć uwagę na ważny szczegół: ten sam attachmentId łączy metadata i download. Dla klienta to „jeden zasób", po prostu dwa sposoby jego odczytu. Klient może najpierw pokazać metadata, a potem, po kliknięciu przez użytkownika, iść na /download.

Żeby to powiązanie było jeszcze prostsze do poczucia, warto wyobrazić sobie przepływ jako mały schemat. Jest bardzo „przyziemny": nie omawiamy tutaj szczegółów przechowywania, tylko granice odpowiedzialności.

flowchart TD
    Client["Klient"] -->|GET metadata| MetaEndpoint["/attachments/{id} (JSON)"]
    Client -->|GET download| DownloadEndpoint["/attachments/{id}/download (binary)"]

    MetaEndpoint --> Service["AttachmentService"]
    DownloadEndpoint --> Service

    Service --> Repo["AttachmentRepository (metadata)"]
    Service --> Storage["Storage (content)"]

    Repo --> Service
    Storage --> Service

Z punktu widzenia projektowania kontraktu piękne tutaj jest to, że endpointy są oczywiste co do przeznaczenia. JSON endpoint zwraca JSON. Download endpoint zwraca plik. I nikt nie próbuje „zgadywać", w jakim trybie jest serwer.

6. Kontrakt błędów dla metadata i download

Już zbudowaliśmy jednolity kontrakt błędów przez ProblemDetail, i ważne jest, żeby nie zniszczyć go scenariuszami plików. Rozdzielenie metadata i content w tym sensie też pomaga, bo każdy endpoint pozostaje jednorodny co do formy odpowiedzi.

Metadata endpoint na happy-path zwraca JSON, a na negative-path zwraca application/problem+json. To znajomy wzorzec dla każdego naszego endpointu JSON.

Download endpoint na happy-path zwraca binarne ciało. Ale na negative-path też może zwrócić ProblemDetail jako JSON (to normalne): jeśli załącznik lub zadanie nie zostały znalezione, nie możesz pobrać czegoś, czego nie ma. I zamiast „pół-pliku" lub „pustej odpowiedzi" oddajesz zrozumiały błąd w tym samym formacie co w reszcie API.

Jeśli zaś robisz jeden endpoint, który „czasem JSON, a czasem plik", to w scenariuszach negatywnych staje się trudne do wyjaśnienia, jakiego typu odpowiedzi oczekiwać. Klient zaczyna żyć w świecie if/else: „jeśli status 200 – to plik, jeśli 404 – to JSON, jeśli 415 – to też JSON, ale czekałem na plik…" Im więcej takich warunków, tym szybciej klient zamienia się w kombajn do obsługi wyjątków, a nie w normalną aplikację.

Rozdzielenie sprawia, że jest to spokojniejsze: klient wie, że /download – to „plik, jeśli wszystko ok" i „ProblemDetail, jeśli nie". A /attachments/{id} – to „metadata, jeśli wszystko ok" i „ProblemDetail, jeśli nie". To dwa proste modele, z którymi i klientowi, i testom żyje się zauważalnie lepiej.

7. Typowe błędy przy rozdzieleniu metadata i content

Błąd nr 1: próba stworzenia „uniwersalnego DTO", do którego dodaje się byte[] content.
Zwykle zaczyna się niewinnie: „no niech będzie pole, może się przyda". Potem okazuje się, że tego DTO używa się i do listy, i do detail-view, i nagle każda lista załączników zaczyna ciągnąć za sobą potencjalnie ogromne ciągi base64. Nawet jeśli „domyślnie content nie wypełniasz", i tak trzymasz w modelu ideę, która prędzej czy później wypali. Lepiej od razu ustalić dyscyplinę: metadata DTO nie zawiera danych binarnych.

Błąd nr 2: wyciek storageKey lub ścieżki do pliku na zewnątrz.
Wewnętrzny klucz przechowywania to szczegół infrastrukturalny. Gdy tylko go zwróciłeś klientowi, zacementowałeś sposób przechowywania jako część kontraktu. Co gorsza, pokazałeś klientowi coś, czego nie musi wiedzieć, a czasem wygląda to jak „link do twojego dysku". Trzymaj takie pola ściśle w wewnętrznym modelu, a na zewnątrz oddawaj tylko attachmentId i właściwe endpointy.

Błąd nr 3: mieszanie metadata i download w jednym endpoincie „w zależności od nastroju".
Gdy jeden i ten sam URL zaczyna zachowywać się różnie (w zależności od query param lub Accept), rośnie ryzyko błędów, a kontrakt staje się „z sekretami". Dla edukacyjnego i praktycznego API lepiej dwa jawne endpointy. Nie stracisz funkcjonalności, ale zyskasz dużo na przewidywalności.

Błąd nr 4: zwracanie metadata z /download i uważanie, że „to też normalne".
Czasem robią tak: „jeśli pliku nie ma, zwrócimy metadata, żeby klient przynajmniej coś dostał". To zły pomysł: klient przyszedł po binarną zawartość, a dostał JSON. Nawet jeśli jest to „wygodne", łamie oczekiwania i zmusza klienta do pisania zbędnej logiki obronnej. Lepiej albo zwrócić plik, albo uczciwie zwrócić ProblemDetail.

Błąd nr 5: przechowywanie w metadata DTO zbyt wielu zbędnych rzeczy.
Metadata – to nie śmietnik „wszystkich informacji, które są w systemie". To to, czego klient potrzebuje do scenariuszy listy i przeglądania załącznika. Jeśli zaczniesz tam dodawać wewnętrzne pola, dane debugowe, techniczne flagi, znowu pojawi się ryzyko „przypadkowego publicznego kontraktu" i wzrost sprzężenia. DTO powinny pozostawać zdyscyplinowane i nudne – nuda tutaj, co dziwne, jest oznaką jakości.

1
Zadanie
Spring REST & MVC, poziom 27, lekcja 1
Niedostępne
Endpoint metadanych bez zawartości binarnej
Endpoint metadanych bez zawartości binarnej
1
Zadanie
Spring REST & MVC, poziom 27, lekcja 1
Niedostępne
Jeden attachmentId, dwie różne odpowiedzi
Jeden attachmentId, dwie różne odpowiedzi
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION