Cómo escribir y organizar un esquema GraphQL en JavaScript

Cómo escribir un esquema GraphQL utilizando una estructura de carpetas y archivos que hace que la comprensión y el mantenimiento sean menos abrumadores.

En una aplicación que usa GraphQL para su capa de datos, es decir, lo que su aplicación usa para recuperar y manipular datos, el esquema es el eje entre el cliente y el servidor.

Si bien los esquemas en GraphQL tienen reglas sobre cómo escribirlos, no hay reglas sobre cómo organizar a ellos. En proyectos grandes, la organización es la clave para que todo funcione sin problemas.

Primeros pasos

Para este tutorial, vamos a utilizar CheatCode Node.js Boilerplate como punto de partida. Esto nos dará acceso a un servidor GraphQL en funcionamiento con un esquema ya adjunto. Modificaremos ese esquema y discutiremos su organización para ayudarlo a informar a la organización de su propio esquema GraphQL.

Primero, clonemos una copia del modelo de Github:

Terminal

git clone https://github.com/cheatcode/nodejs-server-boilerplate.git

A continuación, cd en el repetitivo e instale sus dependencias:

Terminal

cd nodejs-server-boilerplate && npm install

Con las dependencias instaladas, ahora podemos iniciar el servidor de desarrollo:

Terminal

npm run dev

Con eso, estamos listos para comenzar.

Configuración de la estructura de carpetas base

En una aplicación que usa GraphQL, hay dos piezas centrales:su esquema GraphQL y su servidor GraphQL (independiente de su servidor HTTP). El esquema está adjunto al servidor para que cuando llegue una solicitud, el servidor sepa cómo procesarla.

Debido a que estas dos piezas funcionan en conjunto, es mejor guardarlas una al lado de la otra. En el proyecto de ejemplo que acabamos de clonar, estos se colocan en el /api/graphql directorio. Aquí, el /api El directorio contiene carpetas que describen los diferentes tipos de datos en nuestra aplicación. Cuando se combinan, nuestro esquema y servidor representan la API GraphQL para nuestra aplicación (de ahí la ubicación).

Dentro de esa carpeta:/api/graphql —separamos nuestro esquema y las declaraciones del servidor en dos archivos:/api/graphql/schema.js y /api/graphql/server.js . Nuestro enfoque en el futuro estará en el esquema parte de esta ecuación, pero si desea obtener más información sobre cómo configurar un servidor GraphQL, le recomendamos leer este otro tutorial de CheatCode sobre cómo configurar un servidor GraphQL. Antes de terminar, analizaremos cómo funciona adjuntar el esquema que escribimos a un servidor GraphQL.

Organización de tipos, resoluciones de consultas y resoluciones de mutaciones

A continuación, la parte central de nuestro patrón organizativo será cómo separamos los diferentes tipos, solucionadores de consultas y solucionadores de mutaciones en nuestra API GraphQL. En nuestro proyecto de ejemplo, la estructura sugerida es mantener todo organizado bajo el /api directorio que aprendimos antes. En esa carpeta, cada "tema" de datos debe tener su propia carpeta. Un "tema" describe una colección o tabla en su base de datos, una API de terceros (por ejemplo, /api/google ), o cualquier otro tipo distinto de datos en su aplicación.

├── /api
│   ├── /documents
│   │   ├── /graphql
│   │   │   ├── mutations.js
│   │   │   ├── queries.js
│   │   │   └── types.js

Con respecto a GraphQL, dentro de una carpeta de temas, agregamos un graphql carpeta para organizar todos nuestros archivos relacionados con GraphQL para ese tema. En la estructura de ejemplo anterior, nuestro tema es documents . Para este tema, en el contexto de GraphQL, tenemos algunos tipos personalizados (types.js ), solucionadores de consultas (queries.js ), y solucionadores de mutaciones (mutations.js ).

/api/documents/graphql/types.js

const DocumentFields = `
  title: String
  status: DocumentStatus
  createdAt: String
  updatedAt: String
  content: String
`;

export default `
  type Document {
    _id: ID
    userId: ID
    ${DocumentFields}
  }

  enum DocumentStatus {
    draft
    published
  }

  input DocumentInput {
    ${DocumentFields}
  }
`;

En nuestro types.js archivo, exportamos una cadena, definida usando backtics `` para que podamos aprovechar la interpolación de cadenas de JavaScript (a partir de la edición ES6 del estándar) (permitiéndonos incluir e interpretar expresiones de JavaScript dentro de una cadena). Aquí, como técnica organizativa, cuando tenemos un conjunto de propiedades que se usan en varios tipos, extraemos esos campos en una cadena (definida usando comillas invertidas en caso de que necesitemos hacer alguna interpolación) y los almacenamos en una variable en la parte superior. de nuestro archivo (aquí, DocumentFields ).

Entonces, utilizando esa interpolación, concatenamos nuestro DocumentFields en el lugar donde se usan en los tipos devueltos en la cadena exportada. Esto hace que cuando nuestros tipos finalmente se exporten, los campos "compartidos" se agreguen a los tipos que estamos definiendo (por ejemplo, aquí, type Document tendrá todas las propiedades en DocumentFields definido en él).

/api/documents/graphql/queries.js

import isDocumentOwner from "../../../lib/isDocumentOwner";
import Documents from "../index";

export default {
  documents: async (parent, args, context) => {
    return Documents.find({ userId: context.user._id }).toArray();
  },
  document: async (parent, args, context) => {
    await isDocumentOwner(args.documentId, context.user._id);

    return Documents.findOne({
      _id: args.documentId,
      userId: context.user._id,
    });
  },
};

Mirando nuestro queries.js a continuación, aquí almacenamos todas las funciones de resolución para nuestras consultas relacionadas con el tema de los documentos. Para ayudar en la organización, agrupamos todas nuestras funciones de resolución en un solo objeto (en JavaScript, una función definida en un objeto se conoce como método ) y exportar ese objeto principal desde el archivo. Veremos por qué esto es importante más adelante cuando importemos nuestros tipos y resolutores al esquema.

/api/documents/graphql/mutations.js

import isDocumentOwner from "../../../lib/isDocumentOwner";
import Documents from "../index";

export default {
  documents: async (parent, args, context) => {
    return Documents.find({ userId: context.user._id }).toArray();
  },
  document: async (parent, args, context) => {
    await isDocumentOwner(args.documentId, context.user._id);

    return Documents.findOne({
      _id: args.documentId,
      userId: context.user._id,
    });
  },
};

Con respecto a la estructura, mutations.js es idéntico a queries.js . La única diferencia aquí es que estos Las funciones de resolución son responsables de resolver mutaciones en lugar de consultas. Si bien podríamos agrupar nuestros solucionadores de consultas y mutaciones en un solo resolvers.js mantenerlos separados hace que el mantenimiento sea un poco más fácil ya que no hay una distinción inherente entre las funciones de resolución.

Luego, con estos archivos listos, para usarlos necesitamos importar y agregar su contenido a nuestro esquema.

Importación y adición de tipos, resoluciones de consultas y resoluciones de mutaciones al esquema

Ahora que entendemos cómo organizar las piezas que componen nuestro esquema, reunámoslas para tener un esquema funcional. Echemos un vistazo al esquema en nuestro proyecto de ejemplo y veamos cómo se relaciona con los archivos que creamos anteriormente.

/api/graphql/schema.js

import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";

import DocumentTypes from "../documents/graphql/types";
import DocumentQueries from "../documents/graphql/queries";
import DocumentMutations from "../documents/graphql/mutations";

const schema = {
  typeDefs: gql`
    ${DocumentTypes}

    type Query {
      document(documentId: ID!): Document
      documents: [Document]
    }

    type Mutation {
      createDocument(document: DocumentInput!): Document
      deleteDocument(documentId: ID!): Document
      updateDocument(documentId: ID!, document: DocumentInput!): Document
    }
  `,
  resolvers: {
    Query: {
      ...DocumentQueries,
    },
    Mutation: {
      ...DocumentMutations,
    },
  },
};

export default makeExecutableSchema(schema);

Esperemos que esto esté empezando a tener algún sentido. Lo que ve arriba es ligeramente diferente de lo que encontrará en la ruta del archivo en la parte superior de este bloque de código. La diferencia es que aquí hemos extraído las partes del esquema relacionadas con los usuarios para hacer que las partes que creamos anteriormente encajen entre sí (se incluyen como parte del proyecto que clonamos de Github).

Comenzando en la parte superior del archivo, para crear nuestro esquema, importamos el gql etiqueta del graphql-tag paquete (ya instalado como parte de las dependencias en el proyecto que clonamos anteriormente). gql representa una función que toma una cadena que contiene código escrito en GraphQL DSL (lenguaje específico del dominio). Esta es una sintaxis especial que es exclusiva de GraphQL. Debido a que estamos usando GraphQL dentro de JavaScript, necesitamos una forma de interpretar ese DSL dentro de JavaScript.

El gql La función aquí convierte la cadena que le pasamos en un AST o árbol de sintaxis abstracta. Este es un gran objeto de JavaScript que representa un mapa técnico del contenido de la cadena que pasamos a gql . Más tarde, cuando adjuntamos nuestro esquema a nuestro servidor GraphQL, eso la implementación del servidor anticipará y entenderá cómo analizar ese AST.

Si miramos donde gql se usa en el archivo de arriba, vemos que está asignado al typeDefs propiedad en el objeto que hemos almacenado en el schema variable. En un esquema, typeDefs describir la forma de los datos que devuelven los solucionadores de consultas y mutaciones del servidor, así como definir las consultas y mutaciones que se pueden realizar.

Hay dos variaciones de tipos:tipos personalizados que describen los datos en su aplicación y raíz tipos Los tipos raíz son tipos incorporados que GraphQL reserva para describir los campos disponible para consultas y mutaciones. Más específicamente, si observamos el código anterior, el type Query y type Mutation los bloques son dos de los tres tipos de raíces disponibles (el tercero es type Subscription que se utiliza para agregar datos en tiempo real a un servidor GraphQL).

/api/graphql/schema.js

import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";

import DocumentTypes from "../documents/graphql/types";
import DocumentQueries from "../documents/graphql/queries";
import DocumentMutations from "../documents/graphql/mutations";

const schema = {
  typeDefs: gql`
    ${DocumentTypes}

    [...]
  `,
  resolvers: { [...] },
};

export default makeExecutableSchema(schema);

Para utilizar los tipos personalizados que escribimos anteriormente (en el /api/documents/graphql/types.js archivo), en la parte superior de nuestro schema.js archivo aquí, importamos nuestros tipos como DocumentTypes . A continuación, dentro de los acentos graves inmediatamente después de nuestra llamada a gql (el valor que estamos asignando a typeDefs ), usamos la interpolación de cadenas de JavaScript para concatenar nuestros tipos en el valor que estamos pasando a typeDefs . Lo que esto logra es "cargar" nuestros tipos personalizados en nuestro esquema GraphQL.

A continuación, para definir qué consultas y mutaciones podemos ejecutar, debemos definir nuestros campos de consulta y campos de mutación dentro de la raíz type Query y type Mutation tipos Ambos se definen de la misma manera. Especificamos el nombre del campo que esperamos asignar a una función de resolución en nuestro esquema. Opcionalmente, también describimos los argumentos o parámetros que se pueden pasar a ese campo desde el cliente.

/api/graphql/schema.js

[...]

const schema = {
  typeDefs: gql`
    ${DocumentTypes}

    type Query {
      document(documentId: ID!): Document
      documents: [Document]
    }

    type Mutation {
      createDocument(document: DocumentInput!): Document
      deleteDocument(documentId: ID!): Document
      updateDocument(documentId: ID!, document: DocumentInput!): Document
    }
  `,
  resolvers: { [...] },
};

export default makeExecutableSchema(schema);

Aquí, bajo type Query , document(documentId: ID!): Document está diciendo "define un campo que será resuelto por una función de resolución llamada document que requiere un documentId pasado como el tipo escalar ID y espere que devuelva datos en la forma de type Document type (agregado a nuestro esquema como parte del ${DocumentTypes} línea que concatenamos en nuestro typeDefs justo dentro de la llamada a gql ). Repetimos esto para cada uno de los campos que queremos que estén disponibles para consulta bajo type Query .

Repetimos el mismo patrón con las mismas reglas bajo type Mutation . Como discutimos anteriormente, la única diferencia aquí es que estos campos describen mutaciones que podemos ejecutar, no consultas.

Agregar sus solucionadores de consultas y mutaciones

Ahora que hemos especificado nuestros tipos personalizados y los campos en nuestra raíz type Query y raíz type Mutation , a continuación, debemos agregar las funciones de resolución que resolver las consultas y mutaciones que definimos allí. Para hacerlo, en la parte superior de nuestro archivo, importamos nuestro queries.js separado y mutations.js archivos (recuerde, estos son objetos JavaScript exportados) como DocumentQueries y DocumentMutations .

/api/graphql/schema.js

import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";

import DocumentTypes from "../documents/graphql/types";
import DocumentQueries from "../documents/graphql/queries";
import DocumentMutations from "../documents/graphql/mutations";

const schema = {
  typeDefs: gql`
    ${DocumentTypes}

    type Query {
      document(documentId: ID!): Document
      documents: [Document]
    }

    type Mutation {
      createDocument(document: DocumentInput!): Document
      deleteDocument(documentId: ID!): Document
      updateDocument(documentId: ID!, document: DocumentInput!): Document
    }
  `,
  resolvers: {
    Query: {
      ...DocumentQueries,
    },
    Mutation: {
      ...DocumentMutations,
    },
  },
};

export default makeExecutableSchema(schema);

A continuación, en el resolvers propiedad en el objeto que hemos asignado al schema variable, anidamos dos propiedades:Query y Mutation . Estos nombres corresponden a los tipos raíz que definimos en nuestro typeDefs bloquear. Aquí, los solucionadores que están asociados con la raíz type Query se establecen en resolvers.Query objeto y resolutores que están asociados con la raíz type Mutation se establecen en el resolvers.Mutation objeto. Porque exportamos nuestro DocumentQueries y DocumentMutations como objetos, podemos "desempaquetar" esos objetos aquí usando el ... sintaxis extendida en JavaScript.

Como su nombre lo indica, esto "extiende" el contenido de esos objetos en el objeto principal. Una vez interpretado por JavaScript, este código efectivamente logrará esto:

{
  typeDefs: [...],
  resolvers: {
    Query: {
      documents: async (parent, args, context) => {
        return Documents.find({ userId: context.user._id }).toArray();
      },
      document: async (parent, args, context) => {
        await isDocumentOwner(args.documentId, context.user._id);

        return Documents.findOne({
          _id: args.documentId,
          userId: context.user._id,
        });
      },
    },
    Mutation: {
      createDocument: async (parent, args, context) => {
        const _id = generateId();

        await Documents.insertOne({
          _id,
          userId: context.user._id,
          ...args.document,
          createdAt: new Date().toISOString(),
          updatedAt: new Date().toISOString(),
        });

        return {
          _id,
        };
      },
      updateDocument: async (parent, args, context) => {
        await isDocumentOwner(args.documentId, context.user._id);

        await Documents.updateOne(
          { _id: args.documentId },
          {
            $set: {
              ...args.document,
              updatedAt: new Date().toISOString(),
            },
          }
        );

        return {
          _id: args.documentId,
        };
      },
      deleteDocument: async (parent, args, context) => {
        await isDocumentOwner(args.documentId, context.user._id);

        await Documents.removeOne({ _id: args.documentId });
      },
    },
  }
}

Si bien ciertamente podemos hacer esto, dividir nuestras consultas y resoluciones en temas y en sus propios archivos hace que el mantenimiento sea mucho más fácil (y menos abrumador).

/api/graphql/schema.js

import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";

[...]

const schema = {
  typeDefs: [...],
  resolvers: { [...] },
};

export default makeExecutableSchema(schema);

Finalmente, en la parte inferior de nuestro archivo, exportamos nuestro schema variable, pero primero ajuste una llamada a makeExecutableSchema . Similar al gql función, cuando hacemos esto, convierte la totalidad de nuestro esquema en un AST (árbol de sintaxis abstracta) que pueden entender los servidores GraphQL y otras bibliotecas GraphQL (por ejemplo, funciones de middleware GraphQL que ayudan con la autenticación, la limitación de velocidad o el manejo de errores ).

Técnicamente hablando, con todo eso, ¡tenemos nuestro esquema GraphQL! Para concluir, echemos un vistazo a cómo se carga nuestro esquema en un servidor GraphQL.

Agregar su esquema a un servidor GraphQL

Afortunadamente, agregar un esquema a un servidor (una vez que el servidor está definido) solo requiere dos líneas:la importación del schema de nuestro /api/graphql/schema.js y luego asignarlo a las opciones de nuestro servidor.

/api/graphql/server.js

import { ApolloServer } from "apollo-server-express";
import schema from "./schema";
import { isDevelopment } from "../../.app/environment";
import loginWithToken from "../users/token";
import { configuration as corsConfiguration } from "../../middleware/cors";

export default (app) => {
  const server = new ApolloServer({
    schema,
    [...]
  });

  [...]
};

¡Eso es todo! Tenga en cuenta que la forma en que estamos pasando nuestro esquema aquí es específica de la biblioteca del servidor Apollo y no necesariamente todas Implementaciones de servidor GraphQL (Apollo es una de las pocas bibliotecas de servidor GraphQL).

Terminando

En este tutorial, aprendimos cómo organizar un esquema de GraphQL para facilitar el mantenimiento. Aprendimos a analizar las diferentes partes de nuestro esquema GraphQL en archivos individuales y a separar esos archivos en temas directamente relacionados con nuestros datos. También aprendimos cómo combinar esos archivos separados en un esquema y luego cargar ese esquema en un servidor GraphQL.