apibake
Version:
Convert OpenAPI spec to PDF.
494 lines (417 loc) • 14.5 kB
text/typescript
import { errorLog, log } from './utils/logger';
import { PdfWriter } from './pdf/pdf-writer';
import { capitalizeFirst, sanitizeDescription } from './utils/string-utils';
export type ApiSpec = Record<string, any>;
type JsonNode = {
key: string,
value: any
}
export class SchemaRef {
constructor(
public text: string,
public schemaName: string,
public anchor?: string,
public isArray = false
) {}
static undefined(): SchemaRef {
return new SchemaRef('undefined', 'undefined');
}
isUndefined(): boolean {
return this.text == 'undefined';
}
toString(): string {
return JSON.stringify(this);
}
}
export class DataField {
description?: string;
constructor(
public name: string,
public type?: SchemaRef,
description?: string,
public required?: boolean,
) {
if (description) {
this.description = sanitizeDescription(description);
}
}
}
const allAnyOne: Record<string, { name: string; connectWord: string }> = {
'allOf': { name: 'All of', connectWord: 'and' },
'anyOf': { name: 'Any of', connectWord: 'or' },
'oneOf': { name: 'One of', connectWord: 'or' },
};
function getFirstOf(spec: ApiSpec, children: string[]): JsonNode | undefined {
for (const child of children) {
if (spec[child]) {
return { key: child, value: spec[child] };
}
}
return undefined;
}
export class OpenApiParser {
private firstHeaderLevel = 0;
private spec: ApiSpec = {}; // open api spec being processed
private sectionName: string = '';
private schemas: ApiSpec = {};
constructor(
private doc: PdfWriter,
private mergeSchemasInOneSection = false
) {}
parse(apiSpec: ApiSpec, sectionName: string) {
this.spec = apiSpec;
const openapiVer = this.spec['openapi'] ?? this.spec['swagger'];;
if (openapiVer == null) {
throw Error('Invalid OpenAPI specification.');
}
if (openapiVer.startsWith('1') || openapiVer.startsWith('2')) {
throw Error(`Not supported OpenAPI version: ${openapiVer}, supported: 3.0.0+.`);
}
this.sectionName = this.spec['info']['title'] ?? sectionName;
this.doc.newSection(this.sectionName);
this.doc.header(this.firstHeaderLevel, this.sectionName);
this.parseInfo(this.spec);
const paths = this.spec['paths'] as Record<string, any>;
if (paths && Object.entries(paths).length > 0) {
log('Endpoints:');
Object.entries(paths).forEach(([key, value]) => {
this.parsePath(key, value);
});
}
const schemas = this.spec['components']?.['schemas'] as ApiSpec;
if (schemas && Object.entries(schemas).length > 0) {
if (this.mergeSchemasInOneSection) {
this.saveSchemasToParseLater(schemas);
} else {
this.parseSchemas(schemas);
}
}
}
// if we parse multiple api spec files - done() shoud be called only once in the end
done() {
if (this.mergeSchemasInOneSection && Object.entries(this.schemas).length > 0) {
this.parseSchemas(this.schemas);
}
this.doc.finish();
}
private parseInfo(infoSpec: ApiSpec) {
const objectContainsOrString = (obj: any, fields: string[]): boolean => {
return typeof obj === 'string' || typeof obj === 'object' && fields.some(field => field in obj);
}
if (infoSpec['info']) {
log(`File info:`);
Object.entries(infoSpec['info']).forEach(([key, value]) => log(` - ${key}: ${value}`));
this.doc.header(1, 'Overview');
if (infoSpec['info']['description']) {
this.doc.indentStart();
this.doc.para(sanitizeDescription(infoSpec['info']['description']));
this.doc.lineBreak();
}
if ("termsOfService" in infoSpec['info']) {
this.doc.subHeader('Terms of service');
this.doc.indentStart();
this.doc.para(infoSpec['info']['termsOfService']);
this.doc.indentEnd();
this.doc.lineBreak();
}
if ("version" in infoSpec['info']) {
this.doc.subHeader('Version');
this.doc.indentStart();
this.doc.para(infoSpec['info']['version']);
this.doc.indentEnd();
this.doc.lineBreak();
}
if ("license" in infoSpec['info']) {
this.doc.subHeader('License');
this.doc.indentStart();
if(typeof infoSpec['info']['license'] === 'object') {
this.doc.para(infoSpec['info']['license']['name']);
this.doc.para(infoSpec['info']['license']['version']);
} else {
this.doc.para(infoSpec['info']['license'] as string);
}
this.doc.indentEnd();
this.doc.lineBreak();
}
if (objectContainsOrString(infoSpec['info']['contact'], ['name', 'url', 'email'])) {
this.doc.subHeader('Contact');
this.doc.indentStart();
if (typeof infoSpec['info']['contact'] === 'object') {
this.doc.para(infoSpec['info']['contact']['name']);
this.doc.para(infoSpec['info']['contact']['url']);
this.doc.para(infoSpec['info']['contact']['email']);
} else if (typeof infoSpec['info']['contact'] === 'string') {
this.doc.para(infoSpec['info']['contact'] as string);
}
this.doc.indentEnd();
this.doc.lineBreak();
}
// add other info fields if any
const processedEntries = ["title", "description", "termsOfService", "version", "license", "contact"];
Object.entries(infoSpec['info']).forEach(([key, value]) => {
if(processedEntries.includes(key) || value === null || typeof value !== 'string') {
return;
}
this.doc.subHeader(capitalizeFirst(key));
this.doc.indentStart();
this.doc.para(value as string);
this.doc.indentEnd();
this.doc.lineBreak();
});
this.doc.lineBreak(2);
}
}
private parsePath(path: string, pathSpec: ApiSpec) {
Object.entries(pathSpec).forEach(([methodName, methodSpec]) => {
log(` - ${methodName.toUpperCase()} ${path}`);
this.doc.apiHeader(methodName.toUpperCase(), path, this.firstHeaderLevel + 1);
this.parseMethod(methodSpec);
this.doc.lineBreak(2);
});
}
private parseMethod(methodSpec: ApiSpec) {
const summary = methodSpec['summary'];
const descr = methodSpec['description'];
if (summary) {
this.doc.description(sanitizeDescription(summary));
}
if (descr) {
this.doc.description(sanitizeDescription(descr));
}
const parameters = methodSpec['parameters'] as ApiSpec[];
if (parameters && Object.keys(parameters).length > 0) {
this.doc.subHeader('Request Parameters:');
// TODO: for some reason sometimes there are duplicated parameters
this.doc.indentStart();
const uniqParams = parameters;
uniqParams.forEach((param) => {
this.parseParameter(param);
});
this.doc.indentEnd();
this.doc.lineBreak();
}
const body = methodSpec['requestBody'];
if (body && Object.keys(body).length > 0) {
this.doc.subHeader('Request Body:');
this.doc.indentStart();
this.parseBody(body);
this.doc.indentEnd();
}
const responses = methodSpec['responses'] as ApiSpec;
if (responses) {
Object.entries(responses).forEach(([key, value]) => {
this.doc.subHeader(`Response ${key}:`);
this.doc.indentStart();
this.parseBody(value);
this.doc.indentEnd();
});
}
}
private parseBody(bodySpec: ApiSpec) {
const descr = bodySpec['description'] as string;
if (descr) {
this.doc.description(sanitizeDescription(descr));
}
const contentSpec = bodySpec['content'] as ApiSpec;
let emptyBody = true;
if (contentSpec) {
Object.entries(contentSpec).forEach(([contentType, contentSpec]) => {
const schema = contentSpec['schema'] as ApiSpec;
if (schema) {
this.doc.contentType(contentType, { lineContinues: true });
this.parseSchema(schema, { alwaysShowType: true, lineContinues: true });
emptyBody = false;
}
this.parseExamples(contentSpec['examples'] as ApiSpec);
});
}
if (emptyBody) {
// this.doc.para('Empty body.');
}
this.doc.paraBreak();
}
private parseExamples(spec: ApiSpec) {
if (!spec) {
return;
}
Object.entries(spec).forEach(([name, spec]) => {
if (spec?.value && Object.keys(spec.value).length > 0) {
const exampleBody = typeof spec.value === 'object' ? JSON.stringify(spec.value, null, 2) : spec.value;
if (exampleBody) {
this.doc.example(name, exampleBody);
}
}
});
}
private saveSchemasToParseLater(schemas: ApiSpec) {
Object.entries(schemas).forEach(([key, value]) => {
if (!this.schemas[key]) {
this.schemas[key] = value;
} else {
log(`!Duplicated schema: ${key}`);
}
});
}
private parseSchemas(schemas: ApiSpec) {
// Example:
// {
// "Pet": {
// "allOf": [
// {
// "$ref": "#/components/schemas/NewPet"
// },
// {
// "type": "object",
// "required": [
// "id"
// ],
// "properties": {
// "id": {
// "type": "integer",
// "format": "int64"
// }
// }
// }
// ]
// },
// "NewPet": {
// "type": "object",
// "required": [
// "name"
// ],
// "properties": {
// "name": {
// "type": "string"
// },
// "tag": {
// "type": "string"
// }
// }
// },
// }
log('Schemas:');
const headerLevel = this.mergeSchemasInOneSection ? this.firstHeaderLevel : this.firstHeaderLevel + 1;
this.doc.newSection('Schemas');
this.doc.header(headerLevel, 'Schemas');
Object.entries(schemas).forEach(([key, value]) => {
log(` - ${key}`);
this.doc.header(headerLevel + 1, key, this.schemaAnchor(key));
this.parseSchema(value);
this.doc.lineBreak(2);
});
}
private parseSchema(schemaSpec: ApiSpec, options?: { alwaysShowType?: boolean, lineContinues?: boolean }) {
if (!schemaSpec) {
return;
}
// If schema is aggregate: allOf, anyOf, oneOf
const found = getFirstOf(schemaSpec, Object.keys(allAnyOne));
if (found?.value && Array.isArray(found.value)) {
if (options?.lineContinues) {
this.doc.nextLine();
this.doc.paraBreak();
}
const aggregate = allAnyOne[found.key];
this.doc.description(`${aggregate.name}:`);
// TODO: for allOf combine schemas into a single object
found.value.forEach((it, index, arr) => {
this.doc.indentStart();
this.parseSchema(it);
this.doc.indentEnd();
if (index < arr.length - 1) {
this.doc.description(aggregate.connectWord);
}
});
return;
}
// If schema is a reference
if (options?.lineContinues) {
this.doc.text(' | ', {}, { continued: true });
}
const ref = this.parseSchemaRef(schemaSpec);
if (ref.schemaName && ref.anchor) {
const foundRef = this.spec['components']?.['schemas']?.[ref.schemaName] as ApiSpec;
if (foundRef) {
this.doc.textRef(ref.text, ref.anchor);
if (options?.lineContinues) {
this.doc.paraBreak();
}
this.parseSchema(foundRef);
} else {
this.doc.textRef(ref.text, ref.anchor);
}
return;
} else {
if (!ref.isUndefined() && ref.text !== 'object') {
this.doc.text(ref.text);
}
}
// If schema defined explicetly
const properties = schemaSpec['properties'] as ApiSpec;
if (properties) {
const required = schemaSpec['required'] as string[];
const dataFields: DataField[] = [];
Object.entries(properties).forEach(([key, value]) => {
const typeRef = this.parseSchemaRef(value);
dataFields.push(
new DataField(key, typeRef, value['description'], required?.includes(key))
);
});
this.doc.objectSchema(dataFields);
}
else if (schemaSpec['enum']) {
this.doc.enumValues(schemaSpec['enum'] as string[]);
}
this.doc.paraBreak();
}
private schemaNameByRef(ref: string) {
const refPath = '#/components/schemas/';
const start = ref.indexOf(refPath);
if (start >= 0) {
return ref.substring(ref.indexOf(refPath) + refPath.length);
}
return ref;
}
private schemaDefinitionExists(name: string): boolean {
return (this.spec['components']?.['schemas']?.[name]) ? true : false;
}
private schemaAnchor(schemaName: string): string {
return this.mergeSchemasInOneSection ? `schemas:${schemaName}` : `${this.sectionName}:${schemaName}`;
}
private parseSchemaRef(schemaRef: ApiSpec): SchemaRef {
if (schemaRef['type']) {
if (schemaRef['type'] === 'array' && schemaRef['items']) {
const typeRef = this.parseSchemaRef(schemaRef['items']);
return new SchemaRef(`Array<${typeRef.text}>`, typeRef.schemaName, typeRef.anchor, true);
} else {
return new SchemaRef(schemaRef['type'], schemaRef['type']);
}
} else if (schemaRef['\$ref']) {
const schemaName = this.schemaNameByRef(schemaRef['\$ref']);
const anchor = this.schemaDefinitionExists(schemaName) ? this.schemaAnchor(schemaName) : undefined;
if (!anchor) {
log(`! No definition for ref: ${schemaRef['\$ref']}`);
}
return new SchemaRef(schemaName, schemaName, anchor);
}
return SchemaRef.undefined();
}
private parseParameter(paramSpec: ApiSpec) {
if (paramSpec['name']) {
let typeRef: SchemaRef | undefined = undefined;
if (paramSpec['schema'] != null) {
typeRef = this.parseSchemaRef(paramSpec['schema']);
}
if (typeRef === undefined || typeRef.isUndefined()) {
const leaf = getFirstOf(paramSpec, Object.keys(allAnyOne));
// TODO: parse entire array of possible schemas
if (leaf?.value[0]) {
typeRef = this.parseSchemaRef(leaf?.value[0]!);
}
}
this.doc.dataFields([
new DataField(paramSpec['name'], typeRef, paramSpec['description'], paramSpec['required'])
]);
}
}
}