Diagnozowanie błędów w Prisma Guard: model diagnostyczny oparty na fazach
Dowiedz się, jak diagnozować błędy API Prisma powstające w wyniku generowania, przypisując je do konkretnej fazy – konfiguracji, wyboru wywołującego, walidacji lub odpowiedzi – która je spowodowała.
Zacznij rozwiązywanie problemów od określenia, która faza jest właściwie przyczyną awarii.
Generowane API mogą ulec awarii w wielu różnych punktach.
Uwaga dotycząca zakresu: opisane tutaj fazy awarii pochodzą z dokumentacji projektu oraz ustalonych środowisk reprodukcji, a nie ze statystyk użyteczności wśród szerokiej bazy użytkowników.
Router może odrzucić własną konfigurację jeszcze zanim wyśle choćby jeden żądanie. Mechanizm ochronny może odrzucić błędnie sformatowany obiekt w momencie jego utworzenia. Proces routingu może zawieść się, zanim uruchomiony zostanie specyficzny hook dla danej wersji. Walidacja żądania może odrzucić pojedynczy treść ciała. Operacja w określonym zakresie może się nie udać po prostu dlatego, że brakuje kontekstu, od którego zależy.
Poza nimi istnieje trudniejsza kategoria: żądanie technicznie się udaje, jednak argumenty Prisma generowane w tym procesie lub semantyka odpowiedzi odbiegają od tego, co zakładała logika aplikacji.
Każda z tych kategorii wymaga innego rozwiązania oraz innego rodzaju testu. Analiza całej ciągu błędu jest znacznie mniej skuteczna niż postawienie dwóch pytań: kiedy po raz pierwszy pojawiło się to zachowanie i który warstwa jest w stanie je zaobserwować?
Zacznij od mapy faz
Generowane żądanie Prisma przechodzi przez kilka odrębnych etapów w drodze do wykonania:
router construction
caller resolution
operation before-hooks
variant before-hooks
guard shape construction
request validation
Prisma argument execution
response transport
Dokładna kolejność operacji związanych ze kształtami może się zmieniać w zależności od tego, czy kształt jest statyczny, czy zależy od kontekstu w czasie wykonywania, ale ten model diagnostyczny pozostaje przydatnym narzędziem pomocniczym.
Błędy podczas uruchamiania wskazują na problemy z deskryptorami tras. Błędy na etapie wywołania wskazują na logikę selekcji wariantów. Błędy takie jak Invalid query i Invalid data wskazują na niezgodność między treścią żądania a deklarowanym formatem. Błędy związane z politykami wskazują na brak zaufanego kontekstu. A gdy żądanie zostaje zrealizowane, ale wynik jest nieoczekiwany, trzeba całkowicie zignorować kod stanu.
Należy pamiętać, że tekst błędów jest powiązany z konkretnymi wersjami. Przykłady skupione na mechanizmach ochrony, o których mowa tutaj, zostały stworzone przy użyciu stałej kombinacji: prisma-guard wersji 1.33.0, w połączeniu z Zod 4.4.3 i Prisma 6.19.3. Przykłady dotyczące operacji odczytu opartych na HTTP z kolei wykorzystują oddzielną kombinację: prisma-generator-express 1.64.4 działający na Node 22.14.0 przy użyciu PostgreSQL 16.6.
Należy traktować dokładne sformułowanie błędu jako dowód specyficzny dla danej kombinacji wersji. Fazę i podstawową przyczynę należy traktować jako model debugowania, który będzie nadal przydatny później.
Przed złożeniem żądania: konfiguracja nie może stanowić umowy
Konstruktor routera jest odpowiedzialny za weryfikację opisów operacji przed jakimikolwiek innymi działaniami.
Operacja nie może jednocześnie konfigurować zarówno shape, jak i variants. Mapa wariantów nie może być pusta. Każdy opis wariantu musi zawierać informację o kształcie. Rezerwowane klucze kształtów nie mogą być używane jako nazwy wywołujących.
Są to z natury błędy występujące podczas wdrażania. Gdyby system je wykrył, a mimo to kontynuował pracę z częściowo skonfigurowanym routerem, w cichy sposób usunąłby granice, które aplikacja miała egzekwować.
Operacja, która nie definiuje ani shape, ani variants, stanowi zupełnie inną sytuację: jest technicznie poprawna i wywołuje Prisma bezpośrednio, bez żadnych mechanizmów kontroli. To, czy jest to do przyjęcia, powinno być wyraźną decyzją podjętą podczas przeglądu ścieżek, a nie przypadkiem.
Budowa kształtów ma własny zestaw warunków niepowodzenia. Puste kombinatory, puste projekcje, sprzeczne przymiotniki wymuszone, niekompletne kształty utworzone, błędnie sformatowane struktury upsert oraz metody masowe bez kształtu where są odrzucane od razu, zanim dane dostarczone przez klienta zdążą w niebezpieczny sposób z nimi interakcjonować.
Najbardziej przydatna jest minimalna rekonstrukcja, gdy oddziela budowę kształtów od warstwy transportowej:
const query = guard.query('Plant', 'findMany', {
where: {
name: { contains: true },
},
take: { max: 50, default: 20 },
})
const args = query.parse({
where: {
name: { contains: 'fern' },
},
})
Ta izolowana ścieżka dobrze sprawdza się przy testowaniu filtrowania danych, sortowania, argumentów paginacji oraz większości błędów związanych z konstrukcją struktury danych. Nie umożliwia jednak faktycznej eksploatacji w Prismie, zastosowania projekcji danych na poziomie delegata ani przedstawienia sposobu działania mutacji w praktyce.
Niezależnie od tego, jaka będzie poprawka, powinna znajdować się w konfiguracji serwera. Żadne dostosowanie treści żądania nie może naprawić struktury, która od samego początku jest błędna pod względem budowy.
Przed obsługą żądania: nie udało się wybrać funkcji obsługi
Gdy używane są nazwane struktury i ich warianty, przed dotarciem żądania do wygenerowanej funkcji obsługi odbywa się dodatkowy etap routingu.
Wywołanie jest uważane za zaginione, gdy mapa wariantów nie zawiera wpisu default. Wywołanie jest uważane za nieznane, gdy nic do niego nie pasuje: ani dokładny klucz, ani wzorzec parametryzowany, ani wartość domyślna. Dwa nakładające się wzorce parametryzowane nie są rozstrzygane na podstawie kolejności deklaracji; system traktuje taką sytuację jako niejednoznaczną i w efekcie zawodzi.
Dane identyfikacyjne wywołania są przekazywane jako oddzielny kanał od ciała żądania Prisma. Próby ukrycia ich w samych argumentach żądania są odrzucane.
Dla kontraktów dostępnych publicznie użycie nagłówka jako celowego selektora wywołania może być rozsądnym wyborem projektowym. Jednak w przypadku uprzywilejowanych wariantów selekcja powinna pochodzić z autoryzowanej logiki wewnątrz funkcji resolveVariant, a nie z danych wprowadzanych przez klienta. Nadanie nagłówkowi własnej nazwy nie czyni jego wartości godną zaufania.
Błędy routingu występują po uruchomieniu hooków przedoperacyjnych, ale przed uruchomieniem hooków specyficznych dla wariantu. Ten szczegół dotyczący kolejności wyjaśnia subtelne zachowanie: logika autoryzacji na poziomie operacji nadal jest wykonywana, nawet gdy żaden wariant wywołującego się nie pokrywa, podczas gdy hooki specyficzne dla wywołującego się w takim przypadku nigdy nie są uruchamiane.
Prawidłowe rozwiązanie to nie automatyczne „po prostu dodanie wartości domyślnej”. Wartość domyślna wywołującego się w milczeniu akceptuje brakujące, puste oraz niepasujące wartości. Dodaj ją tylko wtedy, gdy takie zachowanie awaryjne jest rzeczywiście do przyjęcia we wszystkich trzech tych scenariuszach.
Podczas walidacji: żądanie przekroczyło określone granice
Błędy odczytu sprawdzone w tym ustawieniu wskazują na dokładną ścieżkę argumentów, która je spowodowała.
Nierozpoznane pole wewnątrz where oznacza, że to pole nie jest częścią struktury filtru. Nierozpoznane pole wewnątrz select oznacza, że żądanie próbuje rozszerzyć projekcję poza dozwolone granice. Odrzucona wartość skip oznacza, że pomijanie stron w celu paginacji nigdy nie zostało włączone dla tej struktury. Błąd w take może oznaczać albo, że zapytana wartość przekroczyła ustawiony maksymalny limit, albo że została dostarczona w zupełnie niewłaściwym typie skalarowym.
W tym przypadku pomocniki GET generowane są istotne, ponieważ argumenty w formacie Prisma nie są zawsze konwertowane w ten sam sposób, gdy są tworzone ręcznie na podstawie ciągów zapytań. Wartości filtrów liczbowych i daty zazwyczaj konwertują się poprawnie w miejscach, gdzie to jest obsługiwane, natomiast wartości logiczne i dane paginacji przekazywane jako łańcuchy mogą nie zostać prawidłowo przekonwertowane. Bezpieczniejszym rozwiązaniem jest użycie generowanego kodera do żądań GET lub odwołanie się do natywnego JSON za pośrednictwem metody odczytu opartej na POST.
Z drugiej strony, pisanie logiki walidacji odbywa się zgodnie ze strukturą specyficzną dla każdej metody Prisma. Operacje tworzenia otrzymują pole data. Operacje aktualizacji otrzymują zarówno where, jak i data. Operacje insert/update otrzymują where, create oraz update. Wywołanie grupowego tworzenia z mechanizmem ochrony wymaga, aby wejściem było tablica.
Operacje masowe mogą zawieść na dwóch odrębnych poziomach. Jeśli w samej strukturze brakuje pola where, jest to problem związany z czasem konstrukcji. Jeśli ciało żądania w trakcie wykonywania ma technicznie pole where, ale nie odpowiada ono żadnej rzeczywistej warunku po stronie klienta, jest to natomiast problem związany z czasem wysyłania żądania.
Błędy polityki ponownie stanowią odrębną kategorię. Brak korzenia zakresu lub brak kontekstu dla struktury, która zależy od kontekstu w czasie wykonywania, wskazuje na to, że jakaś część zaufanego stanu po prostu nie istnieje. Ustawienie zachowania w przypadku braku zakresu na tryb błędu zapobiega temu, by brakujący kontekst potajemnie przekształcił się w niefiltrowane, najwyższościowe zapytanie.
Nawyk, który warto tu rozwijać, to zapisywanie dokładnej ścieżki, w której doszło do błędu. Stwierdzenie „otrzymałem błąd 400 od strażnika” nie mówi prawie nic przydatnego. Z kolei informacja „proces odczytu próbował wykonać include.plants.take ponad ustalonym maksymalnym poziomem zagłębienia” wskazuje bezpośrednio na konkretny element kontraktu.
Gdy strażnik daje zielone światło: status 200 nadal ukrywa prawdziwe ryzyko
Odpowiedź HTTP o statusie sukcesu informuje jedynie o tym, że ścieżka została w pełni przetworzona. Nie mówi nic o tym, czy wysłana wartość została rzeczywiście uwzględniona, czy warunek został wykonywany w gałęzi, o której zakładano, czy też odpowiedź wykorzystała projekcję domyślną, jakiej się oczekiwało.
Weźmy pełnie narzucony predykat najwyższego poziomu: przejmuje kontrolę nad tym, co wysyła klient, bez żadnego widocznego śladu takiego działania. Jeśli jakaś forma zablokuje wartość isPublished na true, klient, który wysyła false, nadal otrzymuje odpowiedź potwierdzającą sukces, podczas gdy faktycznie wykonywana zapytanie zachowuje narzuconą wartość true.
Inne pola zmuszane do określonej wartości zachowują się odwrotnie – odrzucają bezpośrednio wartości dostarczone przez klienta, zamiast je cicho przejmować. Ponieważ narzucanie wartości może działać w sposób niejednolity w zależności od miejsca jego zastosowania, Twoje testy muszą sprawdzać, kto faktycznie kontroluje każdy argument, zamiast zakładać, że jedna instancja funkcji force() ma zastosowanie do wszystkich pól.
Wymuszanie staje się jeszcze trudniejsze w klauzuli OR. Warunek wymuszony umieszczony tam jest przenoszony na wyższy poziom i przekształcany w obowiązkowe ograniczenie najwyższego poziomu. Dlatego kształt, który wydaje się wyrażać „albo warunek klienta, albo warunek serwera”, może w rzeczywistości działać jako połączenie warunku klienta z wymuszonym predykatem przy użyciu logiki AND. Jeśli naprawdę potrzebujesz alternatywy kontrolowanej przez serwer, musisz użyć dedykowanej zapytania stworzonego w tym celu lub wprowadzić to ograniczenie na poziomie polityki bazy danych.
Projekcja odpowiedzi wprowadza własne, subtelne różnice. Gdy klient pominie projekcję podczas chronionej operacji odczytu, stosowana jest domyślna projekcja kształtu, ale ta substytucja następuje w momencie faktycznej eksploatacji zapytania, a nie w momencie wykonywania metody guard.query().parse().
Mutacje nie podlegają tym samym zasadom. Jeśli parametr enforceProjection nie jest ustawiony, klient, który pominie informację o projekcji przy dokonywaniu mutacji, w ogóle nie otrzyma klauzuli select, co oznacza, że zamiast tego obowiązuje standardowe zachowanie Prismy polegające na braku projekcji.
Wymuszanie stosowania zagnieżdżonych zakresów to kolejny obszar, w którym łatwo założyć większą ochronę, niż faktycznie istnieje. Automatyczny mechanizm obsługi zakresów przechwytuje jedynie operacje najwyższego poziomu, które są mu wyraźnie obsługiwane. Nie dotyka on relacji włączanych za pośrednictwem projekcji ani nie filtrowa ich rekurencyjnie. Ponadto sam korzeń zakresu nigdy nie jest filtrowany za pomocą swojego własnego markera, a każdy surowy zapytanie SQL całkowicie omija warstwę wymuszania dostarczaną przez rozszerzenie.
Żadne z tych zachowań nie będzie widocznych, jeśli sprawdzisz jedynie kod stanu.
Wybierz odpowiedni mechanizm odczytu, zanim zaufasz strukturze odpowiedzi
Stworzona warstwa zawiera trzy różne mechanizmy do dostarczania wyników odczytu: odpowiedzi paginowane, transport oparty na POST oraz wydarzenia wysyłane przez serwer w formacie Express.
findManyPaginated zwraca ustaloną strukturę wynikową:
type PaginatedResult<T> = {
data: T[]
total: number
hasMore: boolean
}
Flaga hasMore jest wiarygodna szczególnie w przypadku paginacji opartej na przesunięciu do przodu w połączeniu z dodatnią wartością take. Jeśli używasz paginacji opartej na kursorze lub ujemnej wartości take, nadal możesz otrzymać wartość logiczną, ale nie gwarantuje ona już tego samego efektu. Wartość take równa 0 zwraca zero wierszy oraz fałszywą flagę kontynuacji, przy czym całkowita liczba pozostaje niezmieniona.
Całkowita liczba jest obliczana zgodnie zupełnie inną logiką. Liczenie rozróżniające uwzględnia ustawiony limit. Źródło liczb przeliczonych z góry jest używane tylko wtedy, gdy żądanie nie jest filtrowane, nie ma zabezpieczeń i nie polega na liczeniu rozróżniającym. Każdy dynamiczny filtr, klauzula rozróżniająca lub mechanizm zabezpieczeń zmusza do użycia liczb aktualnie obliczanej w momencie wysyłki żądania.
Taki sposób rozwiązania zapewnia poprawność wyników, ale zmienia zarówno koszt operacji, jak i źródło pochodzenia liczby. Semantykę całkowitej liczby należy traktować jako odrębną kwestię od semantyki dzielenia wierszy.
Czytania oparte na metodzie POST istnieją po to, aby obsłużyć rozmiar i kodowanie treści żądania, a nie po to, aby rozszerzyć możliwości języka zapytań:
POST /delivery/paginated
Content-Type: application/json
{"where":{"city":{"equals":"Bangkok"}},"take":20,"skip":0}
Należy wysłać ciało żądania w formie natywnego JSON. Oczekuje się, że wersje GET i POST tej samej trasy będą realizować identyczne zasady bezpieczeństwa. Jeśli jakiś hook zmieni treść ciała żądania, ta równoważność może zostać naruszona, ponieważ ścieżka GET odczytuje dane z już przetworzonych parametrów zapytania, a nie z ciała w formacie JSON.
Zdarzenia wysyłane przez serwer zmieniają się w zależności od chwili przybycia danych, a nie od samej treści tych danych. Ten mechanizm ma sens tylko wtedy, gdy klient faktycznie implementuje obsługę zdarzeń postępów, zdarzeń końcowego sukcesu, zdarzeń końcowej porażki oraz ścieżki awaryjnej.
{"type":"progress","stage":"relations"}
{"type":"field","field":"summary","data":{"total":6}}
{"type":"result","data":{"summary":{"total":6},"deliveries":[]}}
Ręcznie przygotowywane zdarzenia SSE to zapytania na poziomie aplikacji, które piszemy sami, i one wymagają takiego samego wyraźnego zarządzania bezpieczeństwem jak wszystko inne. Funkcja auto-include obejmuje jedynie struktury relacyjne, które są udokumentowane i mieszczą się w granicach planera; wszystko poza tym jest obsługiwane zgodnie z ustawionym zachowaniem awaryjnym. Ponadto generowane po-hooki nie stanowią gwarantowanego mechanizmu do oczyszczenia strumienia SSE.
Podsumowując, „udana” operacja odczytu może nadal być błędna z kilku niezależnych powodów: niewiarygodny flag kontynuacji, błędna interpretacja źródła liczby, specyficzne dla transportu zachowanie hooków lub zapytanie przygotowane ręcznie, które w ogóle nie zostało zabezpieczone.
Kieruj każdą próbę na warstwę, którą faktycznie może zweryfikować
Jedno żądanie end-to-end nie może jednocześnie zweryfikować wszystkich warstw.
Zastosuj parser podczas testowania walidacji treści lub struktury zmuszonej fuzji. Użyj chronionego delegata, gdy chodzi o projekcję w czasie wykonywania lub argumenty końcowej mutacji. Skorzystaj z ścieżki operacyjnej rozszerzenia, gdy sprawdzasz, czy rzeczywiście nastąpiła automatyczna iniekcja zakresu.
Niezależne narzędzie do przechwytywania argumentów umożliwia inspekcję końcowych argumentów mutacji bez dostępu do bazy danych, ale tylko wtedy, gdy połączysz rozszerzenie ochronne z delegatem, który faktycznie zwraca otrzymane argumenty. Tworzenie odłączonego, fałszywego obiektu nic nie dowodzi. Tego typu narzędzie pokazuje, jakie argumenty zostały wygenerowane, a nie które wiersze faktycznie zwróciłaby baza danych.
W przypadku pytań dotyczących wyników na poziomie najemcy, własności relacji, zachowania transakcyjnego, odrębnych sum lub specyficznych cech dostawcy, potrzebne są narzędzia wspierane przez bazę danych. Utwórz co najmniej dwa konta najemców z wierszami, które wyraźnie się od siebie różnią, aby w razie wycieku informacji był on łatwo widoczny.
Dla pytań związanych z generowanym routowaniem, serializacją, wykonywaniem hooków, równoważnością GET/POST, strukturą odpowiedzi paginacji lub sekwencją zdarzeń SSE, użyj testów na poziomie HTTP.
Zachowaj przynajmniej jeden test kontraktu z włączoną ochroną, nawet jeśli twoja całościowa suita testów w przeglądarce działa w trybie, który wyłącza walidację ochrony. Test przeglądarki, który przechodzi w łagodniejszym trybie, nic nie dowodzi na temat tego, co zostanie odrzucone w środowisku produkcyjnym, ponieważ warstwa egzekwowania została usunięta podczas testu.
Napisz każdy test regresji na najniższym poziomie, który jest w stanie udowodnić konkretne twierdzenie, które przedstawia. Im bardziej precyzyjne testy, tym w przypadku wystąpienia błędu łatwiej jest zlokalizować jego przyczynę na odpowiedniej fazie, zamiast musieć ponownie badać całą ścieżkę żądania od początku.
Rozwiązywanie problemów w jednym kierunku
Krótka, powtarzalna sekwencja pomaga uniknąć domysłów co do rozwiązań:
- Określ, czy chodzi o błąd przy uruchamianiu, błąd w momencie wysyłki żądania, czy o udane odpowiedzi, które cię zaskoczyły.
- Zidentyfikuj fazę, która jest za to odpowiedzialna: router, rozwiązywanie adresu żądania, struktura danych, zasady działania, wykonywanie kodu Prisma lub transport.
- Zredukuj proces odtworzenia błędu do jednej operacji, jednej struktury danych i jednego ciała żądania.
- Zbadaj argumenty na tym poziomie, który jest najbliżej źródła danego zachowania.
API generowane stają się znacznie łatwiejsze do zrozumienia, gdy zachowasz ich poszczególne fazy oddzielnie. Błędy konfiguracyjne powinny zostać wykryte przed udostępnieniem jakiegokolwiek ruchu. Prośby, które naruszają zasadę, powinny wskazywać dokładnie, którą część umowy złamały. Natomiast odpowiedź pomyślna powinna być sprawdzana na podstawie argumentów, które faktycznie zostały wysłane, oraz semantyki transporту opisanej dla niej, a nie tylko na podstawie kodu stanu.
Literatura pokrewna
- Naprawa błędu braku biblioteki libssl.so.1.1 w Prisma na Alpine Docker — Dowiedz się, dlaczego silnik zapytań Prisma zawiesza się na obrazach Docker opartych na Alpine z powodu błędu braku libssl, oraz jak go trwale naprawić.
- Budowa bezpiecznej pod względem typów API GraphQL z Prisma i Nexus w Node.js — Prześledź siedmioetapowy przewodnik po tworzeniu API GraphQL w Node.js, które łączy model danych Prisma z typami i rozwiązywaczami generowanymi przez Nexus.