Creando una API con GraphQL y Apollo - Parte I

gugadev

gugadev

Posted on January 25, 2019

Creando una API con GraphQL y Apollo - Parte I

Nota: Este tutorial, por su complejidad, será divido en dos partes. La primera, que es el presente escrito, será dedicado al backend usando GraphQL y Apollo junto con TypeGraphQL. El segundo, al frontend usando la última versión de Angular.

Source code

En el tutorial anterior aprendimos los conceptos básicos de GraphQL. Vimos qué eran las variables, las queries, mutations, entre otras cosas. Porque no es suficiente quedarse con la teoría, vamos a ponernos manos a la obra para practicar lo aprendido.

Preparación del proyecto

Antes que nada, recuerda utilizar la última versión LTS de Node. Así mismo, puedes usar tanto NPM como Yarn.

La lista de paquetes que necesitamos instalar es:

  • @types/bcryptjs
  • @types/graphql
  • @types/lokijs
  • @types/pino
  • apollo-server
  • bcryptjs
  • class-validator
  • graphql
  • lokijs
  • pino
  • pino-pretty
  • reflect-metadata
  • type-graphql
  • typedi

Las dependencias de desarrollo son las siguientes:

  • @types/node
  • nodemon
  • ts-node
  • tslint
  • typescript

Por último, agrega el script start para que corra nodemon ejecute ts-node y corra nuestra aplicación:

{
  "scripts": {
    "start": "nodemon --exec ts-node src/main.ts --watch src/ --ignore src/database/data.json"
  }
}
Enter fullscreen mode Exit fullscreen mode

Creando los modelos

Para enfocarnos en GraphQL vamos a usar una base de datos en memoria.

Lo primero será crear los modelos, en nuestro caso solo tenemos uno al cual llamaremos User:

// user.type.ts
import {
  ObjectType,
  Field,
  Int
} from 'type-graphql'

@ObjectType()
export default class User {
  @Field(type => Int)
  id: number
  @Field()
  email: string
  @Field()
  password: string
}
Enter fullscreen mode Exit fullscreen mode

Este tipo solo contiene tres campos:

  • id: representa la PK.
  • email
  • password

Nota que type-graphql nos da tipos opcionales como Intcuando los tipos de JavaScript no nos son suficientes. Por ejemplo, por defecto, number es mapeado a un Float de GraphQL. Por esta razón, por medio del parámetro type, le decimos que es de tipo INT.

A su vez, esta misma clase será nuestro modelo con el que trabajará el motor de base de datos (siempre pensando en reutilizar 😉).

Creando el servicio

Ahora procedemos a crear el servicio para User. Este se ve así:

// user.service.ts
import { Service } from 'typedi'
import { hash, genSalt } from 'bcryptjs'
import db from '../database/client'
import User from './user.type'
import UserInput from './user.input'

@Service()
export default class UserService {
  private datasource = db.getCollection('users')

  findByEmail(email: strng): User {
    return this.datasource.findOne({ email })
  }
  async create(data: UserInput): Promise<User> {
    const body = {
      ...data,
      id: this.datsource.count() + 1,
      password: await hash(data.password, await genSalt(10))
    }
    const { id } = this.datasource.insert(body)
    return this.find(id)
  }
}

Enter fullscreen mode Exit fullscreen mode

Lo primero a notar es que el servicio está anotado con el decorador Service. Este decorador nos permite registrar una clase como un servicio en el contenedor DI para posteriormente inyectarlo en algún otro sitio.

El resto es realmente simple. Como propiedad tenemos datasource, la cual contiene la colleción users que hemos recuperado de la base de datos.

Finalmene tenemos dos métodos los cuales son findByEmail que encuentra un usuario por medio de su email y create el cual recibe un argumento de tipo UserInput, hashea su password plano, lo inserta en la colección y finalmente retorna el documento creado.

Suena bien, pero, ¿qué viene a ser UserInput? 🤔

Argumentos personalizados

Recorarás que en el tutorial anterior hablamos de input, los cuales son tipos que engloban campos para ser pasados como un conjunto a través de un solo argumento en las consultas. Tomando este concepto, procedemos a crear nuestro propio input.

import { IsEmail } from 'class-validator'
import {
  InputType,
  Field
} from 'type-graphql'

@InputType()
export default class UserInput {
  @Field()
  @IsEmail()
  email: string
  @Field()
  password: string
}

Enter fullscreen mode Exit fullscreen mode

Te darás cuenta que es muy similar a User, ¿cierto? La única diferencia es la decoración InputType, por medio de la cual indicamos que esta clase es una estructura input. Además, como somos muy cuidadosos, validamos el campo email por medio de la decoración isMail, validación propiedad del paquete class-validator y que será automática, la misma que nos retornará un error a través de GraphQL si proveemos un valor erróneo para el campo.

 Creando el Resolver

Bien, hasta aquí ya tenemos los tipos, ahora procedamos a crear la consulta y la mutación con sus respectivos resolvers. Para esto, creamos una clase y la anotamos con Resolver, como muestro a continuación:

import {
  Resolver,
  Arg,
  Query,
  Mutation,
  Int
} from 'type-graphql'
import User from './user.type'

@Resolver(of => User)
export default class UserResolver {

}
Enter fullscreen mode Exit fullscreen mode

Por medio de la decoración Resolver indicamos que esta clase contendrá uno o más resolvers y además, por medio del argumento of le indicamos a quién pertenecerá; en este caso, a User. Ahora procedemos a incluir el servicio de User para consultar a la base de datos y retornar desde las consultas y mutaciones.

// imports anteriores
import { Inject } from 'typedi'

@Resolver(of => User)
export default class UserResolver {
  @Inject() private service: UserService
}
Enter fullscreen mode Exit fullscreen mode

Listo. Pero, ¿Qué ha pasado aquí? 🤔

La decoración @Inject "inyecta" la dependencia (una instancia) en una variable o argumento, dependencia que debe ser del mismo tipo que el de la variable. Cuando hacemos uso de @Inject lo que hacemos es decirle al contenedor:

Hey, ¿Puedes buscar en tu registro si tienes una clase llamada ´
UserService? Si la tienes, necesito que me des una instancia y la guardes en la variable service.

¿Se entendió? Genial. Una vez que ya hemos incluido la dependencia de UserService ya estamos listos para usar sus métodos. Ahora, definamos nuestra Query. Esta se encargará de encontrar un usuario por medio de su id:

// imports anteriores
import {
  ...
  Arg, // agregamos
  Query, // agregamos
  Int // agregamos
} from 'type-graphql'

@Resolver(of => User)
export default class UserResolver {
  ...
  @Query(returns => User, { nullable: true })
  user(@Arg('email') email: string) {
    return this.userService.findByEmail(email)
  }
}
Enter fullscreen mode Exit fullscreen mode

Por medio del decorador Query indicamos que dicho método representa una consulta. Esta decoración acepta dos parámetros: el tipo de retorno y un array de opciones, el cual es opcional. Por medio de ese array le decimos que esta consulta puede retornar null, debido a que cuando un usuario no se encuentre, lo que será retornado será null. En caso contrario obtendríamos un error al retornar null.

En el argumento id, proveemos un decorador de tipo Arg, al cual le pasamos un nombre. Finalmente, cuando se ejecute el método, buscará en la base de datos ese email y retornará el usuario asociado.

La anterior definición se traduce al siguiente esquema GraphQL:

type Query {
  user(email: String!): User
}
Enter fullscreen mode Exit fullscreen mode

Sencillo, ¿no?. Ahora seguimos con nuestra mutación, la cual será encargada de crear un usuario en la base de datos. La definición del método es bastante similar a la consulta:

// imports anteriores
import {
  ...
  Mutation // agregamos
} from 'type-graphql'
import UserInput from './user.input'

@Resolver(of => User)
export default class UserResolver {
  ...
  @Mutation(returns => User)
  user(@Arg('data') data: UserInput) {
    return this.userService.create(data)
  }
}
Enter fullscreen mode Exit fullscreen mode

Fíjate en el argumento del método, ya no le pasamos el type en el decorador Arg porque ya lo hacemos por medio de Typescript. Lo que hará type-graphql es usar Reflection para ver los tipos de los parámetros y hacer el mapeo correcto. ¡Es genial!

Lo anterior se traducirá a lo siguiente:

type Mutation {
  createUser(data: UserInput!): User
}
Enter fullscreen mode Exit fullscreen mode

DI, Base de datos y Apollo

Ya tenemos casi todo lo que necesitamos, solo nos falta unos pequeños pasos. El primero es configurar nuestro container de inyección de dependencias. Para esto, hacemos lo siguiente:

import { Container } from 'typedi'
import { useContainer } from 'type-graphql'

export default () => {
  useContainer(Container)
}
Enter fullscreen mode Exit fullscreen mode

Importamos el container desde typediy se lo pasamos a type-graphql para que lo configure por nosotros. Eso es todo lo que necesitamos hacer para tenerlo funcionando y poder proveer e inyectar dependencias.

Lo siguiente es crear nuestra bases de datos. Como dijimos al inicio del tutorial, será una base de datos en memoria, así que como es de suponer, el setup será sencillísimo:

// database/bootstrap.ts
import * as Loki from 'lokijs'

const db: Loki = new Loki('data.json')
db.addCollection('users')

export default db
Enter fullscreen mode Exit fullscreen mode

Fíjate que en el momento en que instanciamos la base de datos, creamos una colección llamada users, que es donde se guardarán los usuarios que vayamos creando.

Finalmente, necesitamos crear nuestro servidor GraphQL usando Apollo. Veamos como luce:

// server/index.ts
import { ApolloServer } from 'apollo-server'
import { buildSchema } from 'type-graphql'
import formatError from '../errors/argument.format'
import UserResolver from '../users/user.resolver'

/**
 * Creates a Apollo server using an
 * executable schema generated by
 * TypeGraphQL.
 * We provide a custom Apollo error
 * format to returns a non-bloated
 * response to the client.
 */
export default async () => {
  const schema = await buildSchema({
    resolvers: [
      UserResolver
    ]
  })
  return new ApolloServer({
    schema
  })
}
Enter fullscreen mode Exit fullscreen mode

Lo primero que hacemos es importar los resolvers, luego pasárselos a buildSchema en forma de array para que nos genera un schema de GraphQL válido que pueda entender Apollo. Lo segundo, es instanciar ApolloServer y pasarle el schema junto con otras propiedades opcionales. Puedes ver la lista de propiedades aquí. Una vez hecho esto, ya tenemos un servidor listo para correr.

Entry point

Para terminar, creamos el archivo principal que pondrá a correr el servidor de Apollo. Para esto, solo importamos la función que crea el servidor y ejecutamos la función listen, la cual pondrá en escucha al servidor.

// main.ts
import 'reflect-metadata'
import enableDI from './container/bootstrap'
import createServer from './server'
import log from './logger'

const run = async () => {
  enableDI()
  try {
    const server = await createServer()
    const { url } = await server.listen({ port: 3000 })
    log.info(`🚀  Server ready at ${url}`)
  } catch (e) {
    log.error(e)
  }
}

run()
Enter fullscreen mode Exit fullscreen mode

Opcional

Error Formatter

Por defecto, cuando ocurre un error en tiempo de ejecución, GraphQL nos devuelve un gran objeto con muchos detalles, como en qué línea ocurrió, el stack trace, entre otras. Para no exponer demasiados detalles por seguridad y por simpleza, podemos crear un formateador que intercepte el error y lo modifique a nuestro antojo. Veamos un ejemplo:

// errors/argument.format.ts
import { GraphQLError } from 'graphql'
import { ArgumentValidationError } from 'type-graphql'
import { ValidationError } from 'class-validator'

/**
 * Describes a custom GraphQL error format.
 * @param { err } Original GraphQL Error
 * @returns formatted error
 */
export default (err: GraphQLError): any => {
  const formattedError: { [key: string]: any } = {
    message: err.message
  }

  if (err.originalError instanceof ArgumentValidationError) {
    formattedError.validationErrors = err.originalError.validationErrors.map((ve: ValidationError) => {
      const constraints = { ...ve.constraints }
      return {
        property: ve.property,
        value: ve.value,
        constraints
      }
    })
  }
  return formattedError
}
Enter fullscreen mode Exit fullscreen mode

Los formateadores de errores reciben un error de tipo GraphQL. Este error contiene propiedades como message, paths, location, extensions, entre otras. Sin embargo, podemos solo extrar lo que necesitemos. En este caso, solo necesitamos el mensaje y los errores de validación sin mucho detalle: solo la propiedad donde ocurrió el error, su valor y las restricciones que no pasó. De esta manera tenemos errores personalizados.

Para habilitarlo, solo se lo pasamos a la opción formatError del constructor de ApolloServer:

return new ApolloServer({
    schema,
    formatError
  })
}
Enter fullscreen mode Exit fullscreen mode

Run, Forrest, Run!

Llegó la hora de la verdad. En este punto no hay marcha atrás: o corre o te disparas en la sien 😝 Para correr el servidor ejecuta el clásico npm start.

Si vamos a localhost:3000 veremos el Playground para empezar a jugar. ¡Ejecuta la consulta y mutación que aparece en la imagen para ver los resultados!

En la próxima entrega de esta serie veremos como consumir esta API desde Angular usando el cliente de Apollo. ¡Nos vemos! 🤘

💖 💪 🙅 🚩
gugadev
gugadev

Posted on January 25, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related