Source: middleware.js

/**
 * Express middleware implementing the request and response halves of the
 * specification: body decoding (§2.1), content negotiation (§2.1), entity tags
 * (§7) and problem details (§5).
 */

import { decodeFrom, encodeFor } from "./codec.js";
import {
  LINO_CONTENT_TYPE,
  SUPPORTED_MEDIA_TYPES,
  isDecodableMediaType,
  negotiateMediaType,
  parseContentType,
  problemMediaType,
  withCharset,
} from "./media-type.js";
import { LinoHttpError, toHttpError } from "./problem.js";
import { computeETag, evaluatePreconditions } from "./etag.js";
import { appendVary } from "./headers.js";

export {
  LINO_CONTENT_TYPE,
  LINO_LINE_CONTENT_TYPE,
  LINO_COMPACT_CONTENT_TYPE,
  JSON_CONTENT_TYPE,
  LINO_PROBLEM_CONTENT_TYPE,
  JSON_PROBLEM_CONTENT_TYPE,
} from "./media-type.js";

/** Largest request body accepted by default, in bytes. */
export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;

/**
 * Read a request body as a UTF-8 string, refusing oversized payloads.
 *
 * @param {object} req - Express request
 * @param {number} maxBytes - Largest accepted body
 * @returns {Promise<string>} Raw body
 */
function readBody(req, maxBytes) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let size = 0;
    let settled = false;

    const finish = (callback, value) => {
      if (settled) {
        return;
      }
      settled = true;
      callback(value);
    };

    req.on("data", (chunk) => {
      size += chunk.length;
      if (size > maxBytes) {
        // Stop reading but leave the socket alive, otherwise the client never
        // sees the 413 it is being told about.
        req.unpipe?.();
        req.pause();
        finish(
          reject,
          new LinoHttpError(413, `Request body exceeds ${maxBytes} bytes`, {
            headers: { Connection: "close" },
          }),
        );
        return;
      }
      chunks.push(chunk);
    });
    req.on("end", () =>
      finish(resolve, Buffer.concat(chunks).toString("utf-8")),
    );
    req.on("error", (error) => finish(reject, error));
  });
}

/**
 * Body parser middleware for every media type of the specification.
 *
 * Decoded bodies are exposed as `req.body`; the media type they arrived in is
 * exposed as `req.linoRequestMediaType`.
 *
 * @param {object} [options] - Limits
 * @param {number} [options.maxBodyBytes] - Largest accepted body
 * @returns {Function} Express middleware
 */
export function linoBodyParser(options = {}) {
  const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;

  return async (req, res, next) => {
    if (req.method === "GET" || req.method === "HEAD") {
      req.body = undefined;
      next();
      return;
    }

    const mediaType = parseContentType(req.headers["content-type"]);
    if (!mediaType) {
      req.body = undefined;
      next();
      return;
    }

    if (!isDecodableMediaType(mediaType)) {
      next(
        new LinoHttpError(415, `Unsupported request media type: ${mediaType}`, {
          extensions: { supported: SUPPORTED_MEDIA_TYPES },
        }),
      );
      return;
    }

    try {
      const raw = await readBody(req, maxBytes);
      req.linoRequestMediaType = mediaType;
      req.body = raw.trim() ? decodeFrom(raw, mediaType) : undefined;
      next();
    } catch (error) {
      if (error instanceof LinoHttpError) {
        next(error);
        return;
      }
      next(
        new LinoHttpError(
          400,
          `Malformed ${mediaType} request body: ${error.message}`,
        ),
      );
    }
  };
}

/**
 * Content negotiation middleware.
 *
 * Stores the selected representation on `req.linoMediaType` and installs the
 * `res.lino(...)` and `res.problem(...)` helpers.
 *
 * @param {object} [options] - Negotiation policy
 * @param {string[]} [options.supported] - Representations the server can produce
 * @returns {Function} Express middleware
 */
export function linoNegotiation(options = {}) {
  const supported = options.supported ?? SUPPORTED_MEDIA_TYPES;

  return (req, res, next) => {
    const selected = negotiateMediaType(req.headers.accept, supported);
    appendVary(res, "Accept");

    if (!selected) {
      next(
        new LinoHttpError(
          406,
          `No acceptable representation for Accept: ${req.headers.accept}`,
          { extensions: { supported } },
        ),
      );
      return;
    }

    req.linoMediaType = selected;

    /**
     * Send a value as the negotiated representation.
     *
     * @param {*} value - Value to encode
     * @param {number} [statusCode] - HTTP status code
     * @param {object} [sendOptions] - Response options
     * @param {object} [sendOptions.headers] - Extra response headers
     * @param {boolean} [sendOptions.etag] - Emit an `ETag` (default true)
     * @param {boolean} [sendOptions.preconditions] - Evaluate conditional headers (default: safe methods only)
     * @param {boolean} [sendOptions.requirePrecondition] - Demand `If-Match` on unsafe methods
     * @returns {object} The Express response
     */
    res.lino = (value, statusCode = 200, sendOptions = {}) => {
      for (const [name, headerValue] of Object.entries(
        sendOptions.headers ?? {},
      )) {
        res.set(name, headerValue);
      }

      if (statusCode === 204 || value === undefined) {
        res.status(statusCode === 200 ? 204 : statusCode);
        return res.end();
      }

      const body = encodeFor(value, selected);

      if (sendOptions.etag !== false) {
        const etag = computeETag(body);
        res.set("ETag", etag);

        // Preconditions on unsafe methods have to be evaluated against the
        // *current* representation before the change is applied, which only the
        // route handler can do; here the body is already the new representation.
        const safe = req.method === "GET" || req.method === "HEAD";
        if (sendOptions.preconditions ?? safe) {
          const { notModified } = evaluatePreconditions(
            req.headers,
            req.method,
            etag,
            { requirePrecondition: sendOptions.requirePrecondition },
          );
          if (notModified) {
            res.status(304);
            return res.end();
          }
        }
      }

      res.status(statusCode);
      res.set("Content-Type", withCharset(selected));
      return res.send(body);
    };

    /**
     * Send problem details for an error.
     *
     * @param {*} error - Thrown value or {@link LinoHttpError}
     * @returns {object} The Express response
     */
    res.problem = (error) => sendProblem(req, res, error);

    next();
  };
}

/**
 * Write problem details for an error onto a response.
 *
 * @param {object} req - Express request
 * @param {object} res - Express response
 * @param {*} error - Thrown value
 * @returns {object} The Express response
 */
export function sendProblem(req, res, error) {
  const httpError = toHttpError(error);
  const mediaType = req.linoMediaType ?? LINO_CONTENT_TYPE;
  const problem = httpError.toProblem(req.originalUrl ?? req.url);

  for (const [name, value] of Object.entries(httpError.headers ?? {})) {
    res.set(name, value);
  }

  res.status(httpError.status);
  appendVary(res, "Accept");
  res.set("Content-Type", withCharset(problemMediaType(mediaType)));

  if (req.method === "HEAD") {
    return res.end();
  }
  return res.send(encodeFor(problem, mediaType));
}

/**
 * Terminal error handler turning any thrown value into problem details.
 *
 * @param {object} [options] - Behaviour switches
 * @param {boolean} [options.exposeStack] - Attach the stack trace to `5xx` problems
 * @returns {Function} Express error middleware
 */
export function linoErrorHandler(options = {}) {
  return (error, req, res, next) => {
    if (res.headersSent) {
      next(error);
      return;
    }
    const httpError = toHttpError(error);
    if (options.exposeStack && httpError.status >= 500 && error?.stack) {
      httpError.extensions = { ...httpError.extensions, stack: error.stack };
    }
    sendProblem(req, res, httpError);
  };
}

/**
 * The full request pipeline: negotiation followed by body decoding.
 *
 * @param {object} [options] - Options forwarded to the individual middleware
 * @returns {Function[]} Express middleware chain
 */
export function linoMiddleware(options = {}) {
  const negotiation = linoNegotiation(options);
  const bodyParser = linoBodyParser(options);

  return (req, res, next) => {
    negotiation(req, res, (negotiationError) => {
      if (negotiationError) {
        next(negotiationError);
        return;
      }
      bodyParser(req, res, next);
    });
  };
}

/**
 * Send a value as Links Notation without the negotiation middleware.
 *
 * @param {object} res - Express response
 * @param {*} value - Value to encode
 * @param {number} [statusCode] - HTTP status code
 * @returns {object} The Express response
 */
export function linoResponse(res, value, statusCode = 200) {
  res.status(statusCode);
  res.set("Content-Type", withCharset(LINO_CONTENT_TYPE));
  return res.send(encodeFor(value, LINO_CONTENT_TYPE));
}