Skip to content

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:

  1. Schema Zod - Validazione e tipi TypeScript
  2. API Client - Funzioni per chiamate HTTP
  3. Query Keys - Chiavi per cache TanStack Query
  4. Query Helpers - Funzioni per invalidazione cache
  5. 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.ts

Nota: La directory intermedia (es. admin/) riflette la struttura delle API backend. Per endpoint erp/admin/brands → directory admin/brands/

Step 1: Schema Zod

File: app/schemas/admin/{modulo}/{modulo}.schema.ts

typescript
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

typescript
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

typescript
/**
 * 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 specifico

Step 4: Query Helpers

File: app/hooks/api/admin/{modulo}/{modulo}.query-helpers.ts

typescript
/**
 * 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

typescript
/**
 * 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

typescript
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


Appendice: API Utilities

Questa sezione descrive in dettaglio le utility di app/lib/api.ts usate in tutto il progetto.

Signature apiRequest

typescript
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>
ParametroTipoDescrizioneEsempio
baseUrlstringURL base dell'API"https://api.local.daisy/erp/auth"
endpointstringEndpoint relativo"/login" o "/products/123"
optionsRequestInitOpzioni fetch standard{ method: "POST", body: "..." }
schemaZodType<T>Schema Zod per validare rispostaLoginResponseSchema

Headers Automatici

Ogni richiesta include automaticamente:

typescript
headers: {
    "Content-Type": "application/json",
    "Daisy-Erp": "frontend",
    ...options.headers,  // Puoi aggiungere altri header
}

credentials: "include"  // Cookies automatici

buildQueryString()

Costruisce query string da oggetto, filtrando valori undefined:

typescript
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:

StatusMessaggio
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

typescript
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:

typescript
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:

  1. Pulizia store: Esegue logout automatico
  2. Alert utente: Mostra messaggio di errore
  3. Redirect: Reindirizza alla pagina login

Questo comportamento è integrato in apiRequest() - non richiede gestione manuale.

Best Practices

✅ DO

  • Importa sempre apiRequest e ApiError da @/lib/api
  • Usa createApiResponseSchema() per schemi consistenti
  • Definisci API_BASE_URL costante per ogni modulo
  • Valida input con Zod prima di chiamare apiRequest()
  • Gestisci ApiError con instanceof

❌ DON'T

  • Non duplicare apiRequest o ApiError in 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

Documentazione Elerama Frontend