import { errorLog, log } from './utils/logger'; import { PdfWriter } from './pdf/pdf-writer'; import { capitalizeFirst, sanitizeDescription } from './utils/string-utils'; export type ApiSpec = Record; 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 = { '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; 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']) ]); } } }