Creare un'API GraphQL in Node.js

Questo articolo è una guida passo dopo passo alla creazione di una piccola API GraphQL in Node.js focalizzata su un unico modello di dati. L'obiettivo è capire come definire lo schema, organizzare i resolver, gestire ordinamento e paginazione, aggiungere validazione e autorizzazione e pubblicare l'endpoint dell'API con CORS correttamente configurato. Mantenere il perimetro a un solo modello aiuta a isolare i concetti fondamentali e a predisporre una base estendibile ad altri modelli.

Architettura minima e librerie

Per un setup chiaro e didattico è utile usare uno stack essenziale:

  • Runtime: Node.js LTS
  • GraphQL server: Apollo Server (con Express o Fastify) oppure GraphQL Yoga
  • Schema e resolvers: graphql e file modulari
  • Persistenza (facoltativa ma consigliata): ORM/ODM come Prisma, Knex, TypeORM o Mongoose; in alternativa, un layer repository con query SQL grezze
  • Validazione: una libreria come Zod o Yup
  • Autorizzazione: middleware di autenticazione che popola il context di GraphQL

Il flusso tipico è: definire il tipo Post, esporre query di elenco e dettaglio, mutation di creazione/aggiornamento/eliminazione, abilitare paginazione e ordinamento su publishedAt, e chiudere con CORS, validazione e autorizzazione.

Schema GraphQL: tipo Post e operazioni

Lo schema è la superficie dell'API. Per restare focalizzati, il tipo Post espone un set di campi basilari; le query permettono elenco e dettaglio; le mutation coprono le operazioni CRUD.

"""Rappresentazione di un contenuto pubblicato."""
type Post {
  id: ID!
  title: String!
  body: String!
  publishedAt: String
  createdAt: String!
  updatedAt: String!
}

"""Metadati di paginazione (page-based)."""
type PaginatorInfo {
  count: Int!
  currentPage: Int!
  lastPage: Int!
  perPage: Int!
  total: Int!
  hasMorePages: Boolean!
}

"""Risultato paginato di Post."""
type PostPaginator {
  data: [Post!]!
  paginatorInfo: PaginatorInfo!
}

type Query {
  posts(page: Int = 1, perPage: Int = 10, sort: String = "desc"): PostPaginator!
  post(id: ID!): Post
}

type Mutation {
  createPost(title: String!, body: String!, publishedAt: String): Post!
  updatePost(id: ID!, title: String, body: String, publishedAt: String): Post!
  deletePost(id: ID!): Boolean!
}

Resolver: strategia e responsabilità

I resolver traducono le richieste in operazioni sullo strato dati. Una buona pratica è mantenere i resolver sottili, delegando a servizi o repository la logica di persistenza e alle librerie di validazione i controlli dell'input. L'ordinamento per publishedAt e la paginazione “page-based” (con page e perPage) sono semplici ed efficaci.

// resolvers.js (esemplificativo)
import { z } from "zod";

// Esempio di validazione input
const createPostSchema = z.object({
  title: z.string().min(1).max(255),
  body: z.string().min(1),
  publishedAt: z.string().datetime().optional(),
});

const updatePostSchema = z.object({
  id: z.string().min(1),
  title: z.string().min(1).max(255).optional(),
  body: z.string().min(1).optional(),
  publishedAt: z.string().datetime().optional().nullable(),
});

// Un semplice repository astratto (sostituisci con Prisma/Knex/Mongoose)
const PostRepo = {
  async list({ page, perPage, sort }) {
    const order = String(sort).toLowerCase() === "asc" ? "asc" : "desc";
    const offset = (page - 1) * perPage;

    // Esempi con SQL/ORM:
    // - Prisma: findMany({ skip: offset, take: perPage, orderBy: { publishedAt: order } })
    // - Knex: select(...).orderBy('published_at', order).limit(perPage).offset(offset)
    // Qui simuliamo con funzioni del tuo layer dati:
    const [rows, total] = await Promise.all([
      db.posts.findMany({ offset, limit: perPage, orderBy: { publishedAt: order } }),
      db.posts.count(),
    ]);

    const lastPage = Math.max(1, Math.ceil(total / perPage));
    return {
      data: rows,
      paginatorInfo: {
        count: rows.length,
        currentPage: page,
        lastPage,
        perPage,
        total,
        hasMorePages: page < lastPage,
      },
    };
  },
  async findById(id) {
    return db.posts.findById(id); // restituisce null se assente
  },
  async create(data) {
    return db.posts.create(data);
  },
  async update(id, patch) {
    return db.posts.update(id, patch); // solleva se non trovato
  },
  async remove(id) {
    return db.posts.delete(id); // true/false
  },
};

export const resolvers = {
  Query: {
    posts: async (_, args, ctx) => {
      // Autorizzazione di lettura, se richiesto
      // if (!ctx.user) throw new Error("Unauthorized");
      const page = Number(args.page ?? 1) || 1;
      const perPage = Math.min(Number(args.perPage ?? 10) || 10, 100);
      const sort = args.sort ?? "desc";
      return PostRepo.list({ page, perPage, sort });
    },
    post: async (_, { id }, ctx) => {
      return PostRepo.findById(id);
    },
  },
  Mutation: {
    createPost: async (_, args, ctx) => {
      if (!ctx.user) throw new Error("Unauthorized");
      const input = createPostSchema.parse(args);
      const now = new Date().toISOString();
      return PostRepo.create({
        title: input.title,
        body: input.body,
        publishedAt: input.publishedAt ?? null,
        createdAt: now,
        updatedAt: now,
      });
    },
    updatePost: async (_, args, ctx) => {
      if (!ctx.user) throw new Error("Unauthorized");
      const input = updatePostSchema.parse(args);
      const patch = {
        ...(input.title !== undefined ? { title: input.title } : {}),
        ...(input.body !== undefined ? { body: input.body } : {}),
        ...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}),
        updatedAt: new Date().toISOString(),
      };
      return PostRepo.update(input.id, patch);
    },
    deletePost: async (_, { id }, ctx) => {
      if (!ctx.user) throw new Error("Unauthorized");
      return PostRepo.remove(id);
    },
  },
};

Bootstrap del server GraphQL

Un server minimale con Apollo Server su Express è sufficiente. È importante costruire il context per passare l'utente autenticato ai resolver e applicare il middleware CORS sull'endpoint /graphql.

// server.js (esemplificativo)
import http from "node:http";
import express from "express";
import cors from "cors";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { resolvers } from "./resolvers.js";
import typeDefs from "./schema.js";

const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();

// CORS: elenca gli origin effettivi se invii cookie/Authorization
app.use("/graphql", cors({
  origin: ["http://localhost:5173", "https://app.example.com"],
  credentials: true,
  allowedHeaders: ["content-type", "authorization"],
  methods: ["GET", "POST", "OPTIONS"],
}));

app.use(express.json());

const apollo = new ApolloServer({ schema });
await apollo.start();

app.use("/graphql", expressMiddleware(apollo, {
  context: async ({ req }) => {
    const token = req.headers.authorization || "";
    // Decodifica token / sessione e costruisci un oggetto utente
    const user = await authService.fromAuthHeader(token);
    return { user };
  },
}));

const server = http.createServer(app);
server.listen(4000, () => {
  console.log("GraphQL pronto su http://localhost:4000/graphql");
});

Paginazione e ordinamento: scelte di design

La paginazione “page-based” è immediata da implementare con page e perPage e funziona bene per elenchi amministrativi o pagine statiche. Se in futuro serviranno infinite scroll o aggiornamenti continui, si può introdurre una paginazione a cursori, codificando un cursore opaco ricavato da publishedAt e id. Per dataset medio-grandi, è utile un indice sul campo di ordinamento per mantenere la latenza bassa.

Validazione e autorizzazione

Convalidare l'input nel resolver evita errori difficili da diagnosticare e protegge il database. Librerie come Zod o Yup rendono le regole esplicite e riutilizzabili. L'autorizzazione può essere espressa come controllo nel resolver oppure tramite direttive o middleware che intercettano la richiesta prima della risoluzione, popolando il context con le informazioni sull'utente. In ogni caso, è preferibile restituire errori chiari e consistenti.

CORS e preflight su /graphql

Per richieste da un origin differente, è necessario che il preflight OPTIONS riceva gli header corretti. Assicurarsi che il middleware CORS consenta i metodi necessari e includa authorization tra gli header ammessi quando si usano bearer token. È sconsigliato usare wildcard con credenziali attive; elencare invece gli origin consentiti.

// Esempio di chiamata fetch lato client con credenziali/autorizzazione
await fetch("https://api.example.com/graphql", {
  method: "POST",
  credentials: "include",
  headers: {
    "content-type": "application/json",
    "authorization": "Bearer <token>",
  },
  body: JSON.stringify({ query, variables }),
});

Esempi di operazioni dal punto di vista del client

Le query domandano solo i campi necessari; le mutation restituiscono l'oggetto Post aggiornato o un boolean per l'eliminazione. Una mutation che ritorna Boolean! non accetta una subselection.

query {
  posts(page: 1, perPage: 5, sort: "desc") {
    data { id title publishedAt }
    paginatorInfo { currentPage lastPage perPage total hasMorePages count }
  }
}

query {
  post(id: "123") { id title body publishedAt }
}

mutation {
  createPost(title: "Titolo", body: "Testo", publishedAt: "2025-01-01T00:00:00Z") {
    id title publishedAt
  }
}

mutation {
  updatePost(id: "123", title: "Nuovo titolo") { id title updatedAt }
}

mutation {
  deletePost(id: "123")
}

Performance e robustezza

  • Selettività dei campi: lascia che i client scegliano i campi e mappa la selezione in query efficienti (solo le colonne necessarie).
  • Indici: indicizza publishedAt e i campi usati nei filtri/ordinamenti.
  • Limiti e rate control: imposta limiti su perPage e sulla complessità/densità delle query.
  • Errori prevedibili: per validazione e autorizzazione restituisci messaggi coerenti; logga gli errori inattesi lato server.

Conclusione

Con pochi file e alcune buone pratiche si ottiene un'API GraphQL in Node.js centrata su Post: schema leggibile, resolver snelli, paginazione e ordinamento intuitivi, validazione e autorizzazione esplicite, CORS configurato per l'uso in produzione. Questa base è facilmente estendibile ad altri modelli, mantenendo lo stesso stile e gli stessi principi.

Torna su