Tworzenie pierwszego interfejsu API GraphQL
Opublikowany: 2022-03-11Przedmowa
Kilka lat temu Facebook wprowadził nowy sposób budowania interfejsów API zaplecza o nazwie GraphQL, który w zasadzie jest językiem specyficznym dla domeny do zapytań i manipulacji danymi. Początkowo nie zwracałem na to zbytniej uwagi, ale w końcu zaangażowałem się w projekt w Toptal, gdzie musiałem zaimplementować back-endowe API oparte na GraphQL. Wtedy zacząłem się rozwijać i nauczyłem się, jak zastosować wiedzę zdobytą podczas REST w GraphQL.
Było to bardzo ciekawe doświadczenie i w okresie wdrożenia musiałem przemyśleć standardowe podejścia i metodologie stosowane w REST API w sposób bardziej przyjazny dla GraphQL. W tym artykule postaram się podsumować najczęstsze problemy, które należy wziąć pod uwagę przy wdrażaniu API GraphQL po raz pierwszy.
Wymagane biblioteki
GraphQL został opracowany wewnętrznie przez Facebooka i opublikowany w 2015 roku. Później w 2018 roku projekt GraphQL został przeniesiony z Facebooka do nowo utworzonej GraphQL Foundation, obsługiwanej przez non-profit Linux Foundation, która utrzymuje i rozwija specyfikację języka zapytań GraphQL oraz referencje implementacja dla JavaScript.
Ponieważ GraphQL jest wciąż młodą technologią, a początkowa implementacja referencyjna była dostępna dla JavaScript, najbardziej dojrzałe biblioteki dla niego istnieją w ekosystemie Node.js. Istnieją również dwie inne firmy, Apollo i Prisma, które dostarczają narzędzia i biblioteki typu open source dla GraphQL. Przykładowy projekt w tym artykule będzie oparty na referencyjnej implementacji GraphQL dla JavaScript i bibliotek dostarczonych przez te dwie firmy:
- Graphql-js – referencyjna implementacja GraphQL dla JavaScript
- Apollo-server – serwer GraphQL dla Express, Connect, Hapi, Koa i innych
- Apollo-graphql-tools – Buduj, mock i łącz schemat GraphQL za pomocą SDL
- Prisma-graphql-middleware – Podziel swoje resolvery GraphQL na funkcje oprogramowania pośredniego
W świecie GraphQL opisujesz swoje interfejsy API za pomocą schematów GraphQL, a dla nich specyfikacja definiuje swój własny język o nazwie The GraphQL Schema Definition Language (SDL). SDL jest bardzo prosty i intuicyjny w użyciu, a jednocześnie niezwykle potężny i ekspresyjny.
Istnieją dwa sposoby tworzenia schematów GraphQL: podejście „najpierw kod” i podejście „najpierw schemat”.
- W podejściu code-first opisujesz swoje schematy GraphQL jako obiekty JavaScript oparte na bibliotece graphql-js, a SDL jest automatycznie generowany z kodu źródłowego.
- W podejściu schema-first opisujesz swoje schematy GraphQL w SDL i podłączasz logikę biznesową za pomocą biblioteki graphql-tools Apollo.
Osobiście wolę podejście schema-first i użyję go w przykładowym projekcie w tym artykule. Wdrożymy klasyczny przykład księgarni i stworzymy zaplecze, które zapewni API CRUD do tworzenia autorów i książek oraz API do zarządzania użytkownikami i uwierzytelniania.
Tworzenie podstawowego serwera GraphQL
Aby uruchomić podstawowy serwer GraphQL, musimy stworzyć nowy projekt, zainicjować go za pomocą npm i skonfigurować Babel. Aby skonfigurować Babel, najpierw zainstaluj wymagane biblioteki za pomocą następującego polecenia:
npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/node
Po zainstalowaniu Babel utwórz plik o nazwie .babelrc
w katalogu głównym naszego projektu i skopiuj tam następującą konfigurację:
{ "presets": [ [ "@babel/env", { "targets": { "node": "current" } } ] ] }
Edytuj także plik package.json
i dodaj następujące polecenie do sekcji scripts
:
{ ... "scripts": { "serve": "babel-node index.js" }, ... }
Po skonfigurowaniu Babel zainstaluj wymagane biblioteki GraphQL za pomocą następującego polecenia:
npm install --save express apollo-server-express graphql graphql-tools graphql-tag
Po zainstalowaniu wymaganych bibliotek, aby uruchomić serwer GraphQL z minimalną konfiguracją, skopiuj ten fragment kodu do naszego index.js
:
import gql from 'graphql-tag'; import express from 'express'; import { ApolloServer, makeExecutableSchema } from 'apollo-server-express'; const port = process.env.PORT || 8080; // Define APIs using GraphQL SDL const typeDefs = gql` type Query { sayHello(name: String!): String! } type Mutation { sayHello(name: String!): String! } `; // Define resolvers map for API definitions in SDL const resolvers = { Query: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } }, Mutation: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } } }; // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolvers maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); });
Następnie możemy uruchomić nasz serwer za pomocą polecenia npm run serve
, a jeśli przejdziemy w przeglądarce internetowej do adresu URL http://localhost:8080/graphql
, otworzy się interaktywna powłoka wizualna GraphQL o nazwie Playground, w której możemy wykonuj zapytania i mutacje GraphQL i zobacz dane wynikowe.
W świecie GraphQL funkcje API są podzielone na trzy zestawy, zwane zapytaniami, mutacjami i subskrypcjami:
- Zapytania są używane przez klienta do żądania danych, których potrzebuje z serwera.
- Mutacje są używane przez klienta do tworzenia/aktualizowania/usuwania danych na serwerze.
- Subskrypcje są używane przez klienta do tworzenia i utrzymywania połączenia z serwerem w czasie rzeczywistym. Umożliwia to klientowi pobieranie zdarzeń z serwera i odpowiednie działanie.
W naszym artykule omówimy tylko zapytania i mutacje. Subskrypcje to ogromny temat — zasługują na własny artykuł i nie są wymagane w każdej implementacji API.
Zaawansowane skalarne typy danych
Wkrótce po zabawie z GraphQL odkryjesz, że SDL zapewnia tylko prymitywne typy danych, a brakuje zaawansowanych skalarnych typów danych, takich jak Date, Time i DateTime, które są ważną częścią każdego interfejsu API. Na szczęście mamy bibliotekę, która pomaga nam rozwiązać ten problem i nazywa się graphql-iso-date. Po jego zainstalowaniu będziemy musieli zdefiniować w naszym schemacie nowe zaawansowane skalarne typy danych i połączyć je z implementacjami dostarczonymi przez bibliotekę:
import { GraphQLDate, GraphQLDateTime, GraphQLTime } from 'graphql-iso-date'; // Define APIs using GraphQL SDL const typeDefs = gql` scalar Date scalar Time scalar DateTime type Query { sayHello(name: String!): String! } type Mutation { sayHello(name: String!): String! } `; // Define resolvers map for API definitions in SDL const resolvers = { Date: GraphQLDate, Time: GraphQLTime, DateTime: GraphQLDateTime, Query: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } }, Mutation: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } } };
Oprócz daty i czasu istnieją również inne interesujące implementacje skalarnych typów danych, które mogą być przydatne w zależności od przypadku użycia. Na przykład jednym z nich jest graphql-type-json, który daje nam możliwość korzystania z dynamicznego typowania w naszym schemacie GraphQL oraz przekazywania lub zwracania niewpisanych obiektów JSON za pomocą naszego API. Istnieje również biblioteka graphql-scalar, która daje nam możliwość definiowania własnych skalarów GraphQL z zaawansowaną sanitacją/walidacją/transformacją.
W razie potrzeby możesz również zdefiniować niestandardowy typ danych skalarnych i użyć go w schemacie, jak pokazano powyżej. Nie jest to trudne, ale omówienie tego wykracza poza zakres tego artykułu — jeśli jesteś zainteresowany, możesz znaleźć bardziej zaawansowane informacje w dokumentacji Apollo.
Schemat dzielenia
Po dodaniu większej funkcjonalności do Twojego schematu, zacznie on rosnąć i zrozumiemy, że nie da się utrzymać całego zestawu definicji w jednym pliku i musimy podzielić go na małe kawałki, aby uporządkować kod i uczynić go bardziej skalowalnym dla większy rozmiar. Na szczęście funkcja konstruktora schematów makeExecutableSchema
, dostarczona przez Apollo, również akceptuje definicje schematów i mapy przeliczników w postaci tablicy. Daje nam to możliwość podzielenia naszego schematu i mapy przeliczników na mniejsze części. Dokładnie to zrobiłem w moim przykładowym projekcie; API podzieliłem na następujące części:
-
auth.api.graphql
– API do uwierzytelniania i rejestracji użytkowników -
author.api.graphql
– CRUD API dla wpisów autorów -
book.api.graphql
– CRUD API dla wpisów księgowych -
root.api.graphql
– Korzeń schematu i wspólne definicje (np. zaawansowane typy skalarne) -
user.api.graphql
– CRUD API do zarządzania użytkownikami
Podczas dzielenia schematu jest jedna rzecz, którą musimy wziąć pod uwagę. Jedna z części musi być schematem głównym, a pozostałe muszą rozszerzać schemat główny. Brzmi to skomplikowanie, ale w rzeczywistości jest całkiem proste. W schemacie głównym zapytania i mutacje są definiowane w następujący sposób:
type Query { ... } type Mutation { ... }
A w pozostałych definiuje się je tak:
extend type Query { ... } extend type Mutation { ... }
I to wszystko.
Uwierzytelnianie i autoryzacja
W większości implementacji API istnieje wymóg ograniczenia dostępu globalnego i zapewnienia pewnego rodzaju zasad dostępu opartych na regułach. W tym celu musimy wprowadzić w naszym kodzie: Uwierzytelnianie — aby potwierdzić tożsamość użytkownika — i Autoryzację , aby wymusić zasady dostępu oparte na regułach.
W świecie GraphQL, podobnie jak w świecie REST, generalnie do uwierzytelniania używamy JSON Web Token. Aby zweryfikować przekazany token JWT, musimy przechwycić wszystkie przychodzące żądania i sprawdzić na nich nagłówek autoryzacji. W tym celu podczas tworzenia serwera Apollo możemy zarejestrować funkcję jako przechwytywanie kontekstu, która zostanie wywołana z bieżącym żądaniem, które tworzy kontekst współdzielony przez wszystkie przeliczniki. Można to zrobić w ten sposób:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, context: ({ req, res }) => { const context = {}; // Verify jwt token const parts = req.headers.authorization ? req.headers.authorization.split(' ') : ['']; const token = parts.length === 2 && parts[0].toLowerCase() === 'bearer' ? parts[1] : undefined; context.authUser = token ? verify(token) : undefined; return context; } }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); });
Tutaj, jeśli użytkownik przekaże poprawny token JWT, weryfikujemy go i przechowujemy obiekt użytkownika w kontekście, który będzie dostępny dla wszystkich resolverów podczas wykonywania żądania.
Zweryfikowaliśmy tożsamość użytkownika, ale nasz interfejs API jest nadal dostępny na całym świecie i nic nie stoi na przeszkodzie, aby nasi użytkownicy mogli go wywołać bez autoryzacji. Jednym ze sposobów, aby temu zapobiec, jest sprawdzanie obiektu użytkownika w kontekście bezpośrednio w każdym przeliczniku, ale jest to podejście bardzo podatne na błędy, ponieważ musimy napisać dużo standardowego kodu i możemy zapomnieć o dodaniu sprawdzenia podczas dodawania nowego przelicznika . Jeśli przyjrzymy się frameworkom REST API, generalnie tego rodzaju problemy są rozwiązywane za pomocą interceptorów żądań HTTP, ale w przypadku GraphQL nie ma to sensu, ponieważ jedno żądanie HTTP może zawierać wiele zapytań GraphQL, a jeśli nadal dodamy dzięki temu uzyskujemy dostęp tylko do reprezentacji zapytania w postaci surowego ciągu i musimy ją przeanalizować ręcznie, co zdecydowanie nie jest dobrym podejściem. Ta koncepcja nie przekłada się dobrze z REST na GraphQL.
Potrzebujemy więc jakiegoś sposobu na przechwytywanie zapytań GraphQL, a ten sposób nazywa się prisma-graphql-middleware. Ta biblioteka pozwala nam uruchamiać dowolny kod przed lub po wywołaniu resolvera. Poprawia strukturę naszego kodu, umożliwiając ponowne wykorzystanie kodu i wyraźne oddzielenie problemów.
Społeczność GraphQL stworzyła już kilka niesamowitego oprogramowania pośredniego opartego na bibliotece oprogramowania pośredniczącego Prisma, która rozwiązuje niektóre specyficzne przypadki użycia, a do autoryzacji użytkowników istnieje biblioteka o nazwie graphql-shield, która pomaga nam stworzyć warstwę uprawnień dla naszego API.
Po zainstalowaniu graphql-shield możemy wprowadzić warstwę uprawnień dla naszego API w następujący sposób:
import { allow } from 'graphql-shield'; const isAuthorized = rule()( (obj, args, { authUser }, info) => authUser && true ); export const permissions = { Query: { '*': isAuthorized, sayHello: allow }, Mutation: { '*': isAuthorized, sayHello: allow } }
I możemy zastosować tę warstwę jako oprogramowanie pośredniczące do naszego schematu w następujący sposób:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); const schemaWithMiddleware = applyMiddleware(schema, shield(permissions, { allowExternalErrors: true })); // Build Apollo server const apolloServer = new ApolloServer({ schemaWithMiddleware }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })
Tutaj podczas tworzenia obiektu osłony ustawiamy allowExternalErrors
na true, ponieważ domyślnie zachowaniem osłony jest wyłapywanie i obsługa błędów, które występują w programach rozpoznawania nazw, a to nie było dopuszczalne zachowanie dla mojej przykładowej aplikacji.

W powyższym przykładzie ograniczyliśmy dostęp do naszego API tylko dla uwierzytelnionych użytkowników, ale tarcza jest bardzo elastyczna i korzystając z niej możemy zaimplementować bardzo bogaty schemat autoryzacji dla naszych użytkowników. Na przykład w naszej przykładowej aplikacji mamy dwie role: USER
i USER_MANAGER
, a tylko użytkownicy z rolą USER_MANAGER
mogą wywoływać funkcję administrowania użytkownikami. Realizuje się to w następujący sposób:
export const isUserManager = rule()( (obj, args, { authUser }, info) => authUser && authUser.role === 'USER_MANAGER' ); export const permissions = { Query: { userById: isUserManager, users: isUserManager }, Mutation: { editUser: isUserManager, deleteUser: isUserManager } }
Jeszcze jedną rzeczą, o której chcę wspomnieć, jest organizacja funkcji oprogramowania pośredniczącego w naszym projekcie. Podobnie jak w przypadku definicji schematów i map przeliczników, lepiej jest podzielić je na schemat i przechowywać w osobnych plikach, ale w przeciwieństwie do serwera Apollo, który akceptuje tablice definicji schematów i mapy przeliczników i łączy je dla nas, biblioteka oprogramowania pośredniego Prisma tego nie robi i akceptuje tylko jeden obiekt mapy oprogramowania pośredniego, więc jeśli je podzielimy, musimy je ponownie ręcznie zszyć. Aby zobaczyć moje rozwiązanie tego problemu, zobacz klasę ApiExplorer
w przykładowym projekcie.
Walidacja
GraphQL SDL zapewnia bardzo ograniczoną funkcjonalność sprawdzania poprawności danych wprowadzonych przez użytkownika; możemy tylko określić, które pole jest wymagane, a które opcjonalne. Wszelkie dalsze wymagania dotyczące walidacji musimy wdrożyć ręcznie. Możemy zastosować reguły walidacji bezpośrednio w funkcjach przelicznika, ale ta funkcjonalność tak naprawdę nie pasuje tutaj i jest to kolejny świetny przypadek użycia oprogramowania pośredniczącego GraphQL. Na przykład użyjmy danych wejściowych żądania rejestracji użytkownika, gdzie musimy sprawdzić, czy nazwa użytkownika jest poprawnym adresem e-mail, czy wprowadzone hasło jest zgodne, a hasło jest wystarczająco silne. Można to zaimplementować w następujący sposób:
import { UserInputError } from 'apollo-server-express'; import passwordValidator from 'password-validator'; import { isEmail } from 'validator'; const passwordSchema = new passwordValidator() .is().min(8) .is().max(20) .has().letters() .has().digits() .has().symbols() .has().not().spaces(); export const validators = { Mutation: { signup: (resolve, parent, args, context) => { const { email, password, rePassword } = args.signupReq; if (!isEmail(email)) { throw new UserInputError('Invalid Email address!'); } if (password !== rePassword) { throw new UserInputError('Passwords don\'t match!'); } if (!passwordSchema.validate(password)) { throw new UserInputError('Password is not strong enough!'); } return resolve(parent, args, context); } } }
I możemy zastosować warstwę walidatorów jako oprogramowanie pośredniczące do naszego schematu, wraz z warstwą uprawnień taką jak ta:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); const schemaWithMiddleware = applyMiddleware(schema, validators, shield(permissions, { allowExternalErrors: true })); // Build Apollo server const apolloServer = new ApolloServer({ schemaWithMiddleware }); apolloServer.applyMiddleware({ app })
N + 1 Zapytania
Innym problemem do rozważenia, który ma miejsce w przypadku interfejsów API GraphQL i jest często pomijany, jest liczba zapytań N+1. Ten problem występuje, gdy mamy relację jeden-do-wielu między typami zdefiniowanymi w naszym schemacie. Aby to zademonstrować, wykorzystajmy na przykład book API naszego przykładowego projektu:
extend type Query { books: [Book!]! ... } extend type Mutation { ... } type Book { id: ID! creator: User! createdAt: DateTime! updatedAt: DateTime! authors: [Author!]! title: String! about: String language: String genre: String isbn13: String isbn10: String publisher: String publishDate: Date hardcover: Int } type User { id: ID! createdAt: DateTime! updatedAt: DateTime! fullName: String! email: String! }
Widzimy tutaj, że typ User
ma relację jeden-do-wielu z typem Book
i ta relacja jest reprezentowana jako pole twórcy w Book
. Mapa przeliczników dla tego schematu jest zdefiniowana w następujący sposób:
export const resolvers = { Query: { books: (obj, args, context, info) => { return bookService.findAll(); }, ... }, Mutation: { ... }, Book: { creator: ({ creatorId }, args, context, info) => { return userService.findById(creatorId); }, ... } }
Jeśli wykonamy zapytanie o książki za pomocą tego interfejsu API i spojrzymy na dziennik instrukcji SQL, zobaczymy coś takiego:
select `books`.* from `books` select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? ...
Łatwo się domyślić — podczas wykonywania najpierw wywołano narzędzie przeliczeniowe dla zapytania o książki, które zwróciło listę książek, a następnie każdy obiekt książki został nazwany przelicznikiem pola twórcy, a to zachowanie spowodowało N+1 zapytań do bazy danych. Jeśli nie chcemy rozsadzać naszej bazy danych, takie zachowanie nie jest zbyt dobre.
Aby rozwiązać problem zapytań N+1, twórcy Facebooka stworzyli bardzo ciekawe rozwiązanie o nazwie DataLoader, które jest opisane na jego stronie README w następujący sposób:
„DataLoader to ogólne narzędzie, które może być używane jako część warstwy pobierania danych Twojej aplikacji, aby zapewnić uproszczony i spójny interfejs API dla różnych zdalnych źródeł danych, takich jak bazy danych lub usługi internetowe, za pośrednictwem przetwarzania wsadowego i buforowania”
Nie jest łatwo zrozumieć, jak działa DataLoader, więc najpierw zobaczmy przykład, który rozwiązuje powyższy problem, a następnie wyjaśnijmy stojącą za nim logikę.
W naszym przykładowym projekcie DataLoader jest zdefiniowany tak dla pola twórcy:
export class UserDataLoader extends DataLoader { constructor() { const batchLoader = userIds => { return userService .findByIds(userIds) .then( users => userIds.map( userId => users.filter(user => user.id === userId)[0] ) ); }; super(batchLoader); } static getInstance(context) { if (!context.userDataLoader) { context.userDataLoader = new UserDataLoader(); } return context.userDataLoader; } }
Po zdefiniowaniu UserDataLoader możemy zmienić resolver pola twórcy w następujący sposób:
export const resolvers = { Query: { ... }, Mutation: { ... }, Book: { creator: ({ creatorId }, args, context, info) => { const userDataLoader = UserDataLoader.getInstance(context); return userDataLoader.load(creatorId); }, ... } }
Po zastosowanych zmianach, jeśli ponownie wykonamy zapytanie books i spojrzymy na log instrukcji SQL, zobaczymy coś takiego:
select `books`.* from `books` select `users`.* from `users` where `id` in (?)
Tutaj widzimy, że zapytania bazy danych N+1 zostały zredukowane do dwóch zapytań, z których pierwsze wybiera listę książek, a drugie listę użytkowników prezentowanych jako twórcy na liście książek. Teraz wyjaśnijmy, jak DataLoader osiąga ten wynik.
Podstawową cechą DataLoadera jest przetwarzanie wsadowe. Podczas pojedynczej fazy wykonywania DataLoader zbierze wszystkie różne identyfikatory wszystkich indywidualnych wywołań funkcji ładowania, a następnie wywoła funkcję wsadową ze wszystkimi żądanymi identyfikatorami. Jedną ważną rzeczą do zapamiętania jest to, że instancje DataLoaders nie mogą być ponownie użyte, po wywołaniu funkcji wsadowej zwracane wartości będą buforowane w instancji na zawsze. W związku z tym zachowaniem musimy tworzyć nową instancję DataLoader w każdej fazie wykonania. W tym celu stworzyliśmy statyczną funkcję getInstance
, która sprawdza, czy instancja DataLoader jest prezentowana w obiekcie kontekstowym, i jeśli nie zostanie znaleziona, tworzy go. Pamiętaj, że nowy obiekt kontekstu jest tworzony dla każdej fazy wykonania i jest współdzielony przez wszystkie programy rozpoznawania nazw.
Funkcja ładowania wsadowego programu DataLoader akceptuje tablicę różnych żądanych identyfikatorów i zwraca obietnicę, która przekłada się na tablicę odpowiadających sobie obiektów. Pisząc funkcję ładowania wsadowego, musimy pamiętać o dwóch ważnych rzeczach:
- Tablica wyników musi mieć taką samą długość jak tablica żądanych identyfikatorów. Na przykład, jeśli zażądaliśmy identyfikatorów
[1, 2, 3]
, zwrócona tablica wyników musi zawierać dokładnie trzy obiekty:[{ "id": 1, “fullName”: “user1” }, { “id”: 2, “fullName”: “user2” }, { “id”: 3, “fullName”: “user3” }]
- Każdy indeks w tablicy wyników musi odpowiadać temu samemu indeksowi w tablicy żądanych identyfikatorów. Na przykład, jeśli tablica żądanych identyfikatorów ma następującą kolejność:
[3, 1, 2]
, to zwracana tablica wyników musi zawierać obiekty dokładnie w tej samej kolejności:[{ "id": 3, “fullName”: “user3” }, { “id”: 1, “fullName”: “user1” }, { “id”: 2, “fullName”: “user2” }]
W naszym przykładzie upewniamy się, że kolejność wyników jest zgodna z kolejnością żądanych identyfikatorów z następującym kodem:
then( users => userIds.map( userId => users.filter(user => user.id === userId)[0] ) )
Bezpieczeństwo
I na koniec chciałbym wspomnieć o bezpieczeństwie. Dzięki GraphQL możemy tworzyć bardzo elastyczne interfejsy API i dać użytkownikowi bogate możliwości wyszukiwania danych. Daje to dość dużą moc po stronie klienta aplikacji i, jak powiedział wujek Ben, „z wielką mocą wiąże się wielka odpowiedzialność”. Bez odpowiednich zabezpieczeń złośliwy użytkownik może przesłać kosztowne zapytanie i spowodować atak DoS (Denial of Service) na nasz serwer.
Pierwszą rzeczą, jaką możemy zrobić, aby chronić nasze API, jest wyłączenie introspekcji schematu GraphQL. Domyślnie serwer API GraphQL udostępnia możliwość introspekcji całego schematu, który jest zwykle używany przez interaktywne powłoki wizualne, takie jak GraphiQL i Apollo Playground, ale może być również bardzo przydatny dla złośliwego użytkownika do konstruowania złożonego zapytania opartego na naszym API . Możemy to wyłączyć, ustawiając parametr introspection
na false podczas tworzenia serwera Apollo:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, introspection: false }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })
Następną rzeczą, jaką możemy zrobić, aby chronić nasze API, jest ograniczenie głębokości zapytania. Jest to szczególnie ważne, jeśli mamy cykliczną relację między naszymi typami danych. Na przykład w naszym przykładzie typ projektu Author
ma książki terenowe, a typ Book
ma autorów pól. Jest to wyraźnie zależność cykliczna i nic nie stoi na przeszkodzie, aby złośliwy użytkownik napisał zapytanie w ten sposób:
query { authors { id, fullName books { id, title authors { id, fullName books { id, title, authors { id, fullName books { id, title authors { ... } } } } } } } }
Oczywiste jest, że przy odpowiednim zagnieżdżeniu takie zapytanie może łatwo rozwalić nasz serwer. Aby ograniczyć głębokość zapytań, możemy skorzystać z biblioteki o nazwie graphql-depth-limit. Po zainstalowaniu możemy zastosować ograniczenie głębokości podczas tworzenia serwera Apollo, na przykład:
// Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, introspection: false, validationRules: [ depthLimit(5) ] }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })
Tutaj ograniczyliśmy maksymalną głębokość zapytań do pięciu.
Post Scriptum: Przejście od REST do GraphQL jest interesujące
W tym samouczku starałem się zademonstrować typowe problemy, które napotkasz, gdy zaczniesz implementować API GraphQL. Jednak niektóre jego fragmenty dostarczają bardzo płytkich przykładów kodu i zarysowują jedynie powierzchnię omawianego problemu, ze względu na jego rozmiar. Z tego powodu, aby zobaczyć bardziej kompletne przykłady kodu, zapoznaj się z repozytorium Git mojego przykładowego projektu GraphQL API: graphql-example.
Na koniec chcę powiedzieć, że GraphQL to naprawdę interesująca technologia. Czy zastąpi REST? Nikt nie wie, może jutro w szybko zmieniającym się świecie IT pojawi się lepsze podejście do tworzenia API, ale GraphQL naprawdę należy do kategorii ciekawych technologii, których zdecydowanie warto się nauczyć.