/**
* The LINO REST API application.
*
* Wraps Express with the behaviour the specification requires: content
* negotiation, Links Notation bodies, problem details, automatic `HEAD`,
* automatic `OPTIONS`, `405 Method Not Allowed` with `Allow`, and a
* machine-readable service description.
*/
import express from "express";
import { encode, decode } from "./codec.js";
import { linoCors } from "./cors.js";
import { openApiDocument, serviceDescription } from "./description.js";
import { JSON_CONTENT_TYPE, LINO_CONTENT_TYPE } from "./media-type.js";
import { linoErrorHandler, linoMiddleware } from "./middleware.js";
import { LinoHttpError } from "./problem.js";
import { parseCollectionQuery } from "./query.js";
import { registerResource } from "./resource.js";
import { LinoResult } from "./response.js";
import { RouteTable } from "./router.js";
/** Methods a handler can be registered for. */
export const HTTP_METHODS = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
];
/** Where the native service description lives (specification §9). */
export const DESCRIPTION_PATH = "/.well-known/lino-api";
/** Where the generated OpenAPI 3.1 document lives (specification §9). */
export const OPENAPI_PATH = "/.well-known/openapi.json";
/**
* An Express application that speaks Links Notation.
*/
export class LinoApp {
/**
* @param {object} [options] - Application options
* @param {string} [options.title] - Service title used in the description
* @param {string} [options.version] - Service version used in the description
* @param {string} [options.description] - Prose description of the service
* @param {boolean|object} [options.cors] - Enable CORS, optionally with a policy
* @param {boolean} [options.describe] - Serve the service description (default true)
* @param {string[]} [options.supported] - Representations the server may produce
* @param {number} [options.maxBodyBytes] - Largest accepted request body
* @param {boolean} [options.exposeStack] - Attach stack traces to `5xx` problems
* @param {number} [options.defaultLimit] - Default collection page size
* @param {number} [options.maxLimit] - Largest collection page size
*/
constructor(options = {}) {
this.options = options;
this.info = {
title: options.title ?? "LINO REST API",
version: options.version ?? "1.0.0",
};
if (options.description) {
this.info.description = options.description;
}
this.routes = new RouteTable();
this.app = express();
this.app.disable("x-powered-by");
this.app.disable("etag");
if (options.cors) {
this.app.use(linoCors(options.cors === true ? {} : options.cors));
}
this.app.use(linoMiddleware(options));
// Express answers OPTIONS itself as soon as a router owns the path, with a
// 200 and an Allow header that omits OPTIONS. Intercept it first so that
// specification §4.1 wins.
this.app.use(this.#options());
// User routes live in their own router, which Express dispatches at request
// time. Routes registered later therefore still run before the fallbacks
// below, so the application never needs an explicit "finalize" step.
this.router = express.Router();
this.app.use(this.router);
if (options.describe !== false) {
this.#registerDescription();
}
this.app.use(this.#fallback());
this.app.use(linoErrorHandler({ exposeStack: options.exposeStack }));
}
/**
* Register the service description routes of specification §9.
*
* @returns {void}
*/
#registerDescription() {
this.get(DESCRIPTION_PATH, () => this.describe(), {
summary: "Service description",
});
this.routes.register("GET", OPENAPI_PATH, {
summary: "OpenAPI 3.1 description",
});
this.router.get(OPENAPI_PATH, (req, res) => {
res.status(200);
res.set("Content-Type", `${JSON_CONTENT_TYPE}; charset=utf-8`);
res.send(`${JSON.stringify(this.openapi(), null, 2)}\n`);
});
}
/**
* Middleware answering `OPTIONS` with `Allow` (specification §4.1).
*
* A path with an explicitly registered `OPTIONS` handler is left to that
* handler.
*
* @returns {Function} Express middleware
*/
#options() {
return (req, res, next) => {
if (req.method !== "OPTIONS") {
next();
return;
}
const entry = this.routes.find(req.path);
if (!entry || entry.methods.has("OPTIONS")) {
next();
return;
}
res.set("Allow", (this.routes.allowedMethods(req.path) ?? []).join(", "));
res.status(204).end();
};
}
/**
* The terminal middleware answering `405` and `404`.
*
* @returns {Function} Express middleware
*/
#fallback() {
return (req, res, next) => {
const allowed = this.routes.allowedMethods(req.path);
if (!allowed) {
next(new LinoHttpError(404, `No resource at ${req.path}`));
return;
}
const allow = allowed.join(", ");
next(
new LinoHttpError(405, `${req.method} is not allowed on ${req.path}`, {
headers: { Allow: allow },
}),
);
};
}
/**
* Adapt a handler that returns a value into an Express handler.
*
* @param {Function} handler - `(req, res) => value | LinoResult | Promise<…>`
* @returns {Function} Express handler
*/
#wrap(handler) {
return async (req, res, next) => {
try {
req.collectionQuery = (queryOptions = {}) =>
parseCollectionQuery(req.query, { ...this.options, ...queryOptions });
const result = await handler(req, res);
if (res.headersSent) {
return;
}
if (result instanceof LinoResult) {
res.lino(result.value, result.status, {
headers: result.headers,
...result.options,
});
return;
}
if (result === undefined) {
res.lino(undefined, 204);
return;
}
res.lino(result);
} catch (error) {
next(error);
}
};
}
/**
* Register a handler for a method and path.
*
* @param {string} method - HTTP method
* @param {string} path - Path pattern
* @param {Function} handler - Route handler
* @param {object} [meta] - Description metadata, for example `summary`
* @returns {LinoApp} This application, for chaining
*/
route(method, path, handler, meta = {}) {
const normalized = method.toUpperCase();
if (!HTTP_METHODS.includes(normalized)) {
throw new TypeError(`Unsupported HTTP method: ${method}`);
}
this.routes.register(normalized, path, meta);
this.router[normalized.toLowerCase()](path, this.#wrap(handler));
return this;
}
/**
* Register a `GET` handler.
*
* @param {string} path - Path pattern
* @param {Function} handler - Route handler
* @param {object} [meta] - Description metadata
* @returns {LinoApp} This application, for chaining
*/
get(path, handler, meta) {
return this.route("GET", path, handler, meta);
}
/**
* Register a `POST` handler.
*
* @param {string} path - Path pattern
* @param {Function} handler - Route handler
* @param {object} [meta] - Description metadata
* @returns {LinoApp} This application, for chaining
*/
post(path, handler, meta) {
return this.route("POST", path, handler, meta);
}
/**
* Register a `PUT` handler.
*
* @param {string} path - Path pattern
* @param {Function} handler - Route handler
* @param {object} [meta] - Description metadata
* @returns {LinoApp} This application, for chaining
*/
put(path, handler, meta) {
return this.route("PUT", path, handler, meta);
}
/**
* Register a `PATCH` handler.
*
* @param {string} path - Path pattern
* @param {Function} handler - Route handler
* @param {object} [meta] - Description metadata
* @returns {LinoApp} This application, for chaining
*/
patch(path, handler, meta) {
return this.route("PATCH", path, handler, meta);
}
/**
* Register a `DELETE` handler.
*
* @param {string} path - Path pattern
* @param {Function} handler - Route handler
* @param {object} [meta] - Description metadata
* @returns {LinoApp} This application, for chaining
*/
delete(path, handler, meta) {
return this.route("DELETE", path, handler, meta);
}
/**
* Register a CRUD resource, see {@link registerResource}.
*
* @param {string} path - Collection path
* @param {object} store - Resource store
* @param {object} [options] - Resource options
* @returns {LinoApp} This application, for chaining
*/
resource(path, store, options = {}) {
return registerResource(this, path, store, {
defaultLimit: this.options.defaultLimit,
maxLimit: this.options.maxLimit,
...options,
});
}
/**
* Add Express middleware to the user router.
*
* @param {...any} args - Middleware arguments
* @returns {LinoApp} This application, for chaining
*/
use(...args) {
this.router.use(...args);
return this;
}
/**
* The native service description of specification §9.
*
* @returns {object} Description document
*/
describe() {
return serviceDescription(
this.info,
this.routes.describe(),
this.options.supported,
);
}
/**
* The OpenAPI 3.1 rendering of the service description.
*
* @returns {object} OpenAPI document
*/
openapi() {
return openApiDocument(
this.info,
this.routes.describe(),
this.options.supported,
);
}
/**
* The underlying Express application, usable as a request listener.
*
* @returns {object} Express application
*/
getExpressApp() {
return this.app;
}
/**
* Start an HTTP server.
*
* @param {number} port - Port to listen on
* @param {Function} [callback] - Called once the server is listening
* @returns {object} Node HTTP server
*/
listen(port, callback) {
return this.app.listen(port, callback);
}
}
/**
* Create a new application.
*
* @param {object} [options] - Application options, see {@link LinoApp}
* @returns {LinoApp} New application
*/
export function createLinoApp(options = {}) {
return new LinoApp(options);
}
export { encode, decode, LINO_CONTENT_TYPE };