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.