Saltar al contenido

2. La API: una ruta ​

La plantilla ya expone GET/POST /notas/items y DELETE /notas/items/:id. Añadimos una ruta de resumen para la cabecera de la pantalla.

Las reglas que sigue api/routes.ts:

  • Las rutas se montan bajo /api/v1: se escribe /notas/summary, no /api/v1/notas/summary. Todas empiezan por el nombre del plugin, para no chocar con las del núcleo ni las de otros plugins.
  • Permisos en cada ruta con requirePermission(perm("notas.view")). El perm() es el cast de tables.ts: el tipo Permission del host no conoce los permisos del plugin porque se registran al cargar.
  • Datos validados con zod y parse(Schema, req.body) de @host/http/reply, que responde 400 VALIDATION_ERROR con el detalle.
  • Cambio y auditoría en la misma transacción (audit de @host/lib/audit).
  • El gate del módulo lo pone el marco. Con el módulo apagado, todas las rutas del ZModule responden 404 MODULE_DISABLED sin que tengas que escribir nada.
  • app.deps.db es el Kysely del host, tipado sin las tablas del plugin. Se castea una vez por fichero a HDb, que sí las conoce.

Sustituye api/routes.ts por este (la plantilla más la ruta GET /notas/summary):

ts
import type { FastifyInstance } from "fastify";
import { requirePermission } from "@host/http/auth";
import { notFound } from "@host/http/errors";
import { parse } from "@host/http/reply";
import { audit } from "@host/lib/audit";
import { newId } from "@host/lib/ids";
import { CreateItem } from "./schemas";
import { hostConn, perm, type HDb } from "./tables";

/** Las rutas se montan bajo /api/v1: aquí se escribe `/notas/items`, no `/api/v1/notas/items`. */
export async function notasRoutes(app: FastifyInstance): Promise<void> {
  // El host entrega su Kysely tipado sin las tablas del plugin: este cast, una vez por fichero, las añade.
  const db = app.deps.db as unknown as HDb;
  const view = { preHandler: requirePermission(perm("notas.view")) };
  const edit = { preHandler: requirePermission(perm("notas.edit")) };

  app.get("/notas/items", view, async () => ({
    items: await db.selectFrom("notasItems").selectAll().orderBy("createdAt", "desc").execute(),
  }));

  // Resumen para la cabecera de la pantalla
  app.get("/notas/summary", view, async () => {
    const row = await db.selectFrom("notasItems").select(({ fn }) => fn.countAll<string>().as("total")).executeTakeFirstOrThrow();
    return { total: Number(row.total) };
  });

  app.post("/notas/items", edit, async (req, reply) => {
    const body = parse(CreateItem, req.body);
    // Cambio + auditoría en la misma transacción
    const item = await db.transaction().execute(async (trx) => {
      const row = await trx
        .insertInto("notasItems")
        .values({ id: newId("itm"), name: body.name, note: body.note ?? null })
        .returningAll()
        .executeTakeFirstOrThrow();
      await audit(hostConn(trx), req, { action: "notas.item_created", entity: "notasItems", entityId: row.id, after: { name: row.name } });
      return row;
    });
    return reply.code(201).send(item);
  });

  app.delete<{ Params: { id: string } }>("/notas/items/:id", edit, async (req, reply) => {
    const removed = await db.transaction().execute(async (trx) => {
      const r = await trx.deleteFrom("notasItems").where("id", "=", req.params.id).executeTakeFirst();
      if (!r.numDeletedRows) return false;
      await audit(hostConn(trx), req, { action: "notas.item_deleted", entity: "notasItems", entityId: req.params.id });
      return true;
    });
    if (!removed) throw notFound("Elemento");
    return reply.code(204).send();
  });
}

fn.countAll<string>() es string porque PostgreSQL devuelve bigint como texto; se convierte con Number.

Comprueba tipos y pruebas:

bash
npm run typecheck
npm test

Qué ocurre al compilar. import { requirePermission } from "@host/http/auth" no incluye el código del host en tu paquete: hury-plugin build lo convierte en const __m = globalThis.__HURY_API__["http/auth"]; export const requirePermission = __m.requirePermission. Por eso solo puedes importar lo que enumeran los módulos del host.

Siguiente: la migración SQL →

Contrato con el host: apiVersion 2. Sitio estático: no recoge datos ni usa servicios de terceros.