CodeGym /Kursy /Spring REST & MVC /Ewolucja API i OpenAPI

Ewolucja API i OpenAPI

Spring REST & MVC
Poziom 29, Lekcja 0
Dostępny

1. API jako długowieczny kontrakt

Prawie każdy początkujący backend-deweloper ma w głowie naiwną myśl: „dopóki klient nie przyszedł — można zmieniać jak się chce, potem wszystko poprawimy". Brzmi logicznie dokładnie do momentu, gdy sam stajesz się klientem swojego API przez zapytania .http, Swagger UI, frontend, aplikację mobilną albo integrację kolegi z sąsiedniego zespołu. Kontrakt zaczyna żyć wcześniej, niż zdążysz powiedzieć „to przecież projekt edukacyjny".

Najważniejsza myśl tej lekcji: API żyje dłużej niż twój obecny kod. Kontrolery zostaną przepisane, serwisy przemianowane, repozytorium „przeniesie się" z in-memory do JPA w kolejnym kursie — to normalne. Ale zewnętrzne oczekiwania klientów (jaki URL wywoływać, jakie parametry przekazywać, jakich pól oczekiwać w JSON, jakich statusów i błędów dostać) zmieniają się boleśnie i rzadko „bez konsekwencji".

Wyobraź sobie API jak menu w kawiarni. Kuchnia to twój kod: możesz zamieniać kucharzy miejscami, zastępować patelnię, możesz nawet przeprowadzić się do innego lokalu, jeśli smak i skład dań w menu się nie zmienił. Klientowi to obojętne. Ale jeśli przemianowałeś „barszcz" na „zupę nr 7" i jeszcze zmieniłeś przepis tak, że teraz jest w nim banan, klient to zauważy. I będzie miał rację.

Dlatego dziś ustalamy „punkt widzenia klienta": co dokładnie może on zauważyć, nawet jeśli zrobiłeś „małą zmianę" w kodzie. A już w następnej lekcji nauczymy się klasyfikować zmiany jako bezpieczne lub łamiące, ale najpierw trzeba zrozumieć, co w ogóle jest kontraktem.

2. Powierzchnia kontraktu: HTTP zamiast Javy

Gdy patrzysz na projekt od środka, widzisz klasy, pakiety, serwisy, mappery i „piękną architekturę". Klient tego w ogóle nie widzi. On widzi HTTP: ścieżkę, metodę, parametry, nagłówki, ciało żądania i odpowiedzi, kody statusów i typ treści. Jeśli klient jest zintegrowany na poziomie kodu (np. frontend lub inny serwis), widzi też kształt JSON i wartości enumów — i na tym koniec, „magia Springa" pozostaje za kulisami.

Wygodnie trzymać w głowie prosty schemat: granica kontraktu przebiega po linii HTTP. Wszystko „po tej stronie" — to twoje wewnętrzne szczegóły, wszystko „po tamtej stronie" — zobowiązania wobec klienta.

flowchart TD
  %% Granica kontraktu — HTTP między klientem a twoją aplikacją
  Client["Klient API (frontend / mobile / integracja)"] -->|HTTP request| MVC[Spring MVC]
  MVC --> Controller[Controller]
  Controller --> Service[Service]
  Service --> Repo[In-memory repo]
  Service --> Storage[File storage]
  Repo --> Service
  Storage --> Service
  Service --> Controller
  Controller -->|HTTP response| Client

To, co realnie „jest kontraktem", można rozłożyć w tabelce. Nie jest doskonała, ale świetnie leczy z iluzji „kontrakt = tylko JSON".

Co widzi klient Jak to jest wyrażone w naszym kodzie Spring MVC Dlaczego to jest część kontraktu
URI (np. /api/v1/tasks/{taskId}) @RequestMapping, @GetMapping i inne To „adres" zasobu. Jeśli adres się zmienił — klient nie będzie mógł dotrzeć.
Metoda HTTP (GET/POST/PUT/PATCH/DELETE) adnotacje mapowania (@GetMapping, @PostMapping…) Metoda niesie semantykę. Klient może cache'ować GET, powtarzać PUT, oczekiwać 204 przy DELETE.
Parametry query (page, size, status, q…) @RequestParam, @ModelAttribute To część kontraktu wejściowego. Nazwa i sens parametru to obietnica.
Nagłówki (Content-Type, Accept, Location, nagłówki pobierania) consumes/produces, ResponseEntity, HttpHeaders Nagłówki sterują formatem i zachowaniem klienta (szczególnie przy plikach i 201 Created).
Ciało żądania JSON @RequestBody, request DTO Klient buduje payload ściśle według formy. Każda „drobiazg" tu jest zauważalna.
JSON odpowiedzi response DTO, reguły Jacksona Klient parsuje odpowiedź. Jeśli pole zniknęło/zmieniło nazwę — witaj, bug.
Kody statusów (200/201/204/400/404/409/415/500) ResponseEntity, globalny handler wyjątków Klient reaguje na status, czasem nawet bez czytania body.
Payload błędu ProblemDetail + rozszerzenia (code, fieldErrors) Błędy też są kontraktem. Klient często pisze logikę właśnie pod nie.
Części multipart (file, metadata) @RequestPart / MultipartFile To „nazwy pól" żądania multipart. Jeśli je zmienisz — klient złamie się tak samo jak przy zmianie nazwy pola JSON.

Zauważ, że nie ma tu wiersza „nazwa metody w kontrolerze" czy „struktura pakietów". Klientowi jest obojętne, jak nazwałeś metodę getById() i w jakim pakiecie leży. Natomiast klientowi nie jest obojętne, że ścieżka teraz jest inna albo że status przestał być DONE i stał się COMPLETED.

3. Kontrakt danych: URI, query, DTO i JSON

Bardzo łatwo sprowadzić kontrakt API do „jakiego JSON zwracamy". I tak, JSON to najważniejsza część kontraktu w naszym kursie. Ale jest zawsze powiązany z resztą kontekstu HTTP: z URI, metodami, statusami i nawet z tym, co uważamy za „pusty wynik" dla endpointów listujących. Dlatego tu patrzymy na dane jako powiązanie: „gdzie i jak klient się zwraca" plus „jaką formę danych musi wysłać/otrzymać".

Zacznijmy od miłego faktu: wewnętrzne nazwy w modelu domenowym mogą żyć swoim życiem, jeśli DTO i mapping trzymają kontrakt stabilny. To jeden z powodów, dla których w ogóle robiliśmy DTO, a nie serializowaliśmy Task „jak jest".

Mini-przykład: wewnętrzne pole zostało przemianowane (albo historycznie tak się nazywało), ale zewnętrzny kontrakt pozostaje taki sam.

public class Task {
    // Wewnętrzna nazwa pola może być dowolna — klient tego nie widzi
    private String ownerName;

    // Wewnętrzny getter też nie jest częścią kontraktu HTTP
    public String getOwnerName() { return ownerName; }
}

// Zewnętrzny kontrakt definiuje DTO: właśnie jego pola staną się kluczami w JSON
public record TaskSummaryResponse(String assigneeName) {}

Jeśli twój mapper zwraca assigneeName, klient w ogóle nie musi wiedzieć, że wewnętrznie to nazywasz ownerName. To zwykły refactoring „w kuchni", który nie zmienia „menu".

Dalej zaczynają się bardziej „śliskie" strefy, gdzie naiwny refactoring staje się zewnętrzną zmianą. Przemianowanie pola w response DTO — to już nie sprawa wewnętrzna, bo nazwa pola = klucz w JSON. Zmiana typu pola — też zauważalna: było liczba, stał się ciąg i klient pada już na etapie parsowania. Zmiana formatu daty/czasu — to w ogóle klasyka: serwer radośnie zwraca „jak wyszło", a klient potem smutnieje, bo ISO-8601 nagle stał się „21.03.2026 18:00".

I jest jeszcze jeden rodzaj zmian, który najczęściej jest odbierany jako „drobiazg", ale klient i tak zauważy: dodanie nowego pola do odpowiedzi. Zazwyczaj to rozszerzenie, a nie łamanie (szczegółowo omówimy w następnej lekcji), ale klient zobaczy tę zmianę w dokumentacji, logach i przykładach — i to normalne. Ważne, żebyś rozumiał: zewnętrzny kontrakt się zmienił, tylko nie zawsze „boleśnie".

Oto jak wygląda typowy „rozszerzający" krok w response DTO:

import java.time.Instant;

public record TaskDetailsResponse(
        String id,
        String title,
        String status,
        Instant updatedAt,
        // Nowe pole w odpowiedzi: starzy klienci zazwyczaj będą mogli je ignorować,
        // a nowi — używać (np. do odznaki z liczbą komentarzy).
        Integer commentCount
) {}

Jeśli commentCount pojawi się w odpowiedzi, klient, który go nie zna, zazwyczaj może je zignorować. Ale klient, który chce „pokolorować" przycisk komentarzy, wreszcie będzie mógł to zrobić bez osobnego zapytania. Czyli kontrakt stał się bogatszy — i to też jest zmiana, tylko użyteczna.

Kolejna ważna warstwa kontraktu — parametry query i ich sens. Mamy page, size, sort, filtry według status/priority/tag, tekstowe wyszukiwanie q i zakresy dat. Nazwy tych parametrów i domyślne reguły to część obietnicy. Jeśli zmienisz domyślne sortowanie sort z updatedAt,desc na title,asc, klient to zauważy: wyniki będą „inne", nawet jeśli forma JSON się nie zmieniła. To ten przypadek, gdy schemat się nie zmienił, a zachowanie — tak i dla klienta może to być prawdziwy problem.

4. Kontrakt błędów: ProblemDetail i kody

Zazwyczaj o kontrakcie myśli się na happy-path: „oto żądanie, oto odpowiedź, wszystko pięknie". Ale realne życie API to mieszanka sukcesów i błędów. Czasem wręcz wydaje się, że błędów jest więcej: niepoprawny UUID, nieprawidłowy payload, konflikt statusu, nieobsługiwany typ pliku. I jeśli błędy są nieprzewidywalne, klient musi albo pisać „spaghetti" obsługi, albo po prostu pokazywać użytkownikowi „coś poszło nie tak" w każdej sytuacji. W obu wariantach nie wygrywasz.

Zbudowaliśmy już jednolity kontrakt błędów wokół ProblemDetail i dodaliśmy do niego specyficzny dla aplikacji code i tam gdzie trzeba fieldErrors. Teraz ważne jest przyznanie: to nie jest „wewnętrzna implementacja", to publiczna część API. Klient może opierać się na status dla UX (np. pokazać „nie znaleziono" albo „konflikt"), na code — dla logiki maszynowej (np. podświetlić konkretną przyczynę), na fieldErrors — żeby podpowiedzieć użytkownikowi, co dokładnie poprawić.

Formalnie rzecz ujmując, klientowi przydaje się, gdy payload błędu jest stabilny strukturalnie. Mniej więcej tak (bez przywiązania do konkretnej klasy): type, title, status, detail, instance jako standardowe pola ProblemDetail, plus nasz code, plus opcjonalne rozszerzenia jak timestamp i fieldErrors. Nawet jeśli człowiek czyta błąd oczami, kod klienta zazwyczaj czyta status i code, a pola tekstowe (title/detail) traktuje jako „czytelną podpowiedź".

Właśnie dlatego „mała" zmiana jak przemianowanie kodu błędu to nie kosmetyka. Jeśli wczoraj klient obsługiwał TASK_NOT_FOUND, a dziś zdecydowałeś, że „ładniej brzmi" TASK_MISSING, to klient przestanie rozpoznawać sytuację. Niby nadal 404, ale logika biznesowa po stronie klienta (lub integracji) może być powiązana z code i złamie się.

To samo dotyczy struktury fieldErrors. Jeśli klient oczekuje, że będą tam klucze w postaci title albo tags[0], a ty nagle zmieniłeś format na „czytelne dla człowieka" wiadomości bez ścieżek, zabrałeś klientowi możliwość dokładnego powiązania błędu z polem formularza. I tak, to też jest zmiana kontraktu, nawet jeśli kod serwera „stał się prostszy".

I jeszcze jeden niuans: błędy multipart i download — też są częścią kontraktu błędów. Jeśli endpoint uploadu wcześniej zwracał 415 z code=UNSUPPORTED_MEDIA_TYPE, a zacząłeś zwracać 400 INVALID_INPUT „bo tak prościej", klient to zauważy. Może pokazywać różne komunikaty użytkownikowi albo np. automatycznie proponować inny format pliku tylko przy 415.

5. Kontrakt plików: multipart i download

Endpointy plikowe są często postrzegane jako coś drugorzędnego: „no to przecież zwykły MultipartFile, co tu dokumentować i traktować jako kontrakt". W praktyce to jeden z najbardziej kontraktowo-wrażliwych scenariuszy, bo radykalnie różni się od zwykłego JSON: inny Content-Type, inny sposób przekazywania danych, czasem inni klienci (przeglądarka, aplikacja mobilna, integracja). I jeśli „trochę zmieniłeś", klient pada ze szczególnie zagadkową miną.

Nasz upload wygląda jak multipart/form-data z dwiema częściami: plik binarny i metadane JSON. Najważniejszy detal: nazwy części (file, metadata) to dokładnie taki sam kontrakt jak nazwy pól JSON. Jeśli przemianowujesz part metadata na meta, stary klient będzie wysyłać metadata i serwer nagle zacznie odpowiadać błędem „missing part". Wewnątrz projektu zmieniłeś jeden ciąg znaków, a na zewnątrz klientowi przestaje działać kluczowy scenariusz.

Mini-przykład, który bardzo naocznie pokazuje „kontraktowość" multipart: w samej sygnaturze widać, że klient musi wysłać part z konkretną nazwą.

import org.springframework.http.MediaType;
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",
        // Ważne: klient musi wysłać właśnie multipart/form-data, inaczej będzie 415/400 w zależności od obsługi
        consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
public void upload(
        // Ważne: nazwa part — część publicznego kontraktu ("file")
        @RequestPart("file") MultipartFile file,
        // Ważne: nazwa part — część publicznego kontraktu ("metadata")
        @RequestPart("metadata") AttachmentUploadMetadataRequest metadata
) {
    // Tu może być dowolna wewnętrzna implementacja — kontrakt definiuje sygnatura HTTP powyżej
}

Nawet jeśli nic nie wiesz o Springu, możesz już „przeczytać kontrakt": żądanie multipart, dwie obowiązkowe części, nazwy ustalone.

Scenariusz download też jest kontraktowy. Nie ma tam JSON, ale są nagłówki. Content-Type mówi klientowi, co to są za dane, Content-Disposition — jak nazwać plik i czy pobierać go, a nie próbować otworzyć jak stronę. Jeśli przestałeś wysyłać Content-Disposition, przeglądarka może zacząć otwierać PDF w zakładce zamiast pobierać i użytkownik powie „zepsuło się pobieranie". Technicznie serwer „wysłał plik", ale kontrakt zachowania dla klienta się zmienił.

I są jeszcze „drobiazgi", które nie są drobiazgami: jeśli zmieniłeś fallback content type z application/octet-stream na text/plain, klient może zacząć nieprawidłowo obsługiwać dane binarne. Albo jeśli zmieniłeś endpoint /download na /content dla estetyki — klient zauważy, bo URL się zmienił.

6. Refactoring i zmiana API: jak sprawdzić

Najbardziej niebezpieczny moment — gdy szczerze uważasz, że zrobiłeś „czysto wewnętrzny refactoring", a klient nagle się łamie. Zazwyczaj tak się dzieje, gdy patrzysz na zmiany oczami serwera: „przecież tylko przemianowałem", „przecież tylko zmieniłem wartość domyślną", „przecież tylko usunąłem pole, było zbędne". Klient patrzy inaczej: „moje żądanie było takie — teraz nie działa", albo „odpowiedź była taka — teraz nie mogę jej sparsować".

Najprostszy praktyczny test brzmi niemal śmiesznie, ale działa: jeśli po zmianie musisz edytować swoje zapytania .http (albo kod klienta frontendowego), to kontrakt się zmienił. I nie ma znaczenia, że „nic takiego" nie robiłeś — klientowi to obojętne.

Żeby łatwiej było się kontrolować, przydatna jest tabela „zmiana → klient zauważy?". Tu nie ma tematu safe/breaking (to w następnej lekcji), tu tylko fakt zauważalności.

Zmiana w kodzie Klient zauważy? Dlaczego
Przemianowano metodę TaskService.getById() na findById() Nie To nie wychodzi na zewnątrz; HTTP się nie zmienił.
Przeniesiono klasę mappera do innego pakietu Nie Klient nie widzi pakietów Javy.
Przemianowano pole w modelu domenowym ownerName -> assigneeName, ale DTO nie zmieniono Nie DTO i JSON pozostały bez zmian.
Zmieniono @RequestMapping("/api/v1/tasks") na "/api/v1/work-items" Tak URL zasobu się zmienił — klient będzie pukał „w pustą ścianę".
Przemianowano parametr query q na query Tak Stary klient nadal będzie wysyłać q, a serwer przestanie filtrować.
Przemianowano pole JSON title na taskTitle w response DTO Tak Klient parsuje po kluczach; klucz zniknął.
Zmieniono status HTTP przy usuwaniu z 204 na 200 i zaczęto zwracać ciało Tak Klient może oczekiwać pustego ciała i innego statusu (szczególnie w testach/SDK).
Zmieniono code błędu z TASK_NOT_FOUND na TASK_MISSING Tak Logika klienta po kodach przestaje działać przewidywalnie.
Przemianowano part multipart metadata na meta Tak Klient wysyła starą nazwę części — serwer nie widzi danych.
Zmieniono domyślne sortowanie w GET /tasks Tak To samo żądanie zaczyna dawać inny wynik, choć schemat odpowiedzi jest ten sam.

I tu właśnie wypływa „podstępna" myśl: kontrakt to nie tylko struktura DTO, ale i zachowanie. Nawet jeśli JSON jest identyczny, ale „to samo żądanie" zachowuje się inaczej, klient to poczuje. Czasem od razu (testy padły), czasem po tygodniu (użytkownik powiedział, że „wcześniej było wygodniej").

Na koniec utrwalam najbardziej oczywisty przykład zewnętrznej zmiany: ścieżka zasobu. Kod w kontrolerze może być pusty, ale klient już się złamał, bo zmienił się adres.

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
// Ważne: URI zasobu — część kontraktu; jego zmiana łamie klientów
@RequestMapping("/api/v1/work-items") // było: /api/v1/tasks
class TaskController {
    // Wnętrzności kontrolera mogą się zmieniać, ale URI — to, co widzi klient
}

Dla serwera wygląda to jak „przemianowaliśmy zasób, no trudno". Dla klienta wygląda jak „aplikacja umarła". I właśnie dlatego tak pedantycznie ustalaliśmy URI jeszcze na początku kursu.

Z takim rozumieniem jesteśmy gotowi na następną lekcję, gdzie będziemy już rozróżniać zmiany, które stosunkowo bezpiecznie rozszerzają kontrakt, od zmian, które prawie na pewno łamią klientów. Ale to będzie kolejny krok; teraz ważne jest, że nauczyłeś się widzieć powierzchnię kontraktu w całości.

7. Typowe błędy przy ewolucji API

Błędy w tym temacie zazwyczaj zdarzają się nie ze złej woli, ale przez „tunelowe widzenie": patrzysz na API jako deweloper serwera, a nie jako deweloper klienta. Z powodu tego podejmujesz decyzje „dla piękna kodu", a skutki pojawiają się tylko na zewnątrz — we frontendzie, integracji albo nawet w twoich własnych scenariuszach .http, które nagle przestały działać.

Błąd №1: traktowanie kontraktu jako tylko JSON, ignorowanie URI, metod, statusów i nagłówków.
Jeśli myślisz „kontrakt = DTO", bardzo łatwo złamać klienta zmianą statusu (np. 204 na 200), przeniesieniem zasobu na inną ścieżkę, zmianą Content-Type lub zniknięciem Location przy 201 Created. Klient żyje w świecie HTTP i wszystko to dla niego równie ważne jak pola JSON.

Błąd №2: mylenie wewnętrznego refactoringu z zewnętrzną zmianą, opierając się na IDE i kompilatorze.
IDE się cieszy: wszystko się kompiluje, testy serwera są zielone. Ale klientowi to nie pomaga. Właściwe sprawdzenie tu to nie „czy się skompilowało", ale „czy stary klient mógłby wysłać to samo żądanie i dostać oczekiwaną odpowiedź". Czasem wystarczy po prostu otworzyć stare zapytania .http i uczciwie spróbować je wykonać, zanim powiesz „nic się nie zmieniło".

Błąd №3: zmiana sensu pola bez zmiany jego nazwy i uznanie tego za „bezpieczne".
Na przykład pole archived wcześniej oznaczało „status ARCHIVED", a teraz oznacza „po prostu ukryte z listy". Formalnie klucz JSON jest ten sam, ale semantyka inna i klient, który budował zachowanie na starym sensie, zacznie działać dziwnie. Kontrakt to nie tylko forma, ale i sens.

Błąd №4: traktowanie kontraktu błędów jak „wewnętrznego detalu" i przemianowywanie code lub formatu fieldErrors bez żalu.
Błędy to to, na czym klient pisze rozgałęzienia. Jeśli zmieniłeś code, usunąłeś fieldErrors lub zacząłeś zwracać różne formaty w różnych kontrolerach, klient albo przestanie rozróżniać sytuacje, albo zacznie pisać obejścia. W efekcie cierpi nie tylko klient, ale i twoje API: trudniej go testować i dokumentować.

Błąd №5: traktowanie multipart i download jak „specjalnego przypadku", który nie wymaga takiej samej dyscypliny jak JSON.
W kontrakcie multipart są swoje „nazwy pól" (parts), swoje obowiązkowe elementy, swoje media types i swoje błędy (415, missing part). W kontrakcie download są nagłówki sterujące zachowaniem klienta. Jeśli podejść do tego jak „no to jakoś", prawie na pewno dostaniesz „jakoś się zepsuło".

1
Zadanie
Spring REST & MVC, poziom 29, lekcja 0
Niedostępne
Stabilny kontrakt JSON dla wewnętrznego modelu
Stabilny kontrakt JSON dla wewnętrznego modelu
1
Zadanie
Spring REST & MVC, poziom 29, lekcja 0
Niedostępne
Download endpoint jako część kontraktu
Download endpoint jako część kontraktu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION