JSON Model-to-Model Projection (JM2MP) Documentation

JSON Model-to-Model Projection (JM2MP) Documentation

source

api/JM2MP-ExpressJS-v4-middleware.js

JM2MP-Express-v4-Middleware.js

Middleware de Express.js v4 que expone el sistema de proyecciones JSON (del chat "Álgebra lineal y programación en JSON") como servicio HTTP.

CONTRATO HTTP

El cliente envía una petición cuyo cuerpo (Content-Type: application/json) es el documento origen que se va a proyectar. El nombre del módulo de proyección a aplicar se toma de un parámetro de ruta (:projection) o, en su defecto, de la cabecera "X-Projection" o del campo projection de la query-string. El resultado se devuelve como JSON al cliente.

CARGA DE MÓDULOS DE PROYECCIÓN

Los módulos residen en un directorio constante en disco. Se usa createFileLoader({ baseDir }) de la librería, lo que garantiza:

  • Cada módulo es un fichero JSON dentro de PROJECTIONS_DIR.
  • Los $depends-on se resuelven relativos a ese mismo directorio.
  • El documento origen sigue siendo de sólo lectura (transparencia referencial garantizada por el evaluador).

DIFERENCIAS RELEVANTES CON v5

Express 4 NO captura rechazos de promesas devueltas por handlers async. Por eso el middleware se envuelve en un pequeño helper asyncHandler que hace Promise.resolve(fn(...)).catch(next). Sin esto, cualquier error asíncrono (proyección inexistente, JSON mal formado, error de evaluación) dejaría la petición colgada hasta el timeout.

/**
 * @file JM2MP-Express-v4-Middleware.js
 *
 * Middleware de Express.js v4 que expone el sistema de proyecciones JSON
 * (del chat "Álgebra lineal y programación en JSON") como servicio HTTP.
 *
 * CONTRATO HTTP
 * -------------
 * El cliente envía una petición cuyo cuerpo (Content-Type: application/json)
 * es el **documento origen** que se va a proyectar. El nombre del módulo de
 * proyección a aplicar se toma de un parámetro de ruta (:projection) o, en
 * su defecto, de la cabecera "X-Projection" o del campo `projection` de la
 * query-string. El resultado se devuelve como JSON al cliente.
 *
 * CARGA DE MÓDULOS DE PROYECCIÓN
 * ------------------------------
 * Los módulos residen en un **directorio constante** en disco. Se usa
 * `createFileLoader({ baseDir })` de la librería, lo que garantiza:
 *   - Cada módulo es un fichero JSON dentro de PROJECTIONS_DIR.
 *   - Los `$depends-on` se resuelven relativos a ese mismo directorio.
 *   - El documento origen sigue siendo de sólo lectura (transparencia
 *     referencial garantizada por el evaluador).
 *
 * DIFERENCIAS RELEVANTES CON v5
 * -----------------------------
 * Express 4 NO captura rechazos de promesas devueltas por handlers `async`.
 * Por eso el middleware se envuelve en un pequeño helper `asyncHandler` que
 * hace `Promise.resolve(fn(...)).catch(next)`. Sin esto, cualquier error
 * asíncrono (proyección inexistente, JSON mal formado, error de evaluación)
 * dejaría la petición colgada hasta el timeout.
**/

/**
 * @module jm2mp/api/express/v4/middleware
**/

// ---------------------------------------------------------------------

import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import {
  project,
  createFileLoader,
  createDefaultRegistry,
  ProjectionError,
  ResolutionError,
  ValidationError,
  EvaluationError,
  AdapterError,
} from "../index.js";

// ---------------------------------------------------------------------

/**
 * @constant {string} __dirname
 * @description
 * Directorio donde viven los ficheros de proyección.
 * Resuelto una sola vez al cargar el módulo. Se puede sobreescribir por
 * variable de entorno para despliegues, pero permanece constante durante la
 * vida del proceso.
**/
const __dirname = path.dirname(fileURLToPath(import.meta.url));

// ---------------------------------------------------------------------

/**
 * @constant {string} PROJECTIONS_DIR
 * @description
 * Ruta absoluta al directorio de proyecciones.
**/
export const PROJECTIONS_DIR = path.resolve(
  process.env.PROJECTIONS_DIR ?? path.join(__dirname, "projections"),
);

// ---------------------------------------------------------------------

/**
 * @description
 * Adaptador async como middleware para Express 4.
 * Express 4 sólo entiende errores propagados vía `next(err)`. Este envoltorio
 * convierte cualquier rechazo de promesa en una llamada a `next`, delegando
 * el manejo al pipeline de error de Express.
 * @param {Function} fn Async middleware daisychaining.
 * @returns {*} Resolves 'Promise<fn>' and catches any raised exception.
**/
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// ---------------------------------------------------------------------

/**
 * @description
 * It maps JM2MP domain exceptions toward standard HTTP error codes.
 * @param {*} err The JM2MP domain exception to be returned.
 * @returns {integer} The equivalent standard HTTP error code.
 */
const statusForError = (err) => {
       if (err instanceof ResolutionError) { return 404; }  // módulo no encontrado / JSON inválido
  else if (err instanceof ValidationError) { return 400; }  // módulo mal formado
  else if (err instanceof AdapterError)    { return 400; }  // sintaxis no soportada
  else if (err instanceof EvaluationError) { return 422; }  // fallo semántico en runtime
  else if (err instanceof ProjectionError) { return 500; }  // cajón de sastre del dominio
  else                                     { return 500; }  // Cualquier otro error detectado.
};

// ---------------------------------------------------------------------

/**
 * @description
 * Crea el middleware de proyección.
 * @param {object} [opts]
 * @param {string} [opts.baseDir=PROJECTIONS_DIR] - Directorio de módulos.
 * @param {string} [opts.paramName="projection"]  - Nombre del route param.
 * @param {AdapterRegistry} [opts.registry] -
 *   Registro de adaptadores. Si este parámetro se omite, por defecto se crea uno sólo con el nativo.
 * @returns {Promise<express.RequestHandler>}
 */
export async function JM2MP_CreateMiddleware(opts = {}) {
  const baseDir   = opts.baseDir   ?? PROJECTIONS_DIR;
  const paramName = opts.paramName ?? "projection";
  const registry  = opts.registry  ?? await createDefaultRegistry();

  // El loader se crea UNA sola vez y se reutiliza en cada petición.
  // Los ficheros se releen del disco en cada llamada — el resolver interno
  // de la librería ya cachea dentro de una misma evaluación.
  const loader = await createFileLoader({
    baseDir,
    encoding: "utf8",
  });

  return asyncHandler(async (req, res) => {
    // 1. Determinar qué proyección aplicar.
    const projectionName =
      req.params[paramName] ??
      req.get("X-JM2MP-Projection") ??
      (typeof req.query.projection === "string" ? req.query.projection : null);

    if (!projectionName || typeof projectionName !== "string") {
      const err = new Error(
        "No se ha especificado ninguna proyección (usa el path param, " +
        "la cabecera X-JM2MP-Projection o el query `projection`).",
      );
      err.status = 400;
      throw err;
    }

    // 2. El body ya viene parseado por `express.json()` (montado aguas arriba).
    //    Aceptamos cualquier valor JSON como documento (objeto, array, escalar
    //    o null). Si el cliente no envió body, Express 4 deja `req.body`
    //    como `{}` por defecto, cosa que respetamos.
    const document = req.body;

    // 3. Ejecutar el pipeline resolve → validate → evaluate.
    let result;
    try {
      result = await project({
        rootName: projectionName,
        loader,
        document,
        registry,
      });
    } catch (cause) {
      // Enriquecemos el error con status HTTP para el error-handler.
      cause.status = statusForError(cause);
      throw cause;
    }

    // 4. Devolver el resultado como JSON.
    //    `res.json` serializa cualquier valor JSON válido, incluyendo null,
    //    arrays y escalares — coherente con la definición de "resultado" en
    //    el modelo de proyecciones.
    res.status(200).json(result);
  });
}

// ---------------------------------------------------------------------

/**
 * Error handler compatible con Express 4. Se monta tras el middleware
 * principal para transformar los errores del dominio en respuestas JSON
 * estables. Cuatro parámetros — Express usa la aridad para identificarlo.
 *
 * @type {express.ErrorRequestHandler}
 */
// eslint-disable-next-line no-unused-vars
export function projectionErrorHandler(err, req, res, next) {
  const status = err.status ?? statusForError(err);
  res.status(status).json({
    error: err.name ?? "Error",
    message: err.message,
    // La cadena de `cause` se serializa como string para no filtrar objetos
    // internos, pero preserva la traza lógica.
    cause: err.cause ? String(err.cause.message ?? err.cause) : undefined,
  });
}

// ---------------------------------------------------------------------

/**
 * @description
 * Ejemplo de cómo configurar una aplicación en Express v4
 * con el middleware JM2MP sobre una ruta concreta. *
 * @param {object} [opts] -
 * *param {Parameters<typeof JM2MP_CreateMiddleware>[0]} [opts]
 * @returns {express.Express} The sample Express application.
 */
export async function createApp(opts = {}) {
  const app = express();
  app.use(express.json({ limit: "25mb" }));

  const middleware = await JM2MP_CreateMiddleware(opts);
  app.post("/project/:projection", middleware);
  app.post("/project", middleware); // proyección vía header o query

  app.use(projectionErrorHandler);
  return app;
}

// ---------------------------------------------------------------------
// End of file: ${JM2MP.JS}/src/api/JM2MP-Exress-v4-middleware.js */