From c091aa28d0ecc7ce6a0e09a1d2a2ea27c1a1a925 Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sun, 16 Apr 2023 11:39:16 +0100 Subject: [PATCH] feat: config flag to switch schema parser - add flag to change between zod / joi - implement unimplemented joi methods and generally bring to parity with the zod implementation --- integration-tests/typescript-koa/generate.sh | 3 +- packages/openapi-code-generator/src/config.ts | 15 ++++++- packages/openapi-code-generator/src/index.ts | 6 ++- .../openapi-code-generator/src/templates.ts | 7 +--- .../src/templates.types.ts | 12 ++++++ .../schema-builders/joi-schema-builder.ts | 42 ++++++++++++------- .../schema-builders/zod-schema-builder.ts | 5 +-- .../typescript-koa.generator.ts | 10 +++-- packages/typescript-koa-runtime/src/joi.ts | 41 +++++++++++++++++- 9 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 packages/openapi-code-generator/src/templates.types.ts diff --git a/integration-tests/typescript-koa/generate.sh b/integration-tests/typescript-koa/generate.sh index c58feaa7c..6f49e54ff 100755 --- a/integration-tests/typescript-koa/generate.sh +++ b/integration-tests/typescript-koa/generate.sh @@ -8,5 +8,6 @@ for path in ../../integration-tests-definitions/*; do node ../../packages/openapi-code-generator/dist/index.js \ --input="$path" \ --output="./src/$filename" \ - --template=typescript-koa + --template=typescript-koa \ + --schema-builder=zod done diff --git a/packages/openapi-code-generator/src/config.ts b/packages/openapi-code-generator/src/config.ts index 1c2ff5fb5..8cc5b9480 100644 --- a/packages/openapi-code-generator/src/config.ts +++ b/packages/openapi-code-generator/src/config.ts @@ -1,5 +1,7 @@ import convict from "convict" -import {OpenapiGenerator, templates} from "./templates" +import {templates} from "./templates" +import {OpenapiGenerator} from "./templates.types" +import {SchemaBuilderType} from "./typescript/common/schema-builders/schema-builder" const convictConfig = convict({ input: { @@ -23,6 +25,13 @@ const convictConfig = convict({ env: "TEMPLATE", arg: "template", }, + schemaBuilder: { + doc: "Runtime parsing library to use", + format: ["zod", "joi"], + default: "zod", + env: "SCHEMA_BUILDER", + arg: "schema-builder", + } }) export class Config { @@ -34,6 +43,10 @@ export class Config { return convictConfig.get("output") } + get schemaBuilder(): SchemaBuilderType { + return convictConfig.get("schemaBuilder") + } + get generator(): OpenapiGenerator { const template = convictConfig.get("template") diff --git a/packages/openapi-code-generator/src/index.ts b/packages/openapi-code-generator/src/index.ts index 0dadf1736..70eabdae5 100644 --- a/packages/openapi-code-generator/src/index.ts +++ b/packages/openapi-code-generator/src/index.ts @@ -22,7 +22,11 @@ async function main() { logger.time("generation") // TODO abort generation if not a git repo or there are uncommitted changes - await config.generator({input, dest: config.output}) + await config.generator({ + input, + dest: config.output, + schemaBuilder: config.schemaBuilder, + }) } main() diff --git a/packages/openapi-code-generator/src/templates.ts b/packages/openapi-code-generator/src/templates.ts index ebf9c1916..18870afe4 100644 --- a/packages/openapi-code-generator/src/templates.ts +++ b/packages/openapi-code-generator/src/templates.ts @@ -1,12 +1,7 @@ -import {Input} from "./core/input" - import {generateTypescriptAngular} from "./typescript/typescript-angular/typescript-angular.generator" import {generateTypescriptFetch} from "./typescript/typescript-fetch/typescript-fetch.generator" import {generateTypescriptKoa} from "./typescript/typescript-koa/typescript-koa.generator" - -export interface OpenapiGenerator { - (args: { dest: string, input: Input }): Promise -} +import {OpenapiGenerator} from "./templates.types" export const templates: Record = { "typescript-fetch": generateTypescriptFetch, diff --git a/packages/openapi-code-generator/src/templates.types.ts b/packages/openapi-code-generator/src/templates.types.ts new file mode 100644 index 000000000..cf7818de1 --- /dev/null +++ b/packages/openapi-code-generator/src/templates.types.ts @@ -0,0 +1,12 @@ +import {Input} from "./core/input" +import {SchemaBuilderType} from "./typescript/common/schema-builders/schema-builder" + +export interface OpenapiGeneratorConfig { + dest: string, + input: Input, + schemaBuilder: SchemaBuilderType +} + +export interface OpenapiGenerator { + (args: OpenapiGeneratorConfig): Promise +} diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts index b586a01bc..f9333add0 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts @@ -21,36 +21,48 @@ export class JoiBuilder extends AbstractSchemaBuilder { imports: ImportBuilder, ) { super(filename, input, imports) - } + this.importHelpers(imports) + + imports.from("@nahkies/typescript-koa-runtime/joi") + .add("parseRequestInput", "Params", "responseValidationFactory") + } - protected importHelpers(importBuilder: ImportBuilder) { - importBuilder.addModule(this.joi, "@hapi/joi") - importBuilder.from("@nahkies/typescript-koa-runtime/joi") - .add("parseRequestInput", "Params") + protected importHelpers(imports: ImportBuilder) { + imports.addModule(this.joi, "@hapi/joi") } public any(): string { - // TODO: implement - throw new Error("Method not implemented.") + return [ + this.joi, + "any()", + ].filter(isDefined).join(".") } public void(): string { - // TODO: implement - throw new Error("Method not implemented.") + return [ + this.joi, + "any()", + "valid(undefined)", + ].filter(isDefined).join(".") } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected intersect(schemas: string[]): string { - // TODO: implement - throw new Error("Method not implemented.") + return schemas.filter(isDefined).reduce((acc, it) => { + return `${acc}\n.concat(${it})` + }) } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected union(schemas: string[]): string { - // TODO: implement - throw new Error("Method not implemented.") + return [ + this.joi, + `alternatives().try(${ + schemas.filter(isDefined).map(it => it).join(",") + })` + ].filter(isDefined).join(".") } protected nullable(schema: string): string { diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-schema-builder.ts index 98a074b46..273daef64 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-schema-builder.ts @@ -18,8 +18,7 @@ export class ZodBuilder extends AbstractSchemaBuilder { ) { super(filename, input, imports) - imports.from("zod") - .add(this.zod) + this.importHelpers(imports) imports.from("@nahkies/typescript-koa-runtime/zod") .add("parseRequestInput", "Params", "responseValidationFactory") @@ -40,7 +39,7 @@ export class ZodBuilder extends AbstractSchemaBuilder { return [ this.zod, `union([\n${ - schemas.map(it => it + ",").join("\n") + schemas.filter(isDefined).map(it => it + ",").join("\n") }\n])` ].filter(isDefined).join(".") } diff --git a/packages/openapi-code-generator/src/typescript/typescript-koa/typescript-koa.generator.ts b/packages/openapi-code-generator/src/typescript/typescript-koa/typescript-koa.generator.ts index e1ae1af3b..3b29ea309 100644 --- a/packages/openapi-code-generator/src/typescript/typescript-koa/typescript-koa.generator.ts +++ b/packages/openapi-code-generator/src/typescript/typescript-koa/typescript-koa.generator.ts @@ -7,6 +7,7 @@ import {TypeBuilder} from "../common/type-builder" import {isDefined, titleCase} from "../../core/utils" import {SchemaBuilder, schemaBuilderFactory} from "../common/schema-builders/schema-builder" import {requestBodyAsParameter, statusStringToType} from "../common/typescript-common" +import {OpenapiGeneratorConfig} from "../../templates.types" function reduceParamsToOpenApiSchema(parameters: IRParameter[]): IRModelObject { return parameters.reduce((acc, parameter) => { @@ -201,10 +202,11 @@ function route(route: string): string { }, route) } -export async function generateTypescriptKoa({dest, input}: { dest: string, input: Input }): Promise { +export async function generateTypescriptKoa(config: OpenapiGeneratorConfig): Promise { + const input = config.input const imports = new ImportBuilder() const types = TypeBuilder.fromInput("./models.ts", input).withImports(imports) - const schemaBuilder = schemaBuilderFactory("zod", input, imports) + const schemaBuilder = schemaBuilderFactory(config.schemaBuilder, input, imports) const server = new ServerBuilder( "generated.ts", @@ -213,13 +215,13 @@ export async function generateTypescriptKoa({dest, input}: { dest: string, input imports, types, schemaBuilder, - loadExistingImplementations(await loadPreviousResult(dest, {filename: "index.ts"})) + loadExistingImplementations(await loadPreviousResult(config.dest, {filename: "index.ts"})) ) input.allOperations() .map(it => server.add(it)) - await emitGenerationResult(dest, [ + await emitGenerationResult(config.dest, [ types, server, schemaBuilder, diff --git a/packages/typescript-koa-runtime/src/joi.ts b/packages/typescript-koa-runtime/src/joi.ts index ed0b77b68..d765e0a86 100644 --- a/packages/typescript-koa-runtime/src/joi.ts +++ b/packages/typescript-koa-runtime/src/joi.ts @@ -24,9 +24,46 @@ export function parseRequestInput( const result = schema.validate(input, {stripUnknown: true}) if (result.error) { - // TODO: improve error - throw new Error("validation error") + throw result.error } return result.value } + +export function responseValidationFactory(possibleResponses: [string, JoiSchema][], defaultResponse?: JoiSchema) { + + // Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx + possibleResponses.sort((x, y) => x[0] < y[0] ? -1 : 1) + + return (status: number, value: unknown) => { + + for (const [match, schema] of possibleResponses) { + + const isMatch = + /^\d+$/.test(match) && String(status) === match || + /^\d[xX]{2}$/.test(match) && String(status)[0] === match[0] + + if (isMatch) { + const result = schema.validate(value) + + if (result.error) { + throw result.error + } + + return result.value + } + } + + if (defaultResponse) { + const result = defaultResponse.validate(value) + + if (result.error) { + throw result.error + } + + return result.value + } + + return value + } +}