diff --git a/package-lock.json b/package-lock.json index 4aa643d..4d09c2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "3.0.0", "license": "ISC", "dependencies": { + "@apidevtools/json-schema-ref-parser": "^14.1.1", + "change-case": "^5.4.4", "oas-normalize": "^14.1.2", "ts-essentials": "^10.1.1", "yaml": "^2.8.0" @@ -74,16 +76,16 @@ } }, "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-13.0.5.tgz", - "integrity": "sha512-xfh4xVJD62gG6spIc7lwxoWT+l16nZu1ELyU8FkjaP/oD2yP09EvLAU6KhtudN9aML2Khhs9pY6Slr7KGTES3w==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.1.1.tgz", + "integrity": "sha512-uGF1YGOzzD50L7HLNWclXmsEhQflw8/zZHIz0/AzkJrKL5r9PceUipZxR/cp/8veTk4TVfdDJLyIwXLjaP5ePg==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" }, "engines": { - "node": ">= 16" + "node": ">= 20" }, "funding": { "url": "https://github.com/sponsors/philsturgeon" @@ -2251,6 +2253,22 @@ "openapi-types": ">=7" } }, + "node_modules/@readme/openapi-parser/node_modules/@apidevtools/json-schema-ref-parser": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-13.0.5.tgz", + "integrity": "sha512-xfh4xVJD62gG6spIc7lwxoWT+l16nZu1ELyU8FkjaP/oD2yP09EvLAU6KhtudN9aML2Khhs9pY6Slr7KGTES3w==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@readme/openapi-schemas": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@readme/openapi-schemas/-/openapi-schemas-3.1.0.tgz", @@ -3970,8 +3988,7 @@ "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/char-regex": { "version": "1.0.2", diff --git a/package.json b/package.json index c8f9dfc..2b0b47f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ }, "homepage": "https://github.com/MapColonies/openapi-helpers#readme", "dependencies": { + "@apidevtools/json-schema-ref-parser": "^14.1.1", + "change-case": "^5.4.4", "oas-normalize": "^14.1.2", "ts-essentials": "^10.1.1", "yaml": "^2.8.0" diff --git a/src/generator/generateErrors.mts b/src/generator/generateErrors.mts new file mode 100644 index 0000000..34a9b71 --- /dev/null +++ b/src/generator/generateErrors.mts @@ -0,0 +1,129 @@ +import fs from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import path from 'node:path'; +import { dereference } from '@apidevtools/json-schema-ref-parser'; +import { format, resolveConfig } from 'prettier'; +import * as changeCase from 'change-case'; +import type { OpenAPI3, OperationObject, ResponseObject, SchemaObject } from 'openapi-typescript'; + +const ARGS_SLICE = 2; + +const { + values: { format: shouldFormat }, + positionals, +} = parseArgs({ + args: process.argv.slice(ARGS_SLICE), + options: { + format: { type: 'boolean', alias: 'f' }, + }, + allowPositionals: true, +}); + +const [openapiPath, destinationPath] = positionals; + +if (openapiPath === undefined || destinationPath === undefined) { + console.error('Usage: generateErrors '); + process.exit(1); +} + +const openapi = await dereference(openapiPath); + +if (openapi.paths === undefined) { + console.error('No paths found in the OpenAPI document.'); + process.exit(1); +} + +const errorCodes = new Set(); + +function extractCodeFromSchema(schema: SchemaObject): void { + // Handle direct code property + if (schema.type === 'object' && schema.properties?.code) { + const codeProperty = schema.properties.code as SchemaObject; + + // Handle enum values + if (codeProperty.enum) { + codeProperty.enum.map(String).forEach((code) => { + errorCodes.add(code); + }); + } + } + + // Handle allOf combinations + if (schema.allOf) { + for (const subSchema of schema.allOf) { + extractCodeFromSchema(subSchema as SchemaObject); + } + } + + // Handle oneOf combinations + if (schema.oneOf) { + for (const subSchema of schema.oneOf) { + extractCodeFromSchema(subSchema as SchemaObject); + } + } + + // Handle anyOf combinations + if (schema.anyOf) { + for (const subSchema of schema.anyOf) { + extractCodeFromSchema(subSchema as SchemaObject); + } + } +} + +function createError(code: string): string { + let className = changeCase.pascalCase(code); + + if (!className.endsWith('Error')) { + className += 'Error'; + } + + return `export class ${className} extends Error { + public readonly code = '${code}'; + /** + * Creates an instance of ${className}. + * @param message - The error message. + * @param cause - Optional original error or server response data. + */ + public constructor(message: string, cause?: unknown) { + super(message, { cause }); + Object.setPrototypeOf(this, new.target.prototype); + } +};\n`; +} + +for (const [, methods] of Object.entries(openapi.paths)) { + for (const [key, operation] of Object.entries(methods) as [string, OperationObject][]) { + if (['servers', 'parameters'].includes(key)) { + continue; + } + + for (const [statusCode, response] of Object.entries(operation.responses ?? {}) as [string, ResponseObject][]) { + if (statusCode.startsWith('2') || statusCode.startsWith('3')) { + continue; // Skip successful and redirection responses + } + + const schema = response.content?.['application/json']?.schema as SchemaObject | undefined; + if (schema) { + extractCodeFromSchema(schema); + } + } + } +} + +if (errorCodes.size === 0) { + console.warn('No error codes found in the OpenAPI document.'); + process.exit(0); +} + +let errorFile = errorCodes.values().map(createError).toArray().join('\n'); + +if (shouldFormat === true) { + const prettierOptions = await resolveConfig('./src/index.ts'); + + errorFile = await format(errorFile, { ...prettierOptions, parser: 'typescript' }); +} + +const directory = path.dirname(destinationPath); +await fs.mkdir(directory, { recursive: true }); + +await fs.writeFile(destinationPath, errorFile); diff --git a/tsconfig.json b/tsconfig.json index 317c576..0bb5cd8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "@map-colonies/tsconfig/tsconfig-library", + "compilerOptions": { + "lib": ["ESNext"] + }, "include": ["src", "tests"], "exclude": ["dist", "node_modules"] }