mv-api-response
v0.1.0
Published
Standardized API response envelopes for Next.js and Fastify
Downloads
96
Readme
api-response
Libreria TypeScript per uniformare le risposte delle API nei progetti Next.js (App Router) e Fastify. Definisce una struttura di envelope condivisa e fornisce adapter pronti all'uso per entrambi i framework.
Sommario
- Installazione
- Struttura delle risposte
- Utilizzo con Next.js
- Utilizzo con Fastify
- Utilizzo diretto dei builder
- Codici di errore
- Il campo
meta - Narrowing TypeScript lato client
- Riferimento API
Installazione
npm install api-responsenext e fastify sono peer dependency opzionali: installa solo quello che usi.
# Solo Next.js
npm install next
# Solo Fastify
npm install fastify
# Entrambi
npm install next fastifyStruttura delle risposte
Tutte le risposte condividono un envelope comune. Ci sono tre varianti:
Risposta di successo
{
"success": true,
"status": 200,
"data": { "id": 1, "name": "Mario Rossi" },
"meta": {
"requestId": "req_abc123",
"timestamp": "2026-03-20T12:00:00.000Z"
}
}Risposta di errore
{
"success": false,
"status": 404,
"error": {
"code": "NOT_FOUND",
"message": "Utente non trovato."
}
}Risposta con errori di validazione
{
"success": false,
"status": 422,
"error": {
"code": "VALIDATION_ERROR",
"message": "I dati inviati non sono validi.",
"details": [
{ "field": "email", "message": "Deve essere un indirizzo email valido." },
{ "field": "password", "message": "Deve contenere almeno 8 caratteri." }
]
}
}Risposta paginata
{
"success": true,
"status": 200,
"data": [{ "id": 1 }, { "id": 2 }],
"pagination": {
"page": 2,
"perPage": 10,
"total": 153,
"totalPages": 16,
"hasNextPage": true,
"hasPrevPage": true
}
}Utilizzo con Next.js
Importa dall'entry point dedicato api-response/nextjs. Le funzioni restituiscono direttamente un NextResponse.
// app/api/users/[id]/route.ts
import { errorResponse, successResponse } from "api-response/nextjs";
import { ErrorCode } from "api-response";
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const user = await db.users.findById(params.id);
if (!user) {
return errorResponse({
status: 404,
code: ErrorCode.NOT_FOUND,
message: "Utente non trovato.",
});
}
return successResponse(user);
}Risorsa creata (201)
// app/api/users/route.ts
import { errorResponse, successResponse } from "api-response/nextjs";
import { ErrorCode } from "api-response";
export async function POST(req: Request) {
const body = await req.json();
const validation = validateUserBody(body);
if (!validation.ok) {
return errorResponse({
status: 422,
code: ErrorCode.VALIDATION_ERROR,
message: "I dati inviati non sono validi.",
details: validation.errors,
});
}
const user = await db.users.create(body);
return successResponse(user, { status: 201 });
}Lista paginata
// app/api/users/route.ts
import { paginatedResponse } from "api-response/nextjs";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const page = Number(searchParams.get("page") ?? "1");
const perPage = Number(searchParams.get("perPage") ?? "20");
const [users, total] = await Promise.all([
db.users.findMany({ skip: (page - 1) * perPage, take: perPage }),
db.users.count(),
]);
return paginatedResponse(users, { page, perPage, total });
}Errore interno con meta osservabilità
import { errorResponse } from "api-response/nextjs";
import { ErrorCode } from "api-response";
export async function GET(req: Request) {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
try {
const data = await riskyOperation();
return successResponse(data, { meta: { requestId } });
} catch (err) {
return errorResponse({
status: 500,
code: ErrorCode.INTERNAL_ERROR,
message: "Errore interno del server.",
meta: { requestId },
});
}
}Utilizzo con Fastify
Importa dall'entry point dedicato api-response/fastify. Le funzioni ricevono reply e si occupano di chiamare reply.status().send().
import Fastify from "fastify";
import { sendError, sendSuccess } from "api-response/fastify";
import { ErrorCode } from "api-response";
const app = Fastify();
app.get("/users/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const user = await db.users.findById(id);
if (!user) {
return sendError(reply, {
status: 404,
code: ErrorCode.NOT_FOUND,
message: "Utente non trovato.",
});
}
return sendSuccess(reply, user);
});Risorsa creata (201)
app.post("/users", async (request, reply) => {
const validation = validateUserBody(request.body);
if (!validation.ok) {
return sendError(reply, {
status: 422,
code: ErrorCode.VALIDATION_ERROR,
message: "I dati inviati non sono validi.",
details: validation.errors,
});
}
const user = await db.users.create(request.body);
return sendSuccess(reply, user, { status: 201 });
});Lista paginata
import { sendPaginated } from "api-response/fastify";
app.get("/users", async (request, reply) => {
const { page = 1, perPage = 20 } = request.query as { page?: number; perPage?: number };
const [users, total] = await Promise.all([
db.users.findMany({ skip: (page - 1) * perPage, take: perPage }),
db.users.count(),
]);
return sendPaginated(reply, users, { page, perPage, total });
});Errore di autenticazione
app.addHook("preHandler", async (request, reply) => {
const token = request.headers.authorization?.replace("Bearer ", "");
if (!token || !isValidToken(token)) {
return sendError(reply, {
status: 401,
code: ErrorCode.UNAUTHORIZED,
message: "Token non valido o mancante.",
});
}
});Utilizzo diretto dei builder
I builder sono funzioni pure che restituiscono un plain object senza dipendere da alcun framework. Utili quando devi costruire la risposta senza inviarla immediatamente (es. logging, testing, transformazioni).
import { buildSuccess, buildError, buildPaginated } from "api-response";
import { ErrorCode } from "api-response";
const successEnvelope = buildSuccess({ id: 1, name: "Mario" });
// { success: true, status: 200, data: { id: 1, name: "Mario" } }
const errorEnvelope = buildError({
status: 403,
code: ErrorCode.FORBIDDEN,
message: "Non hai i permessi necessari.",
});
// { success: false, status: 403, error: { code: "FORBIDDEN", message: "..." } }
const paginatedEnvelope = buildPaginated(
[{ id: 1 }, { id: 2 }],
{ page: 1, perPage: 10, total: 42 },
);
// { success: true, status: 200, data: [...], pagination: { page: 1, perPage: 10, total: 42, totalPages: 5, hasNextPage: true, hasPrevPage: false } }Codici di errore
ErrorCode è un oggetto const con i codici machine-readable predefiniti:
| Costante | Valore stringa | Uso tipico |
|---|---|---|
| ErrorCode.BAD_REQUEST | "BAD_REQUEST" | Request malformata (400) |
| ErrorCode.VALIDATION_ERROR | "VALIDATION_ERROR" | Dati non validi (422) |
| ErrorCode.UNAUTHORIZED | "UNAUTHORIZED" | Autenticazione assente o non valida (401) |
| ErrorCode.FORBIDDEN | "FORBIDDEN" | Permessi insufficienti (403) |
| ErrorCode.NOT_FOUND | "NOT_FOUND" | Risorsa non trovata (404) |
| ErrorCode.CONFLICT | "CONFLICT" | Conflitto di stato (409) |
| ErrorCode.UNPROCESSABLE | "UNPROCESSABLE" | Entità non processabile (422) |
| ErrorCode.RATE_LIMITED | "RATE_LIMITED" | Troppo richieste (429) |
| ErrorCode.INTERNAL_ERROR | "INTERNAL_ERROR" | Errore interno (500) |
Codici personalizzati
Puoi usare qualsiasi stringa come code senza dover modificare la libreria:
return errorResponse({
status: 402,
code: "SUBSCRIPTION_EXPIRED",
message: "Il tuo piano è scaduto. Rinnova l'abbonamento per continuare.",
});Per avere type-safety sui tuoi codici personalizzati, crea un oggetto const nel tuo progetto:
// lib/error-codes.ts
export const AppErrorCode = {
SUBSCRIPTION_EXPIRED: "SUBSCRIPTION_EXPIRED",
QUOTA_EXCEEDED: "QUOTA_EXCEEDED",
FEATURE_DISABLED: "FEATURE_DISABLED",
} as const;Il campo meta
meta è opzionale ed è aperto a qualsiasi chiave. Non viene mai incluso nella risposta se non lo passi esplicitamente.
return successResponse(data, {
meta: {
requestId: "req_abc123", // tracciabilità
timestamp: new Date().toISOString(),
version: "2", // versioning API
duration: 42, // tempo di elaborazione in ms
region: "eu-west-1", // qualsiasi chiave custom
},
});Narrowing TypeScript lato client
AnyApiResponse<T> è una union discriminata su success. Puoi usarla per tipare le risposte sul client:
import type { AnyApiResponse } from "api-response";
async function fetchUser(id: string): Promise<AnyApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
const response = await fetchUser("123");
if (response.success) {
// TypeScript sa che response.data è User
console.log(response.data.name);
} else {
// TypeScript sa che response.error esiste
console.error(response.error.code, response.error.message);
}Riferimento API
buildSuccess<T>(data, options?)
| Parametro | Tipo | Default | Descrizione |
|---|---|---|---|
| data | T | — | Payload della risposta |
| options.status | number | 200 | HTTP status code |
| options.meta | ResponseMeta | undefined | Metadati opzionali |
buildError(options)
| Parametro | Tipo | Default | Descrizione |
|---|---|---|---|
| options.code | string | — | Codice errore machine-readable |
| options.message | string | — | Messaggio human-readable |
| options.status | number | 500 | HTTP status code |
| options.details | ErrorDetail[] | undefined | Dettagli campo per campo |
| options.meta | ResponseMeta | undefined | Metadati opzionali |
buildPaginated<T>(data, pagination, options?)
| Parametro | Tipo | Default | Descrizione |
|---|---|---|---|
| data | T[] | — | Array di risultati |
| pagination.page | number | — | Pagina corrente (1-indexed) |
| pagination.perPage | number | — | Elementi per pagina |
| pagination.total | number | — | Totale elementi |
| options.status | number | 200 | HTTP status code |
| options.meta | ResponseMeta | undefined | Metadati opzionali |
totalPages, hasNextPage e hasPrevPage vengono calcolati automaticamente.
Adapter Next.js (api-response/nextjs)
successResponse<T>(data, options?)→NextResponseerrorResponse(options)→NextResponsepaginatedResponse<T>(data, pagination, options?)→NextResponse
Adapter Fastify (api-response/fastify)
sendSuccess<T>(reply, data, options?)→voidsendError(reply, options)→voidsendPaginated<T>(reply, data, pagination, options?)→void
