From de72e592d3e43176c4a21afb3e3802d318d6d0b7 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 1 Nov 2023 19:58:08 +0100 Subject: [PATCH] Add questionToken option to transform --- docs/src/content/docs/node.md | 48 +++++++++++++++++- .../src/transform/components-object.ts | 23 ++++++++- .../src/transform/schema-object.ts | 27 +++++----- packages/openapi-typescript/src/types.ts | 7 ++- .../openapi-typescript/test/node-api.test.ts | 50 +++++++++++++++++-- .../test/transform/components-object.test.ts | 44 +++++++++++++++- .../transform/request-body-object.test.ts | 42 ++++++++++++++++ 7 files changed, 220 insertions(+), 21 deletions(-) diff --git a/docs/src/content/docs/node.md b/docs/src/content/docs/node.md index 5a93525cf..c40cb4031 100644 --- a/docs/src/content/docs/node.md +++ b/docs/src/content/docs/node.md @@ -118,7 +118,7 @@ That would result in the following change: ```diff - updated_at?: string; -+ updated_at?: Date; ++ updated_at: Date | null; ``` #### Example: `Blob` types @@ -158,7 +158,51 @@ Resultant diff with correctly-typed `file` property: ```diff - file?: string; -+ file?: Blob; ++ file: Blob | null; +``` + +#### Example: Add "?" token to property + +It is not possible to create a property with the optional "?" token with the above `transform` functions. The transform function also accepts a different return object, which allows you to add a "?" token to the property. Here's an example schema: + +```yaml +Body_file_upload: + type: object; + properties: + file: + type: string; + format: binary; + required: true; +``` + +Here we return an object with a schema property, which is the same as the above example, but we also add a `questionToken` property, which will add the "?" token to the property. + +```ts +import openapiTS from "openapi-typescript"; +import ts from "typescript"; + +const BLOB = ts.factory.createIdentifier("Blob"); // `Blob` +const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null` + +const ast = await openapiTS(mySchema, { + transform(schemaObject, metadata) { + if (schemaObject.format === "binary") { + return { + schema: schemaObject.nullable + ? ts.factory.createUnionTypeNode([BLOB, NULL]) + : BLOB, + questionToken: true, + }; + } + }, +}); +``` + +Resultant diff with correctly-typed `file` property and "?" token: + +```diff +- file: Blob; ++ file?: Blob | null; ``` Any [Schema Object](https://spec.openapis.org/oas/latest.html#schema-object) present in your schema will be run through this formatter (even remote ones!). Also be sure to check the `metadata` parameter for additional context that may be helpful. diff --git a/packages/openapi-typescript/src/transform/components-object.ts b/packages/openapi-typescript/src/transform/components-object.ts index 3baea1c5d..5bdf2ef51 100644 --- a/packages/openapi-typescript/src/transform/components-object.ts +++ b/packages/openapi-typescript/src/transform/components-object.ts @@ -1,6 +1,7 @@ import ts from "typescript"; import { NEVER, + QUESTION_TOKEN, addJSDocComment, tsModifiers, tsPropertyIndex, @@ -9,6 +10,7 @@ import { createRef, debug, getEntries } from "../lib/utils.js"; import { ComponentsObject, GlobalContext, + SchemaObject, TransformNodeOptions, } from "../types.js"; import transformHeaderObject from "./header-object.js"; @@ -51,14 +53,31 @@ export default function transformComponentsObject( const items: ts.TypeElement[] = []; if (componentsObject[key]) { for (const [name, item] of getEntries(componentsObject[key], ctx)) { - const subType = transformers[key](item, { + let subType = transformers[key](item, { path: createRef(["components", key, name]), ctx, }); + + let hasQuestionToken = false; + if (ctx.transform) { + const result = ctx.transform(item as SchemaObject, { + path: createRef(["components", key, name]), + ctx, + }); + if (result) { + if ("schema" in result) { + subType = result.schema; + hasQuestionToken = result.questionToken; + } else { + subType = result; + } + } + } + const property = ts.factory.createPropertySignature( /* modifiers */ tsModifiers({ readonly: ctx.immutable }), /* name */ tsPropertyIndex(name), - /* questionToken */ undefined, + /* questionToken */ hasQuestionToken ? QUESTION_TOKEN : undefined, /* type */ subType, ); addJSDocComment(item as unknown as any, property); // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 7b9ef3c67..29bbdc851 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -87,16 +87,6 @@ export function transformSchemaObjectWithComposition( return oapiRef(schemaObject.$ref); } - /** - * transform() - */ - if (typeof options.ctx.transform === "function") { - const result = options.ctx.transform(schemaObject, options); - if (result !== undefined && result !== null) { - return result; - } - } - /** * const (valid for any type) */ @@ -461,18 +451,31 @@ function transformSchemaObjectCore( continue; } } - const optional = + let optional = schemaObject.required?.includes(k) || ("default" in v && options.ctx.defaultNonNullable) ? undefined : QUESTION_TOKEN; - const type = + let type = "$ref" in v ? oapiRef(v.$ref) : transformSchemaObject(v, { ...options, path: createRef([options.path ?? "", k]), }); + + if (typeof options.ctx.transform === "function") { + const result = options.ctx.transform(v, options); + if (result) { + if ("schema" in result) { + type = result.schema; + optional = result.questionToken ? QUESTION_TOKEN : optional; + } else { + type = result; + } + } + } + const property = ts.factory.createPropertySignature( /* modifiers */ tsModifiers({ readonly: diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index d51a50067..e48139af5 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -467,6 +467,11 @@ export type SchemaObject = { | {} ); +export interface TransformObject { + schema: ts.TypeNode; + questionToken: boolean; +} + export interface StringSubtype { type: "string" | ["string", "null"]; enum?: (string | ReferenceObject)[]; @@ -646,7 +651,7 @@ export interface OpenAPITSOptions { transform?: ( schemaObject: SchemaObject, options: TransformNodeOptions, - ) => ts.TypeNode | undefined; + ) => ts.TypeNode | TransformObject | undefined; /** Modify TypeScript types built from Schema Objects */ postTransform?: ( type: ts.TypeNode, diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index 555fdf3e2..319257e4b 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -6,6 +6,10 @@ import { TestCase } from "./test-helpers.js"; const EXAMPLES_DIR = new URL("../examples/", import.meta.url); +const DATE = ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier("Date"), +); + describe("Node.js API", () => { const tests: TestCase[] = [ [ @@ -382,9 +386,49 @@ export type operations = Record;`, * then use the `typescript` parser and it will tell you the desired * AST */ - return ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier("Date"), - ); + return DATE; + } + }, + }, + }, + ], + [ + "options > transform with schema object", + { + given: { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + components: { + schemas: { + Date: { type: "string", format: "date-time" }, + }, + }, + }, + want: `export type paths = Record; +export type webhooks = Record; +export interface components { + schemas: { + /** Format: date-time */ + Date?: Date; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + options: { + transform(schemaObject) { + if ( + "format" in schemaObject && + schemaObject.format === "date-time" + ) { + return { + schema: DATE, + questionToken: true, + }; } }, }, diff --git a/packages/openapi-typescript/test/transform/components-object.test.ts b/packages/openapi-typescript/test/transform/components-object.test.ts index 42f110cb5..26aa77e77 100644 --- a/packages/openapi-typescript/test/transform/components-object.test.ts +++ b/packages/openapi-typescript/test/transform/components-object.test.ts @@ -1,11 +1,14 @@ import { fileURLToPath } from "node:url"; -import { astToString } from "../../src/lib/ts.js"; +import ts from "typescript"; +import { NULL, astToString } from "../../src/lib/ts.js"; import transformComponentsObject from "../../src/transform/components-object.js"; import type { GlobalContext } from "../../src/types.js"; import { DEFAULT_CTX, TestCase } from "../test-helpers.js"; const DEFAULT_OPTIONS = DEFAULT_CTX; +const DATE = ts.factory.createTypeReferenceNode("Date"); + describe("transformComponentsObject", () => { const tests: TestCase[] = [ [ @@ -461,6 +464,45 @@ describe("transformComponentsObject", () => { options: { ...DEFAULT_OPTIONS, excludeDeprecated: true }, }, ], + [ + "transform > with transform object", + { + given: { + schemas: { + Alpha: { + type: "object", + properties: { + z: { type: "string", format: "date-time" }, + }, + }, + }, + }, + want: `{ + schemas: { + Alpha: { + /** Format: date-time */ + z?: Date | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +}`, + options: { + ...DEFAULT_OPTIONS, + transform(schemaObject) { + if (schemaObject.format === "date-time") { + return { + schema: ts.factory.createUnionTypeNode([DATE, NULL]), + questionToken: true, + }; + } + }, + }, + }, + ], ]; for (const [ diff --git a/packages/openapi-typescript/test/transform/request-body-object.test.ts b/packages/openapi-typescript/test/transform/request-body-object.test.ts index 1875cda38..dfc8b2d22 100644 --- a/packages/openapi-typescript/test/transform/request-body-object.test.ts +++ b/packages/openapi-typescript/test/transform/request-body-object.test.ts @@ -1,4 +1,5 @@ import { fileURLToPath } from "node:url"; +import ts from "typescript"; import { astToString } from "../../src/lib/ts.js"; import transformRequestBodyObject from "../../src/transform/request-body-object.js"; import { DEFAULT_CTX, TestCase } from "../test-helpers.js"; @@ -8,6 +9,8 @@ const DEFAULT_OPTIONS = { ctx: { ...DEFAULT_CTX }, }; +const BLOB = ts.factory.createTypeReferenceNode("Blob"); + describe("transformRequestBodyObject", () => { const tests: TestCase[] = [ [ @@ -50,6 +53,45 @@ describe("transformRequestBodyObject", () => { // options: DEFAULT_OPTIONS, }, ], + [ + "optional blob property with transform", + { + given: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + blob: { type: "string", format: "binary" }, + }, + }, + }, + }, + }, + want: `{ + content: { + "application/json": { + /** Format: binary */ + blob?: Blob; + }; + }; +}`, + options: { + ...DEFAULT_OPTIONS, + ctx: { + ...DEFAULT_CTX, + transform(schemaObject) { + if (schemaObject.format === "binary") { + return { + schema: BLOB, + questionToken: true, + }; + } + }, + }, + }, + }, + ], ]; for (const [