Crear una API con GraphQL

Antes de mis vacaciones escribí un artículo sobre qué es GraphQL y por qué debería importarte. Hoy quiero crear una API con GraphQL contigo paso a paso. Existen diferentes frameworks que nos pueden ayudar, como por ejemplo: Relay, GraphCool, Apollo, TypeGraphQL, etcétera. En este caso voy a hacer uso de Apollo Server, aunque el funcionamiento es bastante similar en todos ellos.

Crea el proyecto

Lo primero que debes hacer es crear el esqueleto de tu proyecto. En este ejemplo lo llamaré myfeedapp-api, ya que va a tratar de productos alimentarios.

mkdir myfeedapp-api
cd myfeedapp-api

Las dependencias que vamos a necesitar son apollo-server, graphql y mongodb, ya que nuestros datos van a estar alojados en un Mongo.

npm init -y
npm install apollo-server graphql mongodb

Ya tienes todo lo que necesitas para empezar a construir tu API. Esta se compondrá principalmente de tres partes: el esquema, el dataset y los resolvers. No te preocupes, veremos paso a paso cada uno de ellos 🙂

Paso 1: Construir el esquema

Toda API que trabaje con GraphQL se apoya en un esquema que define qué datos y cómo los proporciona. Siempre es recomendable empezar por esta parte, ya que todo lo demás se basará en ello. Creo una carpeta src y añado un archivo llamado schema.js con este contenido:

//src/schema.js
const { gql } = require('apollo-server');
const typeDefs = gql`
    enum TypeOfProduct{
        UltraProcessed
        WellProcessed
        RealFood
    }
    type NutritionFact{
        name: String!
        amount: Float!
    }
    type Product{
        _id: String! #It's a string because of MongoDB
        name: String!
        brand: String!
        market: [String!]!
        amount: Int!
        barcode: String
        imageUrl: String
        ingredients: [String!]!
        nutritionFacts: [NutritionFact!]!
        typeOfProduct: TypeOfProduct!
    }
    type Query {
        products(name: String): [Product!]!
        product(id: String!): Product!
        numberOfProducts(typeOfProduct: String): Int!
    }
    input NutritionFactInput{
        name: String!
        amount: Float!
    }
    type Mutation{
        createProduct(           
            name: String!,
            brand: String!,
            market: [String!]! 
            amount: Int!,
            barcode: String,
            imageUrl: String,
            ingredients: [String!]!,
            nutritionFacts: [NutritionFactInput!]!,
            typeOfProduct: TypeOfProduct
            ): Product!
        
        editProduct(
            _id: String!, 
            name: String,
            brand: String,
            market: [String],
            amount: Int,
            barcode: String,
            imageUrl: String,
            ingredients:  [String],
            typeOfProduct: TypeOfProduct,
            nutritionFacts: [NutritionFactInput]
        ): Product
        deleteProduct(_id: String!) : Boolean
    }
    type Subscription{
        newProduct: Product!
    }
`;
module.exports = typeDefs;

Hay muchas cosas aquí, pero quería que vieras un ejemplo de lo que puede ser una API más o menos completa. En primer lugar, se utiliza el tag template gql para generar el esquema en base a la cadena que le sigue en el lenguaje SDL (Schema Definition Language). Podemos dividirla en cuatro partes:

1. Los tipos que devolverá nuestra API

    enum TypeOfProduct{
        UltraProcessed
        WellProcessed
        RealFood
    }
    type NutritionFact{
        name: String!
        amount: Float!
    }
    type Product{
        _id: String! #It's a string because of MongoDB
        name: String!
        brand: String!
        market: [String!]!
        amount: Int!
        barcode: String
        imageUrl: String
        ingredients: [String!]!
        nutritionFacts: [NutritionFact!]!
        typeOfProduct: TypeOfProduct!
    }

Como ves, podemos definir enumerables (TypeOfProduct), objetos complejos como NutritionFact y Product. Son los tipos que serán devueltos por nuestra API. Estos, a su vez, están compuestos por los que llamamos Scalar types, que básicamente son: ID, String, Int, Float y Boolean, además de otros complejos (como Product que contiene NutritionFacts). Cuando vemos un tipo de elemento, como por ejemplo String, seguido de una exclamación (String!), esto significa que no puede ser nulo. Si tenemos dicho tipo enmarcado entre corchetes ([String]) se trata de un array de elementos de ese tipo. Si además este tiene exclamación dentro y fuera del mismo ([String!]!) significa que la lista no puede estar vacía, ni con nulos.

2. El tipo Query

    type Query {
        products(name: String): [Product!]!
        product(id: String!): Product!
        numberOfProducts(typeOfProduct: String): Int!
    }

Se trata de un tipo especial. Este nos va a permitir definir qué se puede consultar y qué es lo que nos va a devolver, como por ejemplo todos los productos, un producto por id o el número total de productos que tenemos. El valor que aparece después de los dos puntos es lo que nos devolverá al lanzar la consulta.

3. Tipo Mutation

type Mutation{
        createProduct(           
            name: String!,
            brand: String!,
            market: [String!]! 
            amount: Int!,
            barcode: String,
            imageUrl: String,
            ingredients: [String!]!,
            nutritionFacts: [NutritionFactInput!]!,
            typeOfProduct: TypeOfProduct
            ): Product!
        
        editProduct(
            _id: String!, 
            name: String,
            brand: String,
            market: [String],
            amount: Int,
            barcode: String,
            imageUrl: String,
            ingredients:  [String],
            typeOfProduct: TypeOfProduct,
            nutritionFacts: [NutritionFactInput]
        ): Product
        deleteProduct(_id: String!) : Boolean
    }

Este tipo se encarga de definir las operaciones que alterarán nuestros recursos, es decir: las creaciones, modificaciones y eliminaciones. Por ejemplo, es interesante saber que cuando necesitamos crear o modificar un elemento complejo de nuestro objeto, como es NutritionFacts, es necesario crear una nueva definición del tipo input.

    input NutritionFactInput{
        name: String!
        amount: Float!
    }

Se trata de una forma más cómoda de pasar variables complejas a la función de tipo mutation. En este caso, al tratarse de un array de objetos, con name y amount como propiedades, encaja perfectamente su uso. Como ves, gracias a este apartado podremos crear productos (createProduct), modificarlos (editProduct) y eliminarlos (deleteProduct).

4. Tipo Subscription

    type Subscription{
        newProduct: Product!
    }

Por último existe un apartado llamado Subscription, el cual es un tanto peculiar. Lo que nos proporciona este tipo es la capacidad de suscribirnos a eventos que pueden ocurrir en nuestra API, como por ejemplo la creación de un nuevo producto. Te explicaré cómo funciona más adelante.

Paso 2: ¿Dónde están los datos?

Los datos pueden estar en cualquier parte, incluso pueden venir de una API REST. En este caso los voy a almacenar en un MongoDB, y más concretamente en un contenedor de Docker. Para crear un contenedor con MongoDB basta con ejecutar el siguiente comando:

docker run --name mongodb -d -p 27017:27017 mongo

Para que el mismo tenga algo de información, he creado este JSON con algunos productos, el cual se puede cargar a través de Mongo Seeding, una herramienta que te conté en el artículo anterior, para cargar de manera rápida documentos en Mongo.

Pase 3: Resolvers

Ahora que ya tenemos definidos todos los tipos y las operaciones disponibles, necesitamos implementar dichas operaciones en algún sitio. Esto es lo que se conoce como resolvers y deben tener el mismo nombre que se definió en el esquema. Es común que estos estén separados en archivos diferentes, al menos por los tipos mencionados en el apartado anterior:

Resolvers para el tipo Query

//src/resolvers/Query.js
import { ObjectId } from 'mongodb';
const products = async (parent, args, { Products }, info) => {
    if (args.name) {
        return await Products.find({ name: new RegExp(args.name, 'i') }).toArray();
    }
    return (await Products.find().toArray());
}
const product = async (parent, args, { Products }, info) => {
    return await Products.findOne({ _id: ObjectId(args.id) });
}
const numberOfProducts = async (parent, args, { Products }, info) => {
    if (args.typeOfProduct)
        return (Products.find({ typeOfProduct: args.typeOfProduct })).count();
    return await Products.count();
}
module.exports = {
    products,
    product,
    numberOfProducts
}

Como puedes observar, los resolvers de tipo Query están utilizando la colección Products de MongoDB para recuperar ya sean todos los elementos, uno, o el número total.

Resolvers para el tipo Mutation

//src/resolvers/Mutation.js
import { ObjectId } from 'mongodb';
const PRODUCT_ADDED = 'PRODUCT_ADDED';
const createProduct = async (parent, args, { Products, pubsub }, info) => {
    let result = await Products.insertOne(args);
    let newProduct = result.ops[0];
    await pubsub.publish(PRODUCT_ADDED, { newProduct });
    return newProduct;
}
const editProduct = async (parent, args, { Products }, info) => {
    const _id = args._id;
    delete args._id;
    await Products.updateOne({ "_id": ObjectId(_id) }, { $set: args });
    args._id = _id;
    return args;
}
const deleteProduct = async (parent, args, { Products }, info) => {
    try {
        Products.deleteOne({ "_id": ObjectId(args._id) });
        return true;
    } catch (error) {
        console.log(error);
        return false;
    }
}

module.exports = {
    createProduct,
    editProduct,
    deleteProduct
}

Aquí tenemos todas aquellas operaciones que modifican nuestros recursos, en nuestro caso los documentos alojados en MongoDB. Además, en el resolver createProduct vemos que estamos utilizando pubsub.publish, lo cual nos permite avisar a todas aquellos clientes que estén suscritos al evento PRODUCT_ADDED a través de la suscripción newProduct que definimos en el esquema.

Resolvers para el tipo Subscription

//src/resolvers/Subscription.js
const PRODUCT_ADDED = 'PRODUCT_ADDED';
const newProduct = {
    subscribe: (parent, args, context, info) => context.pubsub.asyncIterator(PRODUCT_ADDED)
}
module.exports = {
    newProduct
}

En este caso solo tenemos una sola acción, que es la suscripción a los nuevos productos que se crean.

Paso 4: all together!

Ahora que tenemos todas las partes implicadas en una API con GraphQL, necesitamos unirlas para poder ejecutar nuestro servidor. Para ello, crea un archivo llamado index.js, dentro de src, con el siguiente contenido:

const { ApolloServer, PubSub } = require('apollo-server');
const { MongoClient } = require('mongodb');
//1. Schema
const typeDefs = require('./schema');
//2. Resolvers
const Query = require('./resolvers/Query');
const Mutation = require('./resolvers/Mutation');
const Subscription = require('./resolvers/Subscription');
const pubsub = new PubSub();
const resolvers = {
    Query,
    Mutation,
    Subscription
};
//3. MongoDB
//new mongodb: docker run --name mongodb -d -p 27017:27017 mongo
//existing mongo: docker start mongodb
MongoClient.connect('mongodb://localhost:27017', { useNewUrlParser: true }, function (err, client) {
    if (err)
        throw err;
    console.log("Connected successfully to MongoDB");
    const db = client.db('realfoodingdb');
    const server = new ApolloServer({
        typeDefs,
        resolvers,
        context: {
            Products: db.collection('products'),
            pubsub
        }
    });
    server.listen().then(({ url }) => {
        console.log(`? Server ready at ${url}`);
    });
});

Apollo Server necesita el esquema que hemos creado, los resolvers que implementan ese esquema y tener una conexión a la base de datos con MongoDB. Una vez que tenemos todo ello, a través de new ApolloServer juntamos todas las piezas. Si recuerdas, tanto en los resolvers de tipo Query como los de tipo Mutation estábamos haciendo uso de la variable Products y pubsub. Esto es posible debido a que las mismas son configuradas aquí, como parte del contexto de Apollo Server, lo cual las hace accesibles desde cualquier resolver.

Antes de ejecutar el proyecto, debes tener en cuenta que en el código que te comparto estoy haciendo uso de EMACScript 6 en Node.js, lo cual a día de hoy no está soportado directamente. Sin embargo, puedes usar Babel, como yo 🙂 También estoy usando Nodemon para que el servidor se reinicie de manera automática cada vez que realizo algún cambio en mi API. Por lo que es necesario añadir los siguientes módulos, como dependencias de desarrollo:

npm install --save-dev nodemon @babel/core @babel/node @babel/preset-env

El archivo package.json debería quedar de la siguiente manera:

{
  "name": "myfeedapp-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "mongo_docker": "docker start mongodb",
    "start": "npm run mongo_docker && nodemon --exec babel-node src/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "apollo-server": "^2.9.3",
    "graphql": "^14.5.4",
    "mongodb": "^3.3.2"
  },
  "devDependencies": {
    "@babel/core": "^7.6.0",
    "@babel/node": "^7.6.1",
    "@babel/preset-env": "^7.6.0",
    "nodemon": "^1.19.2"
  }
}

Ahora ya puedes ejecutar tu nueva API a través de npm start y acceder al Playground a través de http://localhost:4000/

El código del ejemplo completo lo tienes en mi GitHub, así como algunas consultas para que pruebes. Para probar la suscripción debes abrir dos Playgrounds, uno que se quede escuchando y el otro que cree un nuevo producto.

¡Saludos!