AndrewWalsh/at-your-service

View on GitHub
src/lib/store-struct-to-openapi.ts

Summary

Maintainability
C
7 hrs
Test Coverage
A
98%
import type { ReadonlyDeep } from "type-fest";
import {
  OpenAPIObject,
  OpenApiBuilder,
  PathItemObject,
  OperationObject,
  ResponseObject,
  ContentObject,
  ResponsesObject,
  MediaTypeObject,
  RequestBodyObject,
  ParameterObject,
  HeaderObject,
} from "openapi3-ts";
import { uniq } from "lodash";

import type { StoreStructure } from "../types";
import convert from "./samples-to-json-schema";

/**
 * Extracts path names e.g. /api/{extractsThis}/example
 *  = ["extractsThis"]
 */
const extractPathNames = (str: string): Array<string> => {
  const regex = /({.+?})/gm;
  let m;
  const out: Array<string> = [];
  while ((m = regex.exec(str)) !== null) {
    if (m.index === regex.lastIndex) {
      regex.lastIndex++;
    }
    m.forEach((match) => {
      out.push(match);
    });
  }
  return out;
};

type OpenAPI = {
  getSpec: () => OpenAPIObject;
  getJSON: () => string;
  getYAML: () => string;
};

type StoreStructToOpenApi = (
  store: StoreStructure
) => Promise<ReadonlyDeep<OpenAPI>>;

type Defaults = {
  title: string;
  description: string;
  version: string;
};

const DEFAULTS: ReadonlyDeep<Defaults> = {
  title: "OpenAPI",
  description: "Generated by at-your-service",
  version: "1.0.0",
};

const openAPIValidMethods = new Set([
  "get",
  "post",
  "put",
  "delete",
  "options",
  "patch",
  "head",
  "trace",
]);

/**
 * Takes a StoreStructure and converts it to an OpenAPIObject
 * Returns an interface to get the OpenAPIObject, JSON, or YAML
 */
const storeStructToOpenApi: StoreStructToOpenApi = async (store) => {
  const spec: OpenApiBuilder = OpenApiBuilder.create();
  // The library assumes 3.0.0, but the generated spec is 3.1.0
  spec.rootDoc.openapi = "3.1.0";

  spec.addTitle(DEFAULTS.title);
  spec.addDescription(DEFAULTS.description);
  spec.addVersion(DEFAULTS.version);

  for (const host in store) {
    for (const pathname in store[host]) {
      for (const method in store[host][pathname]) {
        if (!openAPIValidMethods.has(method.toLowerCase())) {
          continue;
        }
        for (let status in store[host][pathname][method]) {
          // Remove the prefix character from the status code
          const {
            requestBodySamples,
            requestHeadersSamples,
            responseBodySamples,
            responseHeadersSamples,
            queryParameterSamples,
          } = store[host][pathname][method][status];
          status = status.slice(1);

          /**
           * REQUEST OBJECT CREATION
           */
          const reqMediaType: MediaTypeObject = {
            schema: convert(requestBodySamples),
          };
          const reqContent: ContentObject = {
            "application/json": reqMediaType,
          };

          const requestBody: RequestBodyObject = {
            content: reqContent,
            description: `Request for ${pathname} ${method} ${status}`,
          };

          /**
           * RESPONSE OBJECT CREATION
           */
          const resMediaType: MediaTypeObject = {
            schema: convert(responseBodySamples),
          };
          const resContent: ContentObject = {
            "application/json": resMediaType,
          };
          const responseHeaders: Record<string, HeaderObject> = {};

          const { properties: propResponseHeaders } = convert(
            responseHeadersSamples
          );
          if (propResponseHeaders) {
            Object.entries(propResponseHeaders).forEach(([name, schema]) => {
              const headerObj: HeaderObject = {
                required: true,
                schema,
              };
              responseHeaders[name] = headerObj;
            });
          }

          // A concrete response definition
          const response: ResponseObject = {
            content: resContent,
            description: `Response for ${pathname} ${method} ${status}`,
            headers: responseHeaders,
          };
          // All the different responses we can get from 200, 400, 204, etc
          const responses: ResponsesObject = {
            [status]: response,
          };

          /**
           * WRAP UP INTO OPERATION, PATH ITEM, AND PUT INTO PATH
           */
          // Some methods have no req body https://swagger.io/docs/specification/describing-request-body/
          const hasRequestBody = !new Set(["get", "delete", "head"]).has(
            method.toLowerCase()
          );
          const pathnames = uniq(extractPathNames(pathname));
          const parameters: ParameterObject[] = pathnames.map((name) => ({
            name,
            in: "path",
            required: true,
            schema: {
              type: "string",
            },
          }));

          const { properties: propRequestHeaders } = convert(
            requestHeadersSamples
          );
          if (propRequestHeaders) {
            Object.entries(propRequestHeaders).forEach(([name, schema]) => {
              parameters.push({
                name,
                in: "header",
                required: true,
                schema,
              });
            });
          }

          const { properties: propQueryParameters } = convert(
            queryParameterSamples
          );
          if (propQueryParameters) {
            Object.entries(propQueryParameters).forEach(([name, schema]) => {
              parameters.push({
                name,
                in: "query",
                required: true,
                schema,
              });
            });
          }

          // The req/res associated with a HTTP [VERB] request
          const operation: OperationObject = {
            summary: `Summary for ${pathname} ${method} ${status}`,
            description: `${method} call to ${pathname} with status ${status}`,
            responses,
          };
          if (parameters.length) {
            operation.parameters = parameters;
          }
          if (hasRequestBody) {
            operation.requestBody = requestBody;
          }
          // The method (e.g. get) and the operation on it
          const pathItem: PathItemObject = {
            [method.toLowerCase()]: operation,
          };
          // Add the resulting object to the spec
          spec.addPath(pathname, pathItem);
        }
      }
    }
  }

  return {
    getSpec: () => spec.getSpec(),
    getJSON: () => JSON.stringify(spec.getSpec(), null, 2),
    getYAML: () => spec.getSpecAsYaml(),
  };
};

export default storeStructToOpenApi;