Source: description.js

/**
 * Machine-readable service description (specification ยง9).
 *
 * The same route registry is rendered twice: once as the native Links Notation
 * description served at `/.well-known/lino-api`, and once as an OpenAPI 3.1
 * document served at `/.well-known/openapi.json` so that existing tooling keeps
 * working against a LINO API.
 */

import { SUPPORTED_MEDIA_TYPES } from "./media-type.js";

/** Version of the description document this package emits. */
export const LINO_API_DESCRIPTION_VERSION = "1.0";

/**
 * Render the `info` member shared by both description documents.
 *
 * @param {object} info - `title`, `version` and optional `description` of the service
 * @returns {object} Info object, carrying `description` only when one was given
 */
export function infoObject(info) {
  const document = { title: info.title, version: info.version };
  if (info.description) {
    document.description = info.description;
  }
  return document;
}

/**
 * Build the native service description.
 *
 * @param {object} info - `title`, `version` and optional `description` of the service
 * @param {Array<{path: string, methods: string[], summary: string}>} routes - Route descriptions
 * @param {string[]} [mediaTypes] - Representations the service can produce
 * @returns {object} Description ready to be encoded as Links Notation
 */
export function serviceDescription(
  info,
  routes,
  mediaTypes = SUPPORTED_MEDIA_TYPES,
) {
  return {
    lino_api: LINO_API_DESCRIPTION_VERSION,
    info: infoObject(info),
    media_types: [...mediaTypes],
    routes,
  };
}

/**
 * Convert an Express-style path to the OpenAPI template syntax.
 *
 * @param {string} path - Path pattern with `:param` segments
 * @returns {string} Path template with `{param}` segments
 */
export function toOpenApiPath(path) {
  return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
}

/**
 * Extract the path parameters of an Express-style path.
 *
 * @param {string} path - Path pattern
 * @returns {string[]} Parameter names
 */
export function pathParameters(path) {
  return [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]);
}

const LINO_SCHEMA = {
  description:
    "Links Notation document, see https://github.com/link-foundation/links-notation",
  type: "string",
};

/**
 * Build an OpenAPI 3.1 document describing the service.
 *
 * Every request and response body is declared for all negotiable media types, so
 * that a generated client knows it may ask for `text/lino`.
 *
 * @param {object} info - `title`, `version` and optional `description` of the service
 * @param {Array<{path: string, methods: string[], summary: string}>} routes - Route descriptions
 * @param {string[]} [mediaTypes] - Representations the service can produce
 * @returns {object} OpenAPI 3.1 document
 */
export function openApiDocument(
  info,
  routes,
  mediaTypes = SUPPORTED_MEDIA_TYPES,
) {
  const content = Object.fromEntries(
    mediaTypes.map((mediaType) => [mediaType, { schema: LINO_SCHEMA }]),
  );

  const paths = {};
  for (const route of routes) {
    const template = toOpenApiPath(route.path);
    const parameters = pathParameters(route.path).map((name) => ({
      name,
      in: "path",
      required: true,
      schema: { type: "string" },
    }));

    paths[template] = {};
    for (const method of route.methods) {
      const operation = {
        summary: route.summary ?? `${method} ${route.path}`,
        responses: {
          200: { description: "Success", content },
          default: {
            description: "RFC 9457 problem details in Links Notation",
            content,
          },
        },
      };
      if (parameters.length > 0) {
        operation.parameters = parameters;
      }
      if (["POST", "PUT", "PATCH"].includes(method)) {
        operation.requestBody = { required: true, content };
      }
      paths[template][method.toLowerCase()] = operation;
    }
  }

  return {
    openapi: "3.1.0",
    info: infoObject(info),
    paths,
  };
}