Conceptos, ideas, estrategias
Conceptos, ideas, estrategiasDiseñar la aplicación para que funcione con distintos servidores GraphQL

Diseñar la aplicación para que funcione con distintos servidores GraphQL

"Programar contra interfaces, no contra implementaciones" es la práctica de invocar una funcionalidad no directamente, sino a través de un contrato que enumera las entradas necesarias y el resultado esperado, ocultando cómo se realiza la implementación. Esta estrategia ayuda a desacoplar la aplicación de una implementación, proveedor o stack específicos, permitiendo intercambiarlos sin tener que modificar el código de la aplicación.

Podemos aplicar esta estrategia también con GraphQL. GraphQL puede actuar como intermediario entre la aplicación y el servidor, permitiéndonos ejecutar todas las modificaciones necesarias únicamente sobre las consultas GraphQL, manteniendo la lógica de negocio intacta.

Una consulta GraphQL actúa como interfaz entre el cliente y el servidor. Cuando se ejecuta una consulta, el servidor GraphQL la procesará y devolverá los datos requeridos al cliente. ¿De dónde provienen los datos? ¿Cómo se obtuvieron? El cliente no lo sabe, y no le importa.

La consulta GraphQL actúa como interfaz entre cliente y servidor

La respuesta a la consulta tendrá la misma forma que la consulta. Para esta consulta GraphQL:

{
  post(by: { id: 1 }) {
    id
    title
  }
}

...la respuesta será:

{
  "data": {
    "post": {
      "id": 1,
      "title": "Hello world!"
    }
  }
}

Dada la misma consulta con diferentes parámetros, los datos devueltos serán distintos, pero la forma será constante. Esto significa que, mientras la consulta no cambie, la aplicación no necesita cambiar su lógica respecto a cómo leer y procesar los datos, y de igual modo no importará qué servidor GraphQL esté ejecutando la consulta.

Y así podemos intercambiar un servidor GraphQL por otro sin esfuerzo.

Las consultas dependen del esquema GraphQL

Ahora bien, el último párrafo es algo optimista, porque la consulta GraphQL puede tener que cambiar dependiendo del servidor GraphQL. Para ser más precisos, la consulta se basa en el esquema GraphQL, y si servidores distintos exponen esquemas distintos, entonces la consulta también será distinta.

Por ejemplo, un servidor GraphQL que use la Cursor Connections Specification puede ejecutar la siguiente consulta:

{
  categories(first: 10000) {
    edges {
      node {
        categoryId
        description
        id
        name
        slug
      }
    }
  }
}

Y otro servidor que use paginación al estilo WordPress (como Gato GraphQL) ejecutará la misma consulta así:

{
  postCategories(pagination: { limit: 10000 }) {
    id
    description
    globalID
    name
    slug
  }
}

Podemos apreciar las diferencias entre las dos consultas:

CaracterísticaServidor #1Servidor #2
Campo de categorías de postcategoriespostCategories
Argumento de campo para limitar el número de resultadosfirstpagination.limit
El campo id de un objeto representasu ID global únicosu ID único para su tipo
Forma de la consultamás profunda por edges.nodemás plana

Sustituir la consulta del primer servidor por la equivalente del segundo dentro de la aplicación, por sí solo, no funcionará. Eso es porque la lógica seguirá accediendo a los datos de la respuesta según la forma y los campos de la consulta original.

Una posible solución es reemplazar también la lógica para recuperar los datos en el cliente. Por ejemplo, la siguiente lógica:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

...puede reemplazarse así:

const categories = data?.data.postCategories;

Pero eso es precisamente lo que queremos evitar. Queremos mantener los cambios al mínimo, modificando únicamente la interfaz (la consulta GraphQL) y manteniendo la lógica de negocio sin alterar.

Afortunadamente, es posible superar las diferencias modificando solo las consultas GraphQL, siguiendo estos pasos:

  1. Mantener las consultas GraphQL separadas de la aplicación
  2. Adaptar los nombres de los campos mediante alias
  3. Adaptar la forma de la respuesta mediante un campo self

Veamos cómo, mediante estos 3 pasos, podemos adaptar una aplicación para que apunte a un servidor GraphQL distinto.

Mantener las consultas GraphQL separadas de la aplicación

Separar las consultas GraphQL de la lógica de la aplicación implica:

  • Almacenar cada consulta GraphQL (o un conjunto de ellas) en un archivo aparte, y todos ellos en una carpeta específica
  • Exportar las consultas e importarlas en la aplicación

Por ejemplo, podemos colocar cada consulta GraphQL en un archivo aparte bajo src/data, y exportarla:

// file `src/data/categories.js`
export const QUERY_ALL_CATEGORIES = gql`
  {
    categories(first: 10000) {
      edges {
        node {
          databaseId
          description
          id
          name
          slug
        }
      }
    }
  }
`;

La aplicación puede entonces importar y usar la consulta GraphQL:

import { QUERY_ALL_CATEGORIES } from 'data/categories';
 
export async function getAllCategories() {
  const apolloClient = getApolloClient();
 
  const data = await apolloClient.query({
    query: QUERY_ALL_CATEGORIES,
  });
 
  const categories = data?.data.categories.edges.map(({ node = {} }) => node);
 
  return {
    categories,
  };
}

Gracias a esta configuración, todas las modificaciones solo deben realizarse sobre los archivos bajo src/data.

Adaptar los nombres de los campos mediante alias

Se puede usar un alias de campo para renombrar un campo de la respuesta del segundo servidor GraphQL al nombre de ese campo en el primer servidor.

De esta forma, los campos postCategories, id y globalID se pueden recuperar usando los nombres que espera la aplicación: categories, categoryId e id respectivamente:

{
  categories: postCategories(pagination: { limit: 10000 }) {
    categoryId: id
    description
    id: globalID
    name
    slug
  }
}

Hay que tener en cuenta que el campo categories tiene argumento first, mientras que su campo correspondiente postCategories usa el argumento pagination.limit. Sin embargo, dado que los argumentos del campo no se reflejan en el nombre del campo en la respuesta, no nos tenemos que preocupar por ellos.

Adaptar la forma de la respuesta mediante un campo self

El último reto es algo más complicado: necesitamos modificar la forma de la respuesta, añadiendo los niveles adicionales para edges y node provenientes de la spec Cursor Connections.

Para lograrlo, introduciremos un campo self en todos los tipos del esquema GraphQL, que devuelve el mismo objeto sobre el que se aplica:

type QueryRoot {
  self: QueryRoot!
}
 
type Post {
  self: Post!
}
 
type User {
  self: User!
}

El campo self permite añadir niveles adicionales a la consulta sin salir del objeto consultado. Al ejecutar esta consulta:

{
  __typename
  self {
    __typename
  }
  
  post(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
  
  user(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
}

...se produce esta respuesta:

{
  "data": {
    "__typename": "QueryRoot",
    "self": {
      "__typename": "QueryRoot"
    },
    "post": {
      "self": {
        "id": 1,
        "__typename": "Post"
      }
    },
    "user": {
      "self": {
        "id": 1,
        "__typename": "User"
      }
    }
  }
}

Ahora, podemos usar self para añadir artificialmente los niveles nodes y edge:

{
  categories: self {
    edges: postCategories(pagination: { limit: 10000 }) {
      node: self {
        categoryId: id
        description
        id: globalID
        name
        slug
      }
    }
  }
}

El tipo del objeto en el esquema GraphQL para edges y para self es obviamente distinto. Pero eso no le importa a la aplicación, porque no interactúa con el objeto real modelado en el servidor GraphQL. En su lugar, recibe los datos como un objeto JSON, y ese fragmento de datos para un campo proveniente de un objeto PostConnection o de un objeto Post será el mismo.

Hay que tener en cuenta que el campo categories se resuelve mediante self y edges se resuelve mediante postCategories, y no al revés. Esto es para mantener que la cardinalidad de los elementos devueltos coincida con la definida por los campos que usan la spec Cursor Connections:

type RootQuery {
  categories: RootQueryToCategoryConnection
}
 
type RootQueryToCategoryConnection {
  edges: [RootQueryToCategoryConnectionEdge]
}
 
type RootQueryToCategoryConnectionEdge {
  node: Category
}

Si la consulta GraphQL adaptada fuera al revés (es decir, consultando categories: postCategories y edges: self), el acceso a los datos fallaría, porque data.categories sería un array, así que data.categories.edges lanzaría un error al ejecutar:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

Adaptar todas las consultas

Tras aplicar la misma estrategia a todas las consultas GraphQL en src/data, la aplicación puede intercambiar fácilmente entre un servidor GraphQL y otro.