Aggiungere un Nuovo Modulo API
Guida step-by-step per creare un nuovo modulo API completo in elerama-frontend.
Panoramica
Un modulo API completo richiede:
- Schema Zod - Validazione e tipi TypeScript
- API Client - Funzioni per chiamate HTTP
- Query Keys - Chiavi per cache TanStack Query
- Query Helpers - Funzioni per invalidazione cache
- Hooks TanStack Query - useQuery/useMutation per i componenti
Struttura File
La struttura rispecchia il path delle API. Per moduli sotto erp/admin/:
app/
├── schemas/admin/{modulo}/
│ └── {modulo}.schema.ts
├── api/admin/{modulo}/
│ └── {modulo}.api.ts
└── hooks/api/admin/{modulo}/
├── {modulo}.query-keys.ts
├── {modulo}.query-helpers.ts
└── use{Modulo}Api.tsNota: La directory intermedia (es.
admin/) riflette la struttura delle API backend. Per endpointerp/admin/brands→ directoryadmin/brands/
Step 1: Schema Zod
File: app/schemas/admin/{modulo}/{modulo}.schema.ts
import { createApiResponseSchema } from "@/schemas/api.schema";
import { z } from "zod";
/**
* Schema base per l'entità
*/
export const EntitySchema = z.object({
id: z.number(),
code: z.string(),
description: z.string(),
});
export type Entity = z.infer<typeof EntitySchema>;
/**
* Schema per la lista (include campi extra)
*/
export const EntityListItemSchema = EntitySchema.extend({
isDeleted: z.boolean(),
deletedAt: z.string().nullable(),
});
export type EntityListItem = z.infer<typeof EntityListItemSchema>;
/**
* Schema risposta API lista
*/
export const EntitiesListResponseSchema = createApiResponseSchema(
z.array(EntityListItemSchema)
);
export type EntitiesListResponse = z.infer<typeof EntitiesListResponseSchema>;
/**
* Schema richiesta creazione/modifica
*/
export const EntityRequestSchema = z.object({
code: z.string().min(1, "Codice obbligatorio").max(50),
description: z.string().min(1, "Descrizione obbligatoria").max(255),
});
export type EntityRequest = z.infer<typeof EntityRequestSchema>;
/**
* Schema parametri lista
*/
export const EntitiesListParamsSchema = z.object({
search: z.string().optional(),
includeDeleted: z.boolean().optional(),
});
export type EntitiesListParams = z.infer<typeof EntitiesListParamsSchema>;Convenzioni Schema
- Usa
createApiResponseSchema()per wrapper API standard - Separa schema base, lista, dettaglio se hanno campi diversi
- Schema request per validazione input form
- Schema params per parametri query string
- Esporta sempre i tipi TypeScript con
z.infer<>
Step 2: API Client
File: app/api/admin/{modulo}/{modulo}.api.ts
import { apiRequest, buildQueryString, getModuleApiUrl } from "@/lib/api";
import { ApiResponseSchema } from "@/schemas/api.schema";
import {
EntityRequestSchema,
EntityResponseSchema,
EntitiesListParamsSchema,
EntitiesListResponseSchema,
type Entity,
type EntityRequest,
type EntitiesListParams,
} from "@/schemas/admin/{modulo}/{modulo}.schema";
// URL base per il modulo
const API_BASE_URL = getModuleApiUrl("erp/admin/{modulo}");
/**
* GET /erp/admin/{modulo} - Lista entità
*/
export async function getEntitiesList(
params?: EntitiesListParams
): Promise<EntityListItem[]> {
const validatedParams = params
? EntitiesListParamsSchema.parse(params)
: undefined;
const queryString = validatedParams
? buildQueryString(
Object.fromEntries(
Object.entries(validatedParams).filter(
([, v]) => v !== undefined
)
) as Record<string, string | number | boolean>
)
: "";
const response = await apiRequest<EntitiesListResponse>(
API_BASE_URL,
queryString,
{ method: "GET" },
EntitiesListResponseSchema
);
return response.data ?? [];
}
/**
* GET /erp/admin/{modulo}/{id} - Dettaglio entità
*/
export async function getEntity(id: number): Promise<Entity> {
const response = await apiRequest(
API_BASE_URL,
`/${id}`,
{ method: "GET" },
EntityResponseSchema
);
if (!response.data) {
throw new Error("Entità non trovata");
}
return response.data;
}
/**
* POST /erp/admin/{modulo} - Crea entità
*/
export async function createEntity(data: EntityRequest): Promise<Entity> {
const validatedData = EntityRequestSchema.parse(data);
const response = await apiRequest(
API_BASE_URL,
"",
{
method: "POST",
body: JSON.stringify(validatedData),
},
EntityResponseSchema
);
if (!response.data) {
throw new Error("Errore nella creazione");
}
return response.data;
}
/**
* PUT /erp/admin/{modulo}/{id} - Aggiorna entità
*/
export async function updateEntity(
id: number,
data: EntityRequest
): Promise<Entity> {
const validatedData = EntityRequestSchema.parse(data);
const response = await apiRequest(
API_BASE_URL,
`/${id}`,
{
method: "PUT",
body: JSON.stringify(validatedData),
},
EntityResponseSchema
);
if (!response.data) {
throw new Error("Errore nell'aggiornamento");
}
return response.data;
}
/**
* DELETE /erp/admin/{modulo}/{id} - Elimina entità
*/
export async function deleteEntity(id: number): Promise<void> {
await apiRequest(
API_BASE_URL,
`/${id}`,
{ method: "DELETE" },
ApiResponseSchema
);
}Convenzioni API Client
- Usa
getModuleApiUrl()per costruire URL base - Valida sempre input con schema Zod
- Usa
buildQueryString()per parametri query - Documenta ogni funzione con JSDoc in italiano
- Una funzione per ogni endpoint
Step 3: Query Keys
File: app/hooks/api/admin/{modulo}/{modulo}.query-keys.ts
/**
* Query keys per {Modulo} API
*/
import type { EntitiesListParams } from "@/schemas/admin/{modulo}/{modulo}.schema";
export const entitiesQueryKeys = {
all: ["{modulo}"] as const,
lists: () => [...entitiesQueryKeys.all, "list"] as const,
list: (params?: EntitiesListParams) =>
[...entitiesQueryKeys.lists(), params] as const,
details: () => [...entitiesQueryKeys.all, "detail"] as const,
detail: (id: number) => [...entitiesQueryKeys.details(), id] as const,
} as const;Struttura Gerarchica Query Keys
all → Invalida tutto il modulo
├── lists → Invalida tutte le liste
│ └── list(params) → Lista specifica con filtri
└── details → Invalida tutti i dettagli
└── detail(id) → Dettaglio specificoStep 4: Query Helpers
File: app/hooks/api/admin/{modulo}/{modulo}.query-helpers.ts
/**
* Helper per invalidazioni queries
*/
import { queryClient } from "@/lib/query-client";
import { entitiesQueryKeys } from "./{modulo}.query-keys";
/**
* Invalida query specifiche
*/
export function invalidateEntitiesQueries(
scope: "all" | "lists" | "details"
): void {
switch (scope) {
case "all":
queryClient.invalidateQueries({
queryKey: entitiesQueryKeys.all,
});
break;
case "lists":
queryClient.invalidateQueries({
queryKey: entitiesQueryKeys.lists(),
});
break;
case "details":
queryClient.invalidateQueries({
queryKey: entitiesQueryKeys.details(),
});
break;
}
}
/**
* Invalida dettaglio specifico
*/
export function invalidateEntityDetail(id: number): void {
queryClient.invalidateQueries({
queryKey: entitiesQueryKeys.detail(id),
});
}Step 5: Hooks TanStack Query
File: app/hooks/api/admin/{modulo}/use{Modulo}Api.ts
/**
* Hooks TanStack Query per {Modulo} API
*/
import {
createEntity,
deleteEntity,
getEntity,
getEntitiesList,
updateEntity,
} from "@/api/admin/{modulo}/{modulo}.api";
import { ApiError } from "@/lib/api";
import type {
Entity,
EntityListItem,
EntityRequest,
EntitiesListParams,
} from "@/schemas/admin/{modulo}/{modulo}.schema";
import {
useMutation,
useQuery,
type UseMutationOptions,
type UseQueryOptions,
} from "@tanstack/react-query";
import {
invalidateEntitiesQueries,
invalidateEntityDetail,
} from "./{modulo}.query-helpers";
import { entitiesQueryKeys } from "./{modulo}.query-keys";
/**
* Hook per lista entità
*/
export function useEntitiesList(
params?: EntitiesListParams,
options?: Omit<
UseQueryOptions<EntityListItem[], ApiError>,
"queryKey" | "queryFn"
>
) {
return useQuery<EntityListItem[], ApiError>({
queryKey: entitiesQueryKeys.list(params),
queryFn: () => getEntitiesList(params),
...options,
});
}
/**
* Hook per dettaglio entità
*/
export function useEntity(
id: number,
options?: Omit<
UseQueryOptions<Entity, ApiError>,
"queryKey" | "queryFn"
>
) {
return useQuery<Entity, ApiError>({
queryKey: entitiesQueryKeys.detail(id),
queryFn: () => getEntity(id),
...options,
});
}
/**
* Hook per creare entità
*/
export function useCreateEntity(
options?: Omit<
UseMutationOptions<Entity, ApiError, EntityRequest>,
"mutationFn"
>
) {
return useMutation<Entity, ApiError, EntityRequest>({
mutationFn: createEntity,
onSuccess: (...args) => {
invalidateEntitiesQueries("lists");
options?.onSuccess?.(...args);
},
...options,
});
}
/**
* Hook per aggiornare entità
*/
export function useUpdateEntity(
options?: Omit<
UseMutationOptions<
Entity,
ApiError,
{ id: number; data: EntityRequest }
>,
"mutationFn"
>
) {
return useMutation<Entity, ApiError, { id: number; data: EntityRequest }>({
mutationFn: ({ id, data }) => updateEntity(id, data),
onSuccess: (data, variables, ...rest) => {
invalidateEntitiesQueries("lists");
invalidateEntityDetail(variables.id);
options?.onSuccess?.(data, variables, ...rest);
},
...options,
});
}
/**
* Hook per eliminare entità
*/
export function useDeleteEntity(
options?: Omit<
UseMutationOptions<void, ApiError, number>,
"mutationFn"
>
) {
return useMutation<void, ApiError, number>({
mutationFn: deleteEntity,
onSuccess: (...args) => {
invalidateEntitiesQueries("lists");
options?.onSuccess?.(...args);
},
...options,
});
}Pattern Hooks
- useQuery per operazioni GET (lettura)
- useMutation per operazioni POST/PUT/DELETE (scrittura)
- Invalida cache automaticamente dopo mutation
- Permetti override delle options
Utilizzo nei Componenti
import { useEntitiesList, useCreateEntity } from "@/hooks/api/admin/{modulo}/use{Modulo}Api";
export default function EntitiesPage() {
// Lista con filtri
const { data: entities, isLoading, error } = useEntitiesList({
search: searchTerm,
includeDeleted: false,
});
// Creazione con callbacks
const { mutate: create, isPending } = useCreateEntity({
onSuccess: () => {
toast.success("Creato!");
},
onError: (error) => {
toast.error(error.message);
},
});
const handleCreate = (data: EntityRequest) => {
create(data);
};
// ...
}Checklist
- [ ] Schema Zod creato con tipi esportati
- [ ] API client con funzioni per ogni endpoint
- [ ] Query keys gerarchici definiti
- [ ] Query helpers per invalidazione
- [ ] Hooks TanStack Query per query e mutation
- [ ] Invalidazione automatica dopo mutation
- [ ] Commenti JSDoc in italiano
- [ ] NO barrel files (import diretti)
Riferimenti
- TanStack Query Guide
- Best Practices
- Esempio completo:
app/hooks/api/admin/brands/
Appendice: API Utilities
Questa sezione descrive in dettaglio le utility di app/lib/api.ts usate in tutto il progetto.
Signature apiRequest
async function apiRequest<T>(
baseUrl: string, // URL base API (es. "https://api.local.daisy/erp/auth")
endpoint: string, // Endpoint relativo (es. "/login")
options: RequestInit, // Opzioni fetch (method, body, headers)
schema: z.ZodType<T> // Schema Zod per validazione
): Promise<T>| Parametro | Tipo | Descrizione | Esempio |
|---|---|---|---|
baseUrl | string | URL base dell'API | "https://api.local.daisy/erp/auth" |
endpoint | string | Endpoint relativo | "/login" o "/products/123" |
options | RequestInit | Opzioni fetch standard | { method: "POST", body: "..." } |
schema | ZodType<T> | Schema Zod per validare risposta | LoginResponseSchema |
Headers Automatici
Ogni richiesta include automaticamente:
headers: {
"Content-Type": "application/json",
"Daisy-Erp": "frontend",
...options.headers, // Puoi aggiungere altri header
}
credentials: "include" // Cookies automaticibuildQueryString()
Costruisce query string da oggetto, filtrando valori undefined:
import { buildQueryString } from "@/lib/api";
const params = { page: 1, limit: 10, search: "laptop" };
const query = buildQueryString(params);
// → "?page=1&limit=10&search=laptop"
// Uso in API client
const response = await apiRequest(
BASE_URL,
`/list${query}`, // "/list?page=1&limit=10&search=laptop"
{ method: "GET" },
schema
);getDefaultErrorMessage()
Messaggi user-friendly per status HTTP comuni:
| Status | Messaggio |
|---|---|
400 | "Richiesta non valida" |
401 | "Credenziali non valide o sessione scaduta" |
403 | "Accesso negato" |
404 | "Risorsa non trovata" |
429 | "Troppi tentativi, riprova più tardi" |
500 | "Errore del server" |
502 | "Gateway non disponibile" |
503 | "Servizio non disponibile" |
| default | "Errore nella richiesta" |
ApiError Class
class ApiError extends Error {
constructor(
message: string, // Messaggio user-friendly
public status: number, // HTTP status code
public context?: string // Contesto errore dal server
)
}Uso nei componenti:
try {
await createEntity(data);
} catch (error) {
if (error instanceof ApiError) {
console.log("Status:", error.status); // 401
console.log("Message:", error.message); // "Credenziali non valide"
console.log("Context:", error.context); // "auth"
}
}Gestione Automatica 401
Quando una chiamata API restituisce 401 e l'utente è autenticato:
- Pulizia store: Esegue logout automatico
- Alert utente: Mostra messaggio di errore
- Redirect: Reindirizza alla pagina login
Questo comportamento è integrato in apiRequest() - non richiede gestione manuale.
Best Practices
✅ DO
- Importa sempre
apiRequesteApiErrorda@/lib/api - Usa
createApiResponseSchema()per schemi consistenti - Definisci
API_BASE_URLcostante per ogni modulo - Valida input con Zod prima di chiamare
apiRequest() - Gestisci
ApiErrorconinstanceof
❌ DON'T
- Non duplicare
apiRequestoApiErrorin altri file - Non modificare headers/credentials in implementazioni custom
- Non fare fetch direttamente senza usare
apiRequest() - Non dimenticare lo schema Zod per validazione
- Non ignorare gli errori
ApiError