Przykładowa implementacja GraphQL z wykorzystaniem nodejs, expressjs i mongodb

Po artykułach mocno teoretycznych dotyczących GraphQL (wstęp i definicja schematu) czas przejść do czegoś konkretniejszego i przedstawić jakąś przykładową implementację z wykorzystaniem tego rozwiązania. Co prawda na tym blogu pojawiały się już opisy użycia GraphQLa w aplikacji PHPowej w „zapiskach z pola bitwy” podczas powstawania projektu Krauza, ale chciałbym uporządkować temat i przedstawić implementację z wykorzystaniem najlepszej biblioteki do GraphQL, którą stworzył sam Facebook

Na tapet zabierzemy przykłady zawarte w poprzednich wpisach teoretycznych czyli pozostaniemy w tematyce aplikacji przechowującej informacje o książkach i ich autorach. Ten post będzie pierwszym z serii opisujących implementację API z wykorzystaniem GraphQL. Dlatego dzisiaj można spodziewać się podstaw związanych z rozstawieniem projektu.

Wybór technologii

GraphQL to tylko specyfikacja i istnieje do niej pełno implementacji w praktycznie każdej technologii, która tego wymaga. Ale większość z nich to biblioteki opensource tworzone przez społeczność, czasem tylko przez jedną osobę i nie są to na tyle pewne rozwiązania aby brać je pod uwagę podczas użycia w projekcie. Moje pierwsze zetknięcie z biblioteką do PHP trochę mnie do niej zniechęciło bo często zmieniały się kluczowe elementy API (wiem, że to wersja rozwojowa, ale mimo wszystko trochę demotywujące).

Dlatego postanowiłem stworzyć testowy projekt (sandbox) wykorzystujący GraphQL w oparciu o najlepszą bibliotekę, która powstała dla nodejs i została stworzona przez samego Facebooka jako implementacja referencyjna do specyfikacji.

Do przechowywania danych wybrałem MongoDB ze względu na to, że bardzo dobrze współdziała z nodejs i można znaleźć dobre wsparcie chociażby w heroku jeśli chciałbym opublikować ten sandbox. Dodatkowo dla łatwiejszego modelowania dokumentów wybrałem bibliotekę Mongoose.

Oczywiście do serwowania HTTP bez niespodzianek – expressjs, który jest wciąż najpopularniejszy w świecie nodejs.

Implementacja

Po stronie bazy danych

Zacznijmy od zadeklarowania struktury dokumentów jakie będą przechowywane w bazie danych. W pierwszej iteracji nie będzie to nic skomplikowanego bo w API spodziewamy się tylko autorów i książek z relacją jeden do wielu (w celu uproszczenia na tę chwilę nie wnikamy, że jakaś książka może mieć wielu autorów i zakładamy, że książka ma jednego autora, za to autor może posiadać wiele książek).

models/author.js

'use strict';

import mongoose from 'mongoose';

const Schema = mongoose.Schema;
const SchemaObjectId = Schema.Types.ObjectId;
const ObjectId = mongoose.Types.ObjectId;

const authorSchema = Schema({
    _id: { type: SchemaObjectId, default: () => new ObjectId() },
    name: String,
    bio: String,
    birthday: Date,
    sex: String,
    books: [{ type: SchemaObjectId, ref: 'Book' }]
});

export default mongoose.model('Author', authorSchema);

models/book.js

'use strict';

import mongoose from 'mongoose';

const Schema = mongoose.Schema;
const SchemaObjectId = Schema.Types.ObjectId;

const bookSchema = new Schema({
    title: String,
    author: { type: SchemaObjectId, ref: 'Author' },
    shortDescription: String,
    description: String,
    pages: Number,
    isbn: String,
    releaseDate: Date
});

export default mongoose.model('Book', bookSchema);

Schemat GraphQL

Dobra, mamy jakieś założenie co do przechowywanej struktury dokumentów w bazie danych, to teraz można odwzorować ją w naszym API oraz zastanowić się na jakie akcje chcemy pozwolić użytkownikom. W tym podrozdziale nie będę się zagłębiać w tłumaczenie poszczególnych elementów bo jak już wspominałem – wstęp teoretyczny pojawił się na blogu wcześniej i jeszcze raz zachęcam do zobaczenia posta związanego z definiowaniem schematu.

Type

Na początek zadeklarujmy najważniejsze byty w GraphQL – ObjectType, które będą definiować pola encji Book i Author.

graphql/type/bookType.js

'use strict';

import { GraphQLObjectType, GraphQLString, GraphQLID, GraphQLInt } from 'graphql';
import authorType from './authorType'

export default new GraphQLObjectType({
    name: 'Book',
    fields: () => ({
        id: {type: GraphQLID},
        title: {type: GraphQLString},
        shortDescription: {type: GraphQLString},
        description: {type: GraphQLString},
        pages: {type: GraphQLInt},
        isbn: {type: GraphQLString},
        releaseDate: {type: GraphQLString},
        author: {type: authorType}
    })
});

graphql/type/authorType.js

'use strict';

import { GraphQLObjectType, GraphQLString, GraphQLID, GraphQLList } from 'graphql';
import bookType from './bookType';

export default new GraphQLObjectType({
    name: 'Author',
    fields: () => ({
        id: {type: GraphQLID},
        name: {type: GraphQLString},
        bio: {type: GraphQLString},
        birthday: {type: GraphQLString},
        sex: {type: GraphQLString},
        books: {type: new GraphQLList(bookType)}
    })
});

Query

Mając już zadeklarowane pierwsze typy można od razu zdefiniować QueryType, który jest obowiązkowym elementem w schemacie GraphQL.

graphql/root/queryType.js

'use strict';

import { GraphQLObjectType, GraphQLID, GraphQLList } from 'graphql';

import authorType from './../type/authorType';
import bookType from './../type/bookType';

import { getAllAuthors, getAuthorById } from './../../controllers/authorsController';
import { getBookById, getAllBooks } from './../../controllers/booksController';

export default new GraphQLObjectType({
    name: 'Query',
    description: 'Root type for queries',
    fields: {
        authors: {
            type: new GraphQLList(authorType),
            description: 'List of all authors',
            resolve: getAllAuthors
        },
        author: {
            type: authorType,
            description: 'Get author by ID',
            args: { id: { type: GraphQLID } },
            resolve: getAuthorById
        },
        book: {
            type: bookType,
            description: 'Get book by ID',
            args: { id: { type: GraphQLID } },
            resolve: getBookById
        },
        books: {
            type: new GraphQLList(bookType),
            description: 'List of all books',
            resolve: getAllBooks
        }
    }
});

Powyżej zadeklarowane zostały 4 możliwe akcje do wykonania w naszym API:

  • pobranie listy wszystkich autorów
  • pobranie informacji o jednym autorze na podstawie ID
  • pobranie wszystkich książek
  • pobranie informacji o książce na podstawie ID

Jak można zauważyć, w polach author i book zdefiniowane są argumenty, w obu wypadkach oczekiwany jest tylko jeden argument: id (args: { id: { type: GraphQLID } }).

Mutation + Input

Powyżej zadeklarowane akcje jako Query pozwalają tylko na odczyt danych, w aplikacjach jednak bardzo często trzeba dodawać lub modyfikować dane. Tutaj z pomocą przychodzi typ Mutation, który wygląda i działa bardzo podobnie do Query. Na początek zróbmy najprostszy przykład i zezwólmy na dodawanie nowych książek i autorów (bez ich edytowania).

graphql/root/mutationType.js

'use strict';

import { GraphQLObjectType, GraphQLID, GraphQLNonNull } from 'graphql';

import authorType from './../type/authorType';
import bookType from './../type/bookType';
import authorInput from './../input/authorInput';
import bookInput from './../input/bookInput';

import { addAuthor } from './../../controllers/authorsController';
import { addBook } from './../../controllers/booksController';

export default new GraphQLObjectType({
    name: 'Mutation',
    description: 'Root type for mutations',
    fields: {
        addAuthor: {
            type: authorType,
            description: 'Create a new author',
            args: { input: { type: authorInput } },
            resolve: addAuthor
        },
        addBook: {
            type: bookType,
            description: 'Create a new book',
            args: {
                authorId: {type: new GraphQLNonNull(GraphQLID)},
                input: { type: bookInput }
            },
            resolve: addBook
        }
    }
});

Jak widać w powyższym przykładzie w obu przypadkach oczekujemy argumentów, na podstawie których będzie można utworzyć nowe encje w naszej bazie danych. Pojawiły się nowe elementy o nazwie authorInput i bookInput – są to definicje typu Input.

graphql/input/authorInput.js

'use strict';

import { GraphQLInputObjectType, GraphQLString, GraphQLNonNull } from 'graphql';

export default new GraphQLInputObjectType({
    name: 'AuthorInput',
    fields: () => ({
        name: {type: new GraphQLNonNull(GraphQLString)},
        bio: {type: GraphQLString},
        birthday: {type: GraphQLString},
        sex: {type: GraphQLString}
    })
});

graphql/input/bookInput.js

'use strict';

import { GraphQLInputObjectType, GraphQLString, GraphQLInt, GraphQLNonNull } from 'graphql';

export default new GraphQLInputObjectType({
    name: 'BookInput',
    fields: () => ({
        title: {type: new GraphQLNonNull(GraphQLString)},
        shortDescription: {type: GraphQLString},
        description: {type: GraphQLString},
        pages: {type: GraphQLInt},
        isbn: {type: GraphQLString},
        releaseDate: {type: GraphQLString}
    })
});

Schemat

Ostatni element, który pozwala spiąć całe API do kupy to definicja schematu, którą ostatecznie będzie trzeba wpiąć w nasz serwer HTTP (w tym wypadku z frameworkiem expressjs).

graphql/schema.js

'use strict';

import { GraphQLSchema } from 'graphql';
import graphqlHTTP from 'express-graphql';

import queryType from './root/queryType';
import mutationType from './root/mutationType';

export function init() {
    const schema = new GraphQLSchema({
        query: queryType,
        mutation: mutationType
    });

    return graphqlHTTP({
        schema: schema,
        graphiql: true
    });
}

W 17 linii można ustawić czy chcemy uruchomić dla naszego API aplikację graphiql, która pozwala na przyjemne zarządzanie i eksplorowanie API.

Ostatnim krokiem jest tylko ustawienie definicji schematu jako middleware w aplikacji expressowej.

index.js

'use strict';

import mongoose from 'mongoose';
import express from 'express';
import dotenv from 'dotenv';

import {init as initGraphQLSchema} from './graphql/schema';

dotenv.config();

mongoose.connect(process.env.MONGODB_URI,  {
    useMongoClient: true,
    promiseLibrary: require('bluebird')
});

const app = express();
app.use('/', initGraphQLSchema());

app.listen(process.env.PORT || 4000);

Akcje – resolver

Wróćmy jeszcze do definiowania typów Query i Mutation, podczas definiowania elementów API pojawiały się tam takie słowa kluczowe jak resolve i kierowały one do innych funkcji. To tak naprawdę powołuje do prawdziwego życia API oparte o GraphQL. Co nam po zdefiniowaniu schematu, jeśli nic się nie dzieje? To właśnie w miejscu resolve należy wykonać operacje, które zwrócą odpowiednie wyniki dla naszego API.

Na tę chwilę postanowiłem stworzyć kontrolery, które grupują akcje danego typu (booksController i authorsController) i wygląda to mniej więcej tak:

controllers/authorsController.js

'use strict';

import mongoose from 'mongoose';
import Author from './../models/Author';

export function getAuthorById(root, {id}) {
    return Author.findById(new mongoose.Types.ObjectId(id)).populate('books');
}

export function getAllAuthors() {
    return Author.find().populate('books').exec();
}

export function addAuthor(root, {input}) {
    let author = new Author(input);
    return author.save();
}

controllers/booksController.js

'use strict';

import mongoose from 'mongoose';

import Author from './../models/Author';
import Book from './../models/Book';

export function getBookById(root, {id}) {
    return Book.findById(new mongoose.Types.ObjectId(id)).populate('author');
}

export function getAllBooks() {
    return Book.find().populate('author').exec();
}

export async function addBook(root, {authorId, input}) {
    const authorObjectId = new mongoose.Types.ObjectId(authorId);
    const author = await Author.findById(authorObjectId);

    if (!author) {
        throw new Error('Author is not exists');
    }

    input.author = authorObjectId;
    const book = await new Book(input).save();

    author.books.push(book);
    await author.save();

    return getBookById(null, book);
}

Podsumowanie

Chciałbym aby dzisiejszy post był wstępem do ciekawszych omówień pracy ze standardem GraphQL, które mam w planach napisać w przyszłości. Mam nadzieję, że ten post mimo iż nie zawiera skomplikowanych rozwiązań okaże się pomocny i będzie traktowany jako podstawa do późniejszych postów. Cały kod źródłowy dostępny jest na repozytorium graphql-nodejs-sandbox w serwisie GitHub.

Dzisiejszy post jest pierwszą iteracją projekciku, który powstaje przy okazji aby lepiej obrazować działanie GraphQLa dlatego też nie wiem jeszcze w jakim kierunku pójdą zmiany, na dzisiaj jest to bardzo proste i minimalistyczne rozwiązanie, ale być może w przyszłości zostaną wprowadzone zmiany, które będą się znacząco różniły od stanu bieżącego, dlatego postanowiłem wprowadzić TAGa 1.0.0 w repo aby zachować aktualny stan.

Programista skupiony głównie wokół technologii webowych, ale nie przywiązujący się do konkretnych języków i narzędzi. Skoncentrowany na ciągłym rozwoju, zwolennik ruchu Software Crafmanship. Na codzień pracując w DAZN ma okazję rozwijać interesujący projekt do streamingu wydarzeń sportowych. Prywatnie fan sportu, a szczególnie piłki nożnej. Po godzinach próbuje również swoich sił w piwowarstwie domowym.
PODZIEL SIĘ