W poprzednich postach opisywałem już podstawowe różnice, które wprowadza GraphQL w stosunku do tworzenia API w oparciu o standard REST. Tym razem chciałbym wziąć na tapet kolejną kwestię, która wprowadza w konsternację osoby stawiające pierwsze kroki z GraphQL. Mowa tutaj o obsłudze błędów, a dokładniej odpowiedziach jakie zwraca serwer po napotkaniu „problemów”.
Możesz zawęzić znajomość kodów statusu
Standard HTTP oferuje całą gamę kodów odpowiedzi serwera, z których spora część jest dobrze znana zarówno twórcom aplikacji backendowych jak i frontendowych. Pierwsi z nich powinni zaprojektować aplikację w taki sposób aby zwracała odpowiedni kod na zdarzenie po stronie serwera. Z kolei aplikacje klienckie powinny być w stanie interpretować te odpowiedzi.
Każdy już przywykł do tego typu wykorzystania kodów odpowiedzi, ale GraphQL po raz kolejny rezygnuje z utartych standardów. Tutaj założeniem jest, że serwer odpowiada zawsze kodem 200 OK
. Wyjątkiem jest kod 400
, który pojawia się w momencie błędnego wpisania zapytania (nierozpoznawanego w zdefiniowanym schemacie GraphQL).
Pole errors
zamiast kodów odpowiedzi
Implementacji GraphQL dla node.js posiada globalny error handler i przechwytuje każdy błąd, który się wydarzy w aplikacji po wysłaniu zapytania do serwera. Przechwycone wyjątki są opakowane w obiekt GraphQLError
i przekazywane dalej. W momencie gdy taki Error
się pojawi to zmieniony jest format odpowiedzi wysyłanej klientowi.
Aby to zobrazować wykorzystamy aplikację, która powstała podczas tworzenia artykułu o wstępie do GraphQL. Na początek zobaczmy jak wygląda sytuacja w momencie poprawnego zapytania gdzie chcemy wyświetlić dane o autorze na podstawie pewnego uuid.
Z kolei zły format uuid spowoduje wywołanie błędu w Mongoose, który zostanie przechwycony przez GraphQL i zwróci odpowiedni komunikat klientowi:
Z powyższych dwóch przykładów wyraźnie można zauważyć, że w sytuacji poprawnego wyniku zapytania klient może spodziewać się odpowiedzi wewnątrz obiektu data
. Jeśli jednak pojawia się jakiś problem to w odpowiedzi JSON z serwera pojawia się lista errors
z informacjami o błędzie. Warto też zauważyć, że zawsze zwracana jest 200
jako kod odpowiedzi – niezależnie od tego czy wynik jest poprawny czy nie.
Formatowanie błędnych odpowiedzi
W poprzednim przykładzie można było zobaczyć format odpowiedzi po napotkaniu błędu – domyślnie GraphQL zwraca za dużo informacji klientowi. Lepszym rozwiązaniem będzie zalogowanie prawidłowej informacji o błędzie, jednak klient powinien otrzymać informację o problemie bez szczegółów implementacyjnych.
Wyobraźmy sobie, że w aplikacji wywołany zostanie błąd systemowy, który przez nieuwagę nie został przechwycony (ale jak to?!), dla przykładu może to być próba wczytania nieistniejącego pliku. Wynik takiego błędu wyświetli poniższą odpowiedź:
{ "errors": [ { "message": "ENOENT: no such file or directory, open '/missing/file'", "locations": [ { "line": 2, "column": 3 } ], "path": [ "author" ] } ], "data": { "author": null } }
Czy użytkownik musi być informowany o takich szczegółach? Na pewno nie.
Parametr formatError
Jedną z dostępnych opcji podczas inicjalizacji obsługi GraphQL w express.js jest możliwość zdefiniowania formatu wyświetlanych błędów. Z pomocą przychodzi parametr formatError
.
Załóżmy, że nie chcemy pokazywać użytkownikom naszych błędów i decydujemy się „przykryć” każdy wyjątek swoim generycznym komunikatem.
app.use('/', graphqlHTTP({ schema: schemaFactory.create(), graphiql: configurationProvider.useGraphiql(), formatError: (error) => { console.error(error); return { message: 'Something went wrong' }; } }));
W rezultacie każdy błąd aplikacji zwróci podobną odpowiedź:
{ "errors": [ { "message": "Something went wrong" } ], "data": { "author": null } }
Czy to oczekiwany wynik? Wydaje się, że tak. Ale czy na pewno?
Nie wylewajmy dziecka z kąpielą
Powyższy przykład skutecznie odciął wszelkie komunikaty pochodzące z aplikacji w momencie kiedy poszło coś źle. Na tyle skutecznie, że pozbawiliśmy odbiorców istotnych informacji. Brak szukanego elementu w bazie? Trudno, „Something went wrong”. Źle wpisane zapytanie GraphQL? No cóż, domyśl się… „Something went wrong” 🙂
Dlatego aby uniknąć wylania przysłowiowego dziecka z kąpielą warto zrobić to dobrze. Wiemy, że GraphQL przechwytuje wszystkie wyjątki i opakowuje je we własny error GraphQLError
. Posiada on parametr originalError
, który zawiera pierwotnie przechwycony błąd. Jeśli wartość tego parametru tu jest pusta to znaczy, że błąd pochodzi z GraphQL – na przykład zostało wpisane niepoprawne zapytanie. Dzięki tej wiedzy możemy lepiej zaprojektować odpowiedź, którą otrzyma klient.
function formatError(error) { console.error('GraphQL Error', error); if (error.originalError) { return { message: 'Something went wrong' } } return { message: error.message }; }
Dzięki takiemu rozwiązaniu nakładamy generyczną odpowiedź „Something went wrong” dla wszystkich błędów systemowych. Błędy pochodzące z GraphQL zwrócą oryginalną wiadomość aby użytkownik mógł zinterpretować ją i poprawić błędne dane.
Symulacja kodów odpowiedzi
Jeśli zależy nam na utrzymaniu konwencji kodów odpowiedzi ze standardu HTTP i interpretować je w jakiś sposób po stronie klienta można dodać dodatkowe pole w obiekcie zwracanym w funkcji formatError
. Dla przykładu można to rozwiązać w taki sposób:
function formatError(error) { console.error('GraphQL Error', error); if (error.originalError) { return { message: 'Something went wrong', statusCode: 500 } } return { message: error.message, statusCode: 400 }; }
Przez co w odpowiedzi pojawią się odpowiednie parametry.
{ "errors": [ { "message": "Something went wrong", "statusCode": 500 } ], "data": { "author": null } }
Implementacja własnych wyjątków
Oprócz wyjątków stricte technicznych w naszych aplikacjach istnieją również te związane z logiką, jak np. po przesłaniu niepoprawnych danych. W tym wypadku również dobrze by było przekazać użytkownikowi informacje co poszło źle. Do tego zadania również wykorzystamy funkcję formatError
i error handler z GraphQLa.
Zacznijmy od utworzenia „abstrakcyjnego” wyjątku, po którym będą dziedziczyły wszystkie używane przez nas wyjątki. Pozwoli to na łatwe przechwycenie go przed wysłaniem wiadomości do użytkownika, a dodatkowo wymusi konwencję względem wszystkich wyjątków. Myślę, że dobrym pomysłem jest ustawienie od razu kodu odpowiedzi w każdym typie wyjątku co pozwoli zaoszczędzić trochę czasu podczas formatowania odpowiedzi.
class AppError extends Error { constructor(message, statusCode) { super(); this.message = message; this.code = statusCode; } }
Następnie można już zaimplementować jakiś konkretny wyjątek, np. który będzie zwracany w momencie błędnej walidacji.
class ValidationError extends AppError { constructor(message) { super(message, 400); } }
Teraz można w prostu sposób użyć nowo utworzonej klasy w odpowiednim miejscu. W poniższym przykładzie sprawdzamy czy książka, którą chcemy dodać do bazy posiada więcej niż 0 stron.
if (Number.isInteger(inputData.pages) && inputData.pages > 0) { throw new ValidationError('Book must have more than 0 pages'); }
Następnie pojawiającą się już wcześniej funkcję formatError
można rozszerzyć o obsługę wyjątków pochodzących z aplikacji. Dzięki temu, że stworzyliśmy jedną abstrakcyjną klasę, która zawiera od razu wiadomość i status kodu wystarczy tylko sprawdzić czy przechwycony wyjątek jest odpowiedniego typu i można bezpośrednio wyświetlić zawartość.
function formatError(error) { const originalError = error.originalError; console.error('GraphQL Error', error); if (originalError) { if (originalError instanceof AppError) { return { message: originalError.message, statusCode: originalError.code }; } return { message: 'Something went wrong', statusCode: 500 } } return { message: error.message, statusCode: 400 }; }
W taki sposób wszystkie warianty zostały pokryte i w momencie gdy chcemy zwrócić oryginalny komunikat to go wyświetlamy, a kiedy chcemy żeby był „przykryty” to wstawiamy generyczny tekst błędu.
Endpoint zawsze zwróci status HTTP równy 200 OK
, ale klient musi sprawdzić czy w odpowiedzi znajduje się pole errors
, które sugeruje, że coś poszło źle. Dzięki wprowadzonemu jednakowego formatu odpowiedzi klient może w odpowiedni sposób przetworzyć wynik.
Podsumowanie
Implementacje GraphQL nie wspierają standardowych kodów odpowiedzi HTTP jak to ma w wypadku REST API. Zamiast tego zaproponowano aby w odpowiedziach z serwera zawsze trzymać się konwencji. Jeśli we zwracanym wyniku pojawia się pole errors
wiedz, że coś się dzieje! Jeśli dobrze zaprojektujesz format błędnych odpowiedzi to zaoszczędzisz sporo czasu i energii – zarówno sobie jak i odbiorcom.
Kody statusu faktycznie mogą stać się niepotrzebne. Jeśli na początku będzie Ci ich brakowało to z pewnością efekt przyzwyczajenia, ale wraz z czasem stanie się to normalne. Przecież używasz GraphQL… Musi być inaczej 😉