src/lib/store-struct-to-openapi.ts
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;