MOVGRANTO

Jeden kontrakt API dla backendu, webu i aplikacji mobilnej

BusiKM to trzy repozytoria i jeden kontrakt: schema.yaml w backendzie, typy generowane w web i mobile, a CI blokuje rozjazd przed develop i wydaniem.

Opublikowano: 5 min czytania

Autor: Łukasz Głowacz, Full-Stack Engineer · Read in English

Plik schema.yaml w backendzie, z którego powstają typy i hooki w webie oraz typy w aplikacji mobilnej.
Spis treści

BusiKM to trzy repozytoria: backend w Django, panel webowy w Next.js i aplikacja kierowcy w React Native. Jedna osoba i jeden kontrakt API między nimi. Gdy backend zmienia pole w odpowiedzi, web i aplikacja mobilna muszą się o tym dowiedzieć, zanim zmiana trafi do użytkownika, a nie po zgłoszeniu, że ekran się wysypał. W tym poście opisuję, jak trzymam ten kontrakt w jednym pliku i jak CI pilnuje, żeby repozytoria się nie rozjechały.

Jeden plik jako źródło prawdy

Plan wdrożenia z sierpnia ma w tej sprawie dwie krótkie decyzje. Pierwsza: jeden wspólny kontrakt API, plik schema.yaml w repozytorium backendu. Druga: na co dzień schemat bierzemy lokalnie, a ze stagingu dopiero przy wejściu na staging. Kolejność prac też jest ustalona: backend, potem web, potem mobile.

Wcześniej schema.yaml był w .gitignore. Każde repozytorium pobierało schemat z działającego backendu, lokalnego albo stagingowego, i nie było jednej wersji, do której można się odwołać. Od 1 września plik jest wersjonowany w repozytorium backendu i ma dziś 47 996 linii. Generuje go drf-spectacular z serializerów, widoków i routingu.

Kontrakt API: kod backendu generuje schema.yaml, który jest źródłem prawdy w repozytorium. Web generuje z niego typy i hooki przez orval i openapi-typescript, aplikacja kierowcy typy przez openapi-typescript. Skrypt sync-api.sh pobiera kopię z trzech źródeł: lokalnego backendu, pliku z repozytorium albo API stagingu

Web i aplikacja mobilna nie przepisują typów ręcznie. Web generuje z pliku typy (openapi-typescript) i gotowe hooki do pobierania danych (orval), aplikacja kierowcy same typy. Jeśli backend zmieni nazwę pola, kompilator TypeScript w webie i w mobile pokaże każde miejsce, które trzeba poprawić.

Ten sam kod musi dawać ten sam plik

Wersjonowany plik ma sens tylko wtedy, gdy generuje się deterministycznie. Pierwsza próba pokazała, że tak nie jest. Dwie generacje z identycznego kodu dawały pliki różniące się o 3294 linie, same przestawienia kolejności operacji. drf-spectacular iteruje po strukturach, których kolejność zależy od losowego ziarna haszowania w Pythonie. Bez stałego PYTHONHASHSEED=0 każda kontrola rozjazdu byłaby losowa od pierwszego dnia.

Druga przyczyna wyszła w CI przy tym samym PR. Zależność była zapisana jako zakres wersji. CI zainstalowało drf-spectacular 0.30.0, lokalnie było 0.29.0 i schemat wyszedł inny. Kontrola w CI słusznie to zgłosiła. Teraz generator jest przypięty dokładnie do jednej wersji, jako jedyna zależność w projekcie, której wynik trzymamy w repozytorium. Podniesienie jego wersji to osobny commit razem z regeneracją schematu.

Pre-commit, który nie przepuści zmiany bez schematu

Plik w repozytorium można zapomnieć zaktualizować. Dlatego backend ma hook pre-commit, który patrzy, czy commit dotyka czegoś, co kształtuje kontrakt API:

# Files that shape the API contract.
API_SHAPING=$(git diff --cached --name-only --diff-filter=ACMR | grep -E \
  '^(apps/.+/(serializers|views|urls|models|filters|enums|permissions)[^/]*\.py|apps/.+/(serializers|views)/.+\.py|config/urls\.py|config/settings/base\.py)$' || true)
 
if [[ -z "$API_SHAPING" ]]; then
  exit 0  # nothing that changes the contract
fi
# ...regenerate schema.yaml and block the commit if it differs from the staged one

Jeśli commit zmienia serializer, widok, routing, model albo filtr, hook generuje schemat od nowa do pliku tymczasowego, porównuje go z schema.yaml i blokuje commit, gdy się różnią. W komunikacie podaje różnice i komendę, która naprawia sprawę. Kod piszę głównie z Claude Code, więc taki hook jest dla mnie ważniejszy niż dla zespołu, który pamięta o regeneracji z nawyku. Agent nie musi pamiętać, bo commit bez schematu po prostu nie przejdzie.

Trzy źródła tej samej kopii

Web i mobile mają ten sam skrypt sync-api.sh, który pobiera kopię schematu z jednego z trzech źródeł:

  • local (domyślne): lokalny backend, do codziennej pracy, bo daje natychmiastową informację po zmianie w backendzie,
  • repo: plik z repozytorium backendu, bez sieci, do porównań w CI i pracy offline,
  • staging: API stagingu, dopiero przy wchodzeniu na staging, po wdrożeniu backendu.

Źródło prawdy jest jedno, a skrypt tylko wybiera, skąd wziąć jego kopię. Po pobraniu od razu generuje typy.

CI blokuje rozjazd tam, gdzie ma znaczenie

Najciekawsza decyzja dotyczy tego, kiedy rozjazd kontraktu jest błędem. Na gałęzi roboczej jest normalny: skoro praca idzie po kolei, backend często jest przed frontem. Blokowanie każdego PR z gałęzi feat/* tylko by przeszkadzało. Dlatego job contract-drift w web i w mobile ma regułę zależną od gałęzi docelowej:

# Rules per target branch:
#   feat/*         -> warn  (the backend is often ahead, local work)
#   develop        -> BLOCK, source: schema.yaml from the backend repo (no network)
#   staging, main  -> BLOCK, source: api-staging (the contract actually deployed)
target="${{ github.base_ref || github.ref_name }}"
case "$target" in
  develop)       source=repo;    blocking=true  ;;
  staging|main)  source=staging; blocking=true  ;;
  *)             source=repo;    blocking=false ;;
esac

Reguły kontroli rozjazdu w CI: na gałęziach feat ostrzega, na develop blokuje i porównuje z plikiem z repozytorium backendu, na staging i main blokuje i porównuje z API stagingu, czyli z kontraktem faktycznie wdrożonym

Przed develop porównanie idzie z plikiem w repozytorium, bez sieci, więc nie zależy od tego, czy staging akurat działa. Przed staging i main porównanie idzie z API stagingu, bo tam liczy się kontrakt naprawdę wdrożony, a nie ten zapisany w pliku.

Ile to kosztuje

Pierwszy koszt to dostęp między repozytoriami. Żeby CI webu mogło pobrać plik z repozytorium backendu, potrzebuje osobnego tokenu. Bez niego job nie przerywa builda, tylko zapisuje w podsumowaniu, że rozjazd na develop nie jest wykrywany. To świadomy wybór, żeby brak konfiguracji nie blokował całej pracy, ale też miejsce, w którym kontrola może po cichu nie działać.

Drugi koszt to starszy mechanizm, który działa obok. Po każdym merge'u do develop backend wysyła zdarzenie do repozytorium webu, a web po 30 sekundach pobiera schemat ze stagingu i otwiera automatyczny PR z aktualizacją. Te 30 sekund to założenie, że wdrożenie stagingu zdąży się skończyć. Jeśli nie zdąży, PR dostanie stary schemat. Kontrola rozjazdu i tak to wyłapie, ale to dobry przykład, że czekanie przez sleep to zgadywanie, a nie synchronizacja.

Z całej tej konstrukcji najbardziej opłaciło się jedno: kontrakt jest plikiem, a nie stanem działającego serwera. Plik można porównać w CI, wersjonować, przejrzeć w PR i odtworzyć dokładnie ten sam. Jeśli budujesz SaaS z osobnym frontem i aplikacją mobilną, zacznij od tego, gdzie leży kontrakt, zanim zaczniesz generować z niego typy. O tym, jak ta sama aplikacja kierowcy reaguje na zmiany zleceń na żywo, pisałem w poście o WebSockecie jako sygnale. Jeśli chcesz porozmawiać o architekturze swojego systemu, napisz do mnie.

Łukasz Głowacz

O autorze

Łukasz Głowacz

Full-Stack Engineer · MOVGRANTO

Full-Stack Engineer w MOVGRANTO. Buduje systemy od architektury po produkcję: backend, web, mobile, płatności i CI/CD. Autor BusiKM, SaaS do zarządzania flotą busów.

LinkedInGitHub

BusiKM

Zarządzanie flotą busów 2,5-3,5 t: zlecenia, mapa floty, czas pracy kierowców i koszty w jednym miejscu. 14 dni za darmo.

Sprawdź BusiKM ↗

Budujesz podobny system?

Dołączamy do zespołów jako inżynierowie full-stack albo budujemy produkt od zera. Porozmawiajmy o Twoim projekcie.

Umów rozmowę →

Newsletter

Co tydzień jedna prawdziwa decyzja architektoniczna z budowy produkcyjnego SaaS.