Saltar al contenido

a

Servidor GraphQL

REST, familiar para nosotros de las partes anteriores del curso, ha sido durante mucho tiempo la forma más frecuente de implementar las interfaces que ofrecen los servidores para los navegadores y, en general, la integración entre diferentes aplicaciones en la web.

En los últimos años GraphQL, desarrollado por Facebook, se ha vuelto popular para la comunicación entre aplicaciones web y servidores.

La filosofía GraphQL es muy diferente a REST. REST está basado en recursos. Cada recurso, por ejemplo un usuario tiene su propia dirección que lo identifica, por ejemplo /users/10. Todas las operaciones realizadas en el recurso se realizan con solicitudes HTTP a su URL. La acción depende del método HTTP utilizado.

La base de recursos de REST funciona bien en la mayoría de situaciones. Sin embargo, a veces puede resultar un poco incómodo.

Supongamos que nuestra aplicación de lista de blogs contiene una funcionalidad similar a las de las redes sociales y, por ejemplo, queremos mostrar una lista de todos los blogs que los usuarios que han comentado sobre los blogs que seguimos han agregado.

Si el servidor implementara una API REST, probablemente tendríamos que hacer varias solicitudes HTTP desde el navegador antes de tener todos los datos que queríamos. Las solicitudes también devolverían una gran cantidad de datos innecesarios y el código en el navegador probablemente sería bastante complicado.

Si esta fuera una funcionalidad de uso frecuente, podría haber un endpoint REST para ella. Sin embargo, si hubiera muchos de estos tipos de escenarios, sería muy laborioso implementar endpoints REST para todos ellos.

Un servidor GraphQL es adecuado para este tipo de situaciones.

El principio fundamental de GraphQL es que el código del navegador forma una consulta que describe los datos deseados y los envía a la API con una solicitud HTTP POST. A diferencia de REST, todas las consultas GraphQL se envían a la misma dirección y su tipo es POST.

Los datos descritos en el escenario anterior podrían obtenerse con (aproximadamente) la siguiente consulta:

query FetchBlogsQuery {
  user(username: "mluukkai") {
    followedUsers {
      blogs {
        comments {
          user {
            blogs {
              title
            }
          }
        }
      }
    }
  }
}

La respuesta de los servidores sería sobre el siguiente objeto JSON:

{
  "data": {
    "followedUsers": [
      {
        "blogs": [
          {
            "comments": [
              {
                "user": {
                  "blogs": [
                    {
                      "title": "Goto considered harmful"
                    },
                    {
                      "title": "End to End Testing with Cypress is most enjoyable"
                    },
                    {
                      "title": "Navigating your transition to GraphQL"
                    },
                    {
                      "title": "From REST to GraphQL"
                    }
                  ]
                }
              }
            ]
          }
        ]
      }
    ]
  }
}

La lógica de la aplicación se mantiene simple y el código en el navegador obtiene exactamente los datos que necesita con una sola consulta.

Esquemas y consultas

Conoceremos los conceptos básicos de GraphQL mediante la implementación de una versión GraphQL de la aplicación de directorio telefónico de las partes 2 y 3.

En el corazón de todas las aplicaciones GraphQL hay un esquema, que describe los datos enviados entre el cliente y el servidor. El esquema inicial de nuestra agenda telefónica es el siguiente:

type Person {
  name: String!
  phone: String
  street: String!
  city: String!
  id: ID! 
}

type Query {
  personCount: Int!
  allPersons: [Person!]!
  findPerson(name: String!): Person
}

El esquema describe dos tipos. El primer tipo, Person, determina que las personas tienen cinco campos. Cuatro de los campos son de tipo String, que es uno de los tipos escalares de GraphQL. Todos los campos de cadena, excepto phone, deben tener un valor. Esto está marcado por el signo de exclamación en el esquema. El tipo de campo id es ID. Los campos ID son cadenas, pero GraphQL garantiza que sean únicos.

El segundo tipo es una Consulta. Prácticamente todos los esquemas GraphQL describen una consulta, que indica qué tipo de consultas se pueden realizar a la API.

La agenda telefónica describe tres consultas diferentes. personCount devuelve un número entero, allPersons devuelve una lista de objetos Person y findPerson recibe un parámetro de cadena y devuelve un objeto Person.

Nuevamente, los signos de exclamación se utilizan para marcar qué valores y parámetros de retorno son No nulos. personCount, seguro, devolverá un número entero. La consulta findPerson debe recibir una cadena como parámetro. La consulta devuelve un objeto Person o null. allPersons devuelve una lista de objetos Person y la lista no contiene ningún valor nulo.

Entonces, el esquema describe qué consultas puede enviar el cliente al servidor, qué tipo de parámetros pueden tener las consultas y qué tipo de datos devuelven las consultas.

La consulta más simple, personCount, tiene el siguiente aspecto:

query {
  personCount
}

Suponiendo que nuestras aplicaciones han guardado la información de tres personas, la respuesta se vería así:

{
  "data": {
    "personCount": 3
  }
}

La consulta que obtiene la información de todas las personas, allPersons, es un poco más complicada. Como la consulta devuelve una lista de objetos Person, la consulta debe describir que campos de los objetos devuelve la consulta:

query {
  allPersons {
    name
    phone
  }
}

El la respuesta podría verse así:

{
  "data": {
    "allPersons": [
      {
        "name": "Arto Hellas",
        "phone": "040-123543"
      },
      {
        "name": "Matti Luukkainen",
        "phone": "040-432342"
      },
      {
        "name": "Venla Ruuska",
        "phone": null
      }
    ]
  }
}

Se puede realizar una consulta para devolver cualquier campo descrito en el esquema. Por ejemplo, lo siguiente también sería posible:

query {
  allPersons{
    name
    city
    street
  }
}

El último ejemplo muestra una consulta que requiere un parámetro y devuelve los detalles de una persona.

query {
  findPerson(name: "Arto Hellas") {
    phone 
    city 
    street
    id
  }
}

Entonces, primero el parámetro se describe entre paréntesis y luego los campos del objeto de valor de retorno se enumeran entre paréntesis.

La respuesta es así:

{
  "data": {
    "findPerson": {
      "phone": "040-123543",
      "city": "Espoo",
      "street": "Tapiolankatu 5 A"
      "id": "3d594650-3436-11e9-bc57-8b80ba54c431"
    }
  }
}

El valor de retorno se marcó como anulable, así que si buscamos los detalles de una consulta desconocida

query {
  findPerson(name: "Donald Trump") {
    phone 
  }
}

el valor de retorno es nulo.

{
  "data": {
    "findPerson": null
  }
}

Como puede ver, existe un vínculo directo entre una consulta GraphQL y el objeto JSON devuelto. Se puede pensar que la consulta describe qué tipo de datos quiere como respuesta. La diferencia con las consultas REST es marcada. Con REST, la URL y el tipo de solicitud no tienen nada que ver con la forma de los datos devueltos.

La consulta GraphQL describe solo los datos que se mueven entre un servidor y el cliente. En el servidor los datos se pueden organizar y guardar como queramos.

A pesar de su nombre, GraphQL en realidad no tiene nada que ver con las bases de datos. No le importa cómo se guardan los datos. Los datos que utiliza una API GraphQL se pueden guardar en una base de datos relacional, base de datos de documentos o en otros servidores a los que el servidor GraphQL puede acceder con, por ejemplo, REST.

servidor Apollo

Implementemos un servidor GraphQL con la biblioteca líder en la actualidad Apollo-server.

Cree un nuevo proyecto npm con npm init e instale las dependencias necesarias.

npm install apollo-server graphql

También cree un archivo index.js en el directorio raíz de su proyecto.

El código inicial es el siguiente:

const { ApolloServer } = require('@apollo/server')
const { startStandaloneServer } = require('@apollo/server/standalone')

let persons = [
  {
    name: "Arto Hellas",
    phone: "040-123543",
    street: "Tapiolankatu 5 A",
    city: "Espoo",
    id: "3d594650-3436-11e9-bc57-8b80ba54c431"
  },
  {
    name: "Matti Luukkainen",
    phone: "040-432342",
    street: "Malminkaari 10 A",
    city: "Helsinki",
    id: '3d599470-3436-11e9-bc57-8b80ba54c431'
  },
  {
    name: "Venla Ruuska",
    street: "Nallemäentie 22 C",
    city: "Helsinki",
    id: '3d599471-3436-11e9-bc57-8b80ba54c431'
  },
]

const typeDefs = gql`
  type Person {
    name: String!
    phone: String
    street: String!
    city: String! 
    id: ID!
  }

  type Query {
    personCount: Int!
    allPersons: [Person!]!
    findPerson(name: String!): Person
  }
`

const resolvers = {
  Query: {
    personCount: () => persons.length,
    allPersons: () => persons,
    findPerson: (root, args) =>
      persons.find(p => p.name === args.name)
  }
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
})

startStandaloneServer(server, {
  listen: { port: 4000 },
}).then(({ url }) => {
  console.log(`Server ready at ${url}`)
})

El corazón del código es un ApolloServer, al que se le dan dos parámetros

const server = new ApolloServer({
  typeDefs,
  resolvers,
})

El primer parámetro, typeDefs, contiene el esquema GraphQL.

El segundo parámetro es un objeto, que contiene los resolutores del servidor. Estos son el código, que define cómo se responde a las consultas GraphQL.

El código de los resolutores es el siguiente:

const resolvers = {
  Query: {
    personCount: () => persons.length,
    allPersons: () => persons,
    findPerson: (root, args) =>
      persons.find(p => p.name === args.name)
  }
}

Como puede ver, los resolutores corresponden a las consultas descritas en el esquema.

type Query {
  personCount: Int!
  allPersons: [Person!]!
  findPerson(name: String!): Person
}

Así que hay un campo debajo de Query para cada consulta descrita en el esquema.

La consulta

query {
  personCount
}

Tiene el resolutor

() => persons.length

Por tanto, la respuesta a la consulta es la longitud de la matriz persons.

La consulta que obtiene todas las personas

query {
  allPersons {
    name
  }
}

tiene un resolutor que devuelve todos los objetos de la matriz persons.

() => persons

Lanza el servidor ejecutando en la terminal node index.js

Apollo Studio Explorer

Cuando Apollo-server se ejecuta en modo de desarrollo, la página http://localhost:4000/ tiene un botón Query your server que nos lleva a Apollo Studio Explorer. Esto es muy útil para un desarrollador y se puede utilizar para realizar consultas al servidor.

Vamos a probarlo:

fullstack content

Al lado izquierdo el navegador muestra la documentación de la API que se ha generado automáticamente a partir del esquema.

Parámetros de un resolutor

La consulta que obtiene una sola persona

query {
  findPerson(name: "Arto Hellas") {
    phone 
    city 
    street
  }
}

tiene un resolutor que se diferencia de los anteriores porque se le dan dos parámetros:

(root, args) => persons.find(p => p.name === args.name)

El segundo parámetro, args, contiene los parámetros de la consulta. El resolutor luego devuelve del arreglo persons a la persona cuyo nombre es el mismo que el valor de args.name. El resolutor no necesita el primer parámetro root.

De hecho, todas las funciones de resolución tienen cuatro parámetros. Con JavaScript, los parámetros no tienen que estar definidos, si no son necesarios. Usaremos el primer y tercer parámetro de un resolutor más adelante en esta parte.

El solucionador predeterminado

Cuando hacemos una consulta, por ejemplo

query {
  findPerson(name: "Arto Hellas") {
    phone 
    city 
    street
  }
}

el servidor sabe devolver exactamente los campos requeridos por la consulta. ¿Cómo sucede eso?

Un servidor GraphQL debe definir resolutores para cada campo de cada tipo en el esquema. Hasta ahora solo hemos definido resolutores para campos del tipo Query, es decir, para cada consulta de la aplicación.

Debido a que no definimos resolutores para los campos del tipo Person, Apollo ha definido resolutores predeterminados para ellos. Funcionan como el que se muestra a continuación:

const resolvers = {
  Query: {
    personCount: () => persons.length,
    allPersons: () => persons,
    findPerson: (root, args) => persons.find(p => p.name === args.name)
  },
  Person: {    name: (root) => root.name,    phone: (root) => root.phone,    street: (root) => root.street,    city: (root) => root.city,    id: (root) => root.id  }}

El solucionador predeterminado devuelve el valor del campo correspondiente del objeto. Se puede acceder al objeto en sí a través del primer parámetro del resolutor, root.

Si la funcionalidad del solucionador predeterminado es suficiente, no es necesario que defina la suya propia. También es posible definir resolutores solo para algunos campos de un tipo y dejar que los resolutores predeterminados manejen el resto.

Podríamos, por ejemplo, definir que la dirección de todas las personas es Manhattan Nueva York codificando de forma rígida lo siguiente para los resolutores de los campos de calle y ciudad del tipo Persona .

Person: {
  street: (root) => "Manhattan",
  city: (root) => "New York"
}

Objeto dentro de un objeto

Modifiquemos un poco el esquema

type Address {  street: String!  city: String! }
type Person {
  name: String!
  phone: String
  address: Address!  id: ID!
}

type Query {
  personCount: Int!
  allPersons: [Person!]!
  findPerson(name: String!): Person
}

por lo que una persona ahora tiene un campo con el tipo Address, que contiene la calle y la ciudad.

Las consultas que requieren la dirección cambian a

query {
  findPerson(name: "Arto Hellas") {
    phone 
    address {
      city 
      street
    }
  }
}

y la respuesta ahora es un objeto persona, que contiene un objeto de dirección.

{
  "data": {
    "findPerson": {
      "phone": "040-123543",
      "address":  {
        "city": "Espoo",
        "street": "Tapiolankatu 5 A"
      }
    }
  }
}

Todavía guardamos a las personas en el servidor de la misma manera que lo hacíamos antes.

let persons = [
  {
    name: "Arto Hellas",
    phone: "040-123543",
    street: "Tapiolankatu 5 A",
    city: "Espoo",
    id: "3d594650-3436-11e9-bc57-8b80ba54c431"
  },
  // ...
]

Por lo tanto, los objetos de persona guardados en el servidor no son exactamente los mismos que los objetos de tipo Person GraphQL descritos en el esquema.

A diferencia del tipo Person, el tipo Address no tiene un campo id porque no se guardan en su propia estructura de datos en el servidor.

Debido a que los objetos guardados en la matriz no tienen un campo address, el resolutor predeterminado no es suficiente. Agreguemos un resolutor para el campo address de tipo Person:

const resolvers = {
  Query: {
    personCount: () => persons.length,
    allPersons: () => persons,
    findPerson: (root, args) =>
      persons.find(p => p.name === args.name)
  },
  Person: {    address: (root) => {      return {         street: root.street,        city: root.city      }    }  }}

Así que cada vez que un objeto Person es devuelto, los campos name, phone e id se devuelven utilizando sus resolutores predeterminados, pero el campo address se forma utilizando un resolutor autodefinido. El parámetro root de la función de resolución es el objeto-persona, por lo que la calle y la ciudad de la dirección se pueden tomar de sus campos.

El código actual de la aplicación se puede encontrar en Github, rama part8-1.

Mutaciones

Agreguemos una funcionalidad para agregar nuevas personas a la agenda. En GraphQL, todas las operaciones que provocan un cambio se realizan con mutaciones. Las mutaciones se describen en el esquema como claves de tipo Mutation.

El esquema de una mutación para agregar una nueva persona tiene el siguiente aspecto:

type Mutation {
  addPerson(
    name: String!
    phone: String
    street: String!
    city: String!
  ): Person
}

A la mutación se le dan los detalles de la persona como parámetros. El parámetro phone es el único que admite valores NULL. La mutación también tiene un valor de retorno. El valor de retorno es de tipo Person, la idea es que los detalles de la persona agregada se devuelvan si la operación es exitosa y si no, nula. El valor del campo id no se proporciona como parámetro. Es mejor dejar la generación de una identificación para el servidor.

Las mutaciones también requieren un resolutor:

const { v1: uuid } = require('uuid')

// ...

const resolvers = {
  // ...
  Mutation: {
    addPerson: (root, args) => {
      const person = { ...args, id: uuid() }
      persons = persons.concat(person)
      return person
    }
  }
}

La mutación agrega el objeto que se le dio como parámetro args al arreglo persons, y devuelve el objeto que agregó al arreglo.

El campo id recibe un valor único utilizando la librería uuid.

Se puede agregar una nueva persona con la siguiente mutación

mutation {
  addPerson(
    name: "Pekka Mikkola"
    phone: "045-2374321"
    street: "Vilppulantie 25"
    city: "Helsinki"
  ) {
    name
    phone
    address{
      city
      street
    }
    id
  }
}

Ten en cuenta que la persona se guarda en la matriz persons como

{
  name: "Pekka Mikkola",
  phone: "045-2374321",
  street: "Vilppulantie 25",
  city: "Helsinki",
  id: "2b24e0b0-343c-11e9-8c2a-cb57c2bf804f"
}

Pero la respuesta a la mutación es

{
  "data": {
    "addPerson": {
      "name": "Pekka Mikkola",
      "phone": "045-2374321",
      "address": {
        "city": "Helsinki",
        "street": "Vilppulantie 25"
      },
      "id": "2b24e0b0-343c-11e9-8c2a-cb57c2bf804f"
    }
  }
}

Entonces, el resolutor del campo address del tipo Person formatea el objeto de respuesta en la forma correcta.

Manejo de errores

Si intentamos crear una nueva persona, pero los parámetros no se corresponden con la descripción del esquema, el servidor muestra un mensaje de error:

fullstack content

Por lo tanto, parte del manejo de errores se puede realizar automáticamente con validación GraphQL.

Sin embargo, GraphQL no puede manejar todo automáticamente. Por ejemplo, las reglas más estrictas para los datos enviados a una mutación deben agregarse manualmente. Los errores de esas reglas son manejados por el mecanismo de manejo de errores de Apollo Server.

Bloqueemos agregar el mismo nombre al directorio telefónico varias veces:

const { GraphQLError } = require('graphql')
// ...

const resolvers = {
  // ..
  Mutation: {
    addPerson: (root, args) => {
      if (persons.find(p => p.name === args.name)) {        throw new GraphQLError('Name must be unique', {          extensions: {            code: 'BAD_USER_INPUT',            invalidArgs: args.name          }        })      }
      const person = { ...args, id: uuid() }
      persons = persons.concat(person)
      return person
    }
  }
}

Entonces, si el nombre que se agregará ya existe en la agenda, arroje el error UserInputError.

fullstack content

El código actual de la aplicación se puede encontrar en Github, rama part8-2.

Enum

Agreguemos la posibilidad de filtrar la consulta que devuelve todas las personas con el parámetro phone para que solo devuelva personas con un número de teléfono

query {
  allPersons(phone: YES) {
    name
    phone 
  }
}

o personas sin un número de teléfono

query {
  allPersons(phone: NO) {
    name
  }
}

El esquema cambia así:

enum YesNo {  YES  NO}
type Query {
  personCount: Int!
  allPersons(phone: YesNo): [Person!]!  findPerson(name: String!): Person
}

El tipo YesNo es GraphQL enum, o un enumerable, con dos valores posibles YES o NO. En la consulta allPersons el parámetro phone tiene el tipo YesNo, pero acepta valores NULL.

El solucionador cambia así:

Query: {
  personCount: () => persons.length,
  allPersons: (root, args) => {    if (!args.phone) {      return persons    }    const byPhone = (person) =>      args.phone === 'YES' ? person.phone : !person.phone    return persons.filter(byPhone)  },  findPerson: (root, args) =>
    persons.find(p => p.name === args.name)
},

Cambiar un número de teléfono

Agreguemos una mutación para cambiar el número de teléfono de una persona. El esquema de esta mutación se ve como sigue:

type Mutation {
  addPerson(
    name: String!
    phone: String
    street: String!
    city: String!
  ): Person
  editNumber(    name: String!    phone: String!  ): Person}

y lo hace un solucionador:

Mutation: {
  // ...
  editNumber: (root, args) => {
    const person = persons.find(p => p.name === args.name)
    if (!person) {
      return null
    }

    const updatedPerson = { ...person, phone: args.phone }
    persons = persons.map(p => p.name === args.name ? updatedPerson : p)
    return updatedPerson
  }   
}

La mutación encuentra a la persona que se actualizará mediante el campo name.

El código actual de la aplicación se puede encontrar en Github, rama part8-3.

Más sobre consultas

Con GraphQL es posible combinar varios campos de tipo Query, o "consultas separadas" en una sola consulta. Por ejemplo, la siguiente consulta devuelve tanto la cantidad de personas en la agenda telefónica como sus nombres:

query {
  personCount
  allPersons {
    name
  }
}

La respuesta se ve como sigue

{
  "data": {
    "personCount": 3,
    "allPersons": [
      {
        "name": "Arto Hellas"
      },
      {
        "name": "Matti Luukkainen"
      },
      {
        "name": "Venla Ruuska"
      }
    ]
  }
}

La consulta combinada también puede usar la misma consulta varias veces. Sin embargo, debe dar a las consultas nombres alternativos como

query {
  havePhone: allPersons(phone: YES){
    name
  }
  phoneless: allPersons(phone: NO){
    name
  }
}

La respuesta se ve como

{
  "data": {
    "havePhone": [
      {
        "name": "Arto Hellas"
      },
      {
        "name": "Matti Luukkainen"
      }
    ],
    "phoneless": [
      {
        "name": "Venla Ruuska"
      }
    ]
  }
}

En en algunos casos, puede resultar beneficioso nombrar las consultas. Este es el caso especialmente cuando las consultas o mutaciones tienen parámetros. Pronto entraremos en los parámetros.