Concetti, idee, strategie
Concetti, idee, strategieProgettare l'applicazione affinché funzioni con diversi server GraphQL

Progettare l'applicazione affinché funzioni con diversi server GraphQL

«Programmare contro le interfacce, non le implementazioni» è la pratica di invocare una funzionalità non direttamente, ma attraverso un contratto che enumera quali input sono richiesti e quale output ci si aspetta, nascondendo al contempo come l'implementazione viene realizzata. Questa strategia aiuta a disaccoppiare l'applicazione da una specifica implementazione, fornitore o stack, permettendo di scambiarli tra loro senza dover modificare il codice dell'applicazione.

Possiamo applicare questa strategia anche con GraphQL. GraphQL può agire come intermediario tra l'applicazione e il server, permettendoci di eseguire tutte le modifiche necessarie solo sulle query GraphQL, mantenendo intatta la logica di business.

Una query GraphQL agisce come un'interfaccia tra il client e il server. Durante l'esecuzione di una query, il server GraphQL la elaborerà e restituirà i dati richiesti al client. Da dove provengono i dati? Come sono stati ottenuti? Il client non lo sa e non se ne preoccupa.

La query GraphQL agisce come un'interfaccia tra il client e il server

La risposta alla query avrà la stessa forma della query. Per questa query GraphQL:

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

...la risposta sarà:

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

Data la stessa query con parametri diversi, i dati restituiti saranno diversi, ma la forma sarà costante. Questo significa che, finché la query non cambia, l'applicazione non ha bisogno di modificare la propria logica su come leggere ed elaborare i dati, e allo stesso modo non importerà quale server GraphQL stia eseguendo la query.

E così possiamo sostituire un server GraphQL con un altro in modo del tutto trasparente.

Le query dipendono dallo schema GraphQL

Ora, l'ultimo paragrafo è un po' troppo ottimista, perché la query GraphQL potrebbe dover cambiare a seconda del server GraphQL. Per essere più precisi, la query si basa sullo schema GraphQL, e se server diversi espongono schemi diversi, allora anche la query sarà diversa.

Ad esempio, un server GraphQL che utilizza la Cursor Connections Specification potrebbe eseguire la seguente query:

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

E un altro server che utilizza la paginazione in stile WordPress (come Gato GraphQL) eseguirà la stessa query in questo modo:

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

Possiamo apprezzare le differenze tra le due query:

CaratteristicaServer #1Server #2
Campo delle categorie di articolocategoriespostCategories
Argomento di campo per limitare il numero di risultatifirstpagination.limit
Il campo id di un oggetto rappresentail suo ID globale univocoil suo ID univoco per il suo tipo
Forma della querypiù profonda a causa di edges.nodepiù piatta

Sostituire la query del primo server con quella equivalente del secondo all'interno dell'applicazione, da sola, non funzionerà. Questo perché la logica continuerà ad accedere ai dati della risposta secondo la forma e i campi della query originale.

Una possibile soluzione è sostituire anche la logica per recuperare i dati nel client. Ad esempio, la seguente logica:

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

...può essere sostituita così:

const categories = data?.data.postCategories;

Ma questo è esattamente ciò che vogliamo evitare. Vogliamo limitare i cambiamenti allo stretto necessario, modificando solo l'interfaccia (la query GraphQL) e mantenendo la logica di business invariata.

Fortunatamente, è possibile colmare le differenze modificando solo le query GraphQL, seguendo questi passaggi:

  1. Mantenere le query GraphQL separate dall'applicazione
  2. Adattare i nomi dei campi tramite alias
  3. Adattare la forma della risposta tramite un campo self

Vediamo come, tramite questi 3 passaggi, possiamo adattare un'applicazione affinché punti a un diverso server GraphQL.

Mantenere le query GraphQL separate dall'applicazione

Separare le query GraphQL dalla logica dell'applicazione comporta:

  • Memorizzare ogni query GraphQL (o un gruppo di esse) in un file separato, e tutte in una cartella specifica
  • Esportare le query e importarle nell'applicazione

Ad esempio, possiamo collocare ogni query GraphQL in un file separato sotto src/data, ed esportarla:

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

L'applicazione può quindi importare e utilizzare la query 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,
  };
}

Grazie a questa configurazione, tutte le modifiche devono essere effettuate solo sui file sotto src/data.

Adattare i nomi dei campi tramite alias

Un alias di campo può essere utilizzato per rinominare un campo nella risposta del secondo server GraphQL con il nome di quel campo nel primo server.

In questo modo, i campi postCategories, id e globalID possono essere recuperati utilizzando i nomi attesi dall'applicazione: rispettivamente categories, categoryId e id:

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

Si noti che il campo categories ha l'argomento first, mentre il suo campo corrispondente postCategories utilizza l'argomento pagination.limit. Tuttavia, poiché gli argomenti di campo non si riflettono nel nome del campo nella risposta, non dobbiamo preoccuparcene.

Adattare la forma della risposta tramite un campo self

La sfida finale è un po' più delicata: dobbiamo modificare la forma della risposta, aggiungendo i livelli supplementari per edges e node provenienti dalla spec Cursor Connections.

Per ottenere questo, introdurremo un campo self in tutti i tipi dello schema GraphQL, che restituisce lo stesso oggetto su cui viene applicato:

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

Il campo self permette di aggiungere livelli supplementari alla query senza lasciare l'oggetto interrogato. Eseguendo questa query:

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

...si produce questa risposta:

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

Ora, possiamo usare self per aggiungere artificialmente i livelli nodes e edge:

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

Il tipo dell'oggetto nello schema GraphQL per edges e per self è ovviamente diverso. Ma questo non ha importanza per l'applicazione, perché non interagisce con l'oggetto reale modellato nel server GraphQL. Invece, riceve i dati sotto forma di oggetto JSON, e quella porzione di dati per un campo proveniente da un oggetto PostConnection o da un oggetto Post sarà la stessa.

Si noti che il campo categories è risolto tramite self ed edges è risolto tramite postCategories, e non il contrario. Questo serve a mantenere la cardinalità degli elementi restituiti corrispondente a quella definita dai campi che utilizzano la spec Cursor Connections:

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

Se la query GraphQL adattata fosse invertita (cioè interrogando categories: postCategories ed edges: self), l'accesso ai dati fallirebbe, perché data.categories sarebbe un array, quindi data.categories.edges genererebbe un errore durante l'esecuzione:

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

Adattare tutte le query

Dopo aver applicato la stessa strategia a tutte le query GraphQL in src/data, l'applicazione può facilmente passare da un server GraphQL a un altro.