Skip to content

Commit 06c04a0

Browse files
authored
Add questionToken option to transform (#1417)
1 parent 6da7888 commit 06c04a0

File tree

7 files changed

+220
-21
lines changed

7 files changed

+220
-21
lines changed

docs/src/content/docs/node.md

+46-2
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ That would result in the following change:
118118

119119
```diff
120120
- updated_at?: string;
121-
+ updated_at?: Date;
121+
+ updated_at: Date | null;
122122
```
123123

124124
#### Example: `Blob` types
@@ -158,7 +158,51 @@ Resultant diff with correctly-typed `file` property:
158158

159159
```diff
160160
- file?: string;
161-
+ file?: Blob;
161+
+ file: Blob | null;
162+
```
163+
164+
#### Example: Add "?" token to property
165+
166+
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:
167+
168+
```yaml
169+
Body_file_upload:
170+
type: object;
171+
properties:
172+
file:
173+
type: string;
174+
format: binary;
175+
required: true;
176+
```
177+
178+
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.
179+
180+
```ts
181+
import openapiTS from "openapi-typescript";
182+
import ts from "typescript";
183+
184+
const BLOB = ts.factory.createIdentifier("Blob"); // `Blob`
185+
const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null`
186+
187+
const ast = await openapiTS(mySchema, {
188+
transform(schemaObject, metadata) {
189+
if (schemaObject.format === "binary") {
190+
return {
191+
schema: schemaObject.nullable
192+
? ts.factory.createUnionTypeNode([BLOB, NULL])
193+
: BLOB,
194+
questionToken: true,
195+
};
196+
}
197+
},
198+
});
199+
```
200+
201+
Resultant diff with correctly-typed `file` property and "?" token:
202+
203+
```diff
204+
- file: Blob;
205+
+ file?: Blob | null;
162206
```
163207

164208
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.

packages/openapi-typescript/src/transform/components-object.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ts from "typescript";
22
import {
33
NEVER,
4+
QUESTION_TOKEN,
45
addJSDocComment,
56
tsModifiers,
67
tsPropertyIndex,
@@ -9,6 +10,7 @@ import { createRef, debug, getEntries } from "../lib/utils.js";
910
import {
1011
ComponentsObject,
1112
GlobalContext,
13+
SchemaObject,
1214
TransformNodeOptions,
1315
} from "../types.js";
1416
import transformHeaderObject from "./header-object.js";
@@ -51,14 +53,31 @@ export default function transformComponentsObject(
5153
const items: ts.TypeElement[] = [];
5254
if (componentsObject[key]) {
5355
for (const [name, item] of getEntries(componentsObject[key], ctx)) {
54-
const subType = transformers[key](item, {
56+
let subType = transformers[key](item, {
5557
path: createRef(["components", key, name]),
5658
ctx,
5759
});
60+
61+
let hasQuestionToken = false;
62+
if (ctx.transform) {
63+
const result = ctx.transform(item as SchemaObject, {
64+
path: createRef(["components", key, name]),
65+
ctx,
66+
});
67+
if (result) {
68+
if ("schema" in result) {
69+
subType = result.schema;
70+
hasQuestionToken = result.questionToken;
71+
} else {
72+
subType = result;
73+
}
74+
}
75+
}
76+
5877
const property = ts.factory.createPropertySignature(
5978
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
6079
/* name */ tsPropertyIndex(name),
61-
/* questionToken */ undefined,
80+
/* questionToken */ hasQuestionToken ? QUESTION_TOKEN : undefined,
6281
/* type */ subType,
6382
);
6483
addJSDocComment(item as unknown as any, property); // eslint-disable-line @typescript-eslint/no-explicit-any

packages/openapi-typescript/src/transform/schema-object.ts

+15-12
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,6 @@ export function transformSchemaObjectWithComposition(
8787
return oapiRef(schemaObject.$ref);
8888
}
8989

90-
/**
91-
* transform()
92-
*/
93-
if (typeof options.ctx.transform === "function") {
94-
const result = options.ctx.transform(schemaObject, options);
95-
if (result !== undefined && result !== null) {
96-
return result;
97-
}
98-
}
99-
10090
/**
10191
* const (valid for any type)
10292
*/
@@ -461,18 +451,31 @@ function transformSchemaObjectCore(
461451
continue;
462452
}
463453
}
464-
const optional =
454+
let optional =
465455
schemaObject.required?.includes(k) ||
466456
("default" in v && options.ctx.defaultNonNullable)
467457
? undefined
468458
: QUESTION_TOKEN;
469-
const type =
459+
let type =
470460
"$ref" in v
471461
? oapiRef(v.$ref)
472462
: transformSchemaObject(v, {
473463
...options,
474464
path: createRef([options.path ?? "", k]),
475465
});
466+
467+
if (typeof options.ctx.transform === "function") {
468+
const result = options.ctx.transform(v, options);
469+
if (result) {
470+
if ("schema" in result) {
471+
type = result.schema;
472+
optional = result.questionToken ? QUESTION_TOKEN : optional;
473+
} else {
474+
type = result;
475+
}
476+
}
477+
}
478+
476479
const property = ts.factory.createPropertySignature(
477480
/* modifiers */ tsModifiers({
478481
readonly:

packages/openapi-typescript/src/types.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,11 @@ export type SchemaObject = {
467467
| {}
468468
);
469469

470+
export interface TransformObject {
471+
schema: ts.TypeNode;
472+
questionToken: boolean;
473+
}
474+
470475
export interface StringSubtype {
471476
type: "string" | ["string", "null"];
472477
enum?: (string | ReferenceObject)[];
@@ -646,7 +651,7 @@ export interface OpenAPITSOptions {
646651
transform?: (
647652
schemaObject: SchemaObject,
648653
options: TransformNodeOptions,
649-
) => ts.TypeNode | undefined;
654+
) => ts.TypeNode | TransformObject | undefined;
650655
/** Modify TypeScript types built from Schema Objects */
651656
postTransform?: (
652657
type: ts.TypeNode,

packages/openapi-typescript/test/node-api.test.ts

+47-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { TestCase } from "./test-helpers.js";
66

77
const EXAMPLES_DIR = new URL("../examples/", import.meta.url);
88

9+
const DATE = ts.factory.createTypeReferenceNode(
10+
ts.factory.createIdentifier("Date"),
11+
);
12+
913
describe("Node.js API", () => {
1014
const tests: TestCase<any, OpenAPITSOptions>[] = [
1115
[
@@ -382,9 +386,49 @@ export type operations = Record<string, never>;`,
382386
* then use the `typescript` parser and it will tell you the desired
383387
* AST
384388
*/
385-
return ts.factory.createTypeReferenceNode(
386-
ts.factory.createIdentifier("Date"),
387-
);
389+
return DATE;
390+
}
391+
},
392+
},
393+
},
394+
],
395+
[
396+
"options > transform with schema object",
397+
{
398+
given: {
399+
openapi: "3.1",
400+
info: { title: "Test", version: "1.0" },
401+
components: {
402+
schemas: {
403+
Date: { type: "string", format: "date-time" },
404+
},
405+
},
406+
},
407+
want: `export type paths = Record<string, never>;
408+
export type webhooks = Record<string, never>;
409+
export interface components {
410+
schemas: {
411+
/** Format: date-time */
412+
Date?: Date;
413+
};
414+
responses: never;
415+
parameters: never;
416+
requestBodies: never;
417+
headers: never;
418+
pathItems: never;
419+
}
420+
export type $defs = Record<string, never>;
421+
export type operations = Record<string, never>;`,
422+
options: {
423+
transform(schemaObject) {
424+
if (
425+
"format" in schemaObject &&
426+
schemaObject.format === "date-time"
427+
) {
428+
return {
429+
schema: DATE,
430+
questionToken: true,
431+
};
388432
}
389433
},
390434
},

packages/openapi-typescript/test/transform/components-object.test.ts

+43-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { fileURLToPath } from "node:url";
2-
import { astToString } from "../../src/lib/ts.js";
2+
import ts from "typescript";
3+
import { NULL, astToString } from "../../src/lib/ts.js";
34
import transformComponentsObject from "../../src/transform/components-object.js";
45
import type { GlobalContext } from "../../src/types.js";
56
import { DEFAULT_CTX, TestCase } from "../test-helpers.js";
67

78
const DEFAULT_OPTIONS = DEFAULT_CTX;
89

10+
const DATE = ts.factory.createTypeReferenceNode("Date");
11+
912
describe("transformComponentsObject", () => {
1013
const tests: TestCase<any, GlobalContext>[] = [
1114
[
@@ -461,6 +464,45 @@ describe("transformComponentsObject", () => {
461464
options: { ...DEFAULT_OPTIONS, excludeDeprecated: true },
462465
},
463466
],
467+
[
468+
"transform > with transform object",
469+
{
470+
given: {
471+
schemas: {
472+
Alpha: {
473+
type: "object",
474+
properties: {
475+
z: { type: "string", format: "date-time" },
476+
},
477+
},
478+
},
479+
},
480+
want: `{
481+
schemas: {
482+
Alpha: {
483+
/** Format: date-time */
484+
z?: Date | null;
485+
};
486+
};
487+
responses: never;
488+
parameters: never;
489+
requestBodies: never;
490+
headers: never;
491+
pathItems: never;
492+
}`,
493+
options: {
494+
...DEFAULT_OPTIONS,
495+
transform(schemaObject) {
496+
if (schemaObject.format === "date-time") {
497+
return {
498+
schema: ts.factory.createUnionTypeNode([DATE, NULL]),
499+
questionToken: true,
500+
};
501+
}
502+
},
503+
},
504+
},
505+
],
464506
];
465507

466508
for (const [

packages/openapi-typescript/test/transform/request-body-object.test.ts

+42
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { fileURLToPath } from "node:url";
2+
import ts from "typescript";
23
import { astToString } from "../../src/lib/ts.js";
34
import transformRequestBodyObject from "../../src/transform/request-body-object.js";
45
import { DEFAULT_CTX, TestCase } from "../test-helpers.js";
@@ -8,6 +9,8 @@ const DEFAULT_OPTIONS = {
89
ctx: { ...DEFAULT_CTX },
910
};
1011

12+
const BLOB = ts.factory.createTypeReferenceNode("Blob");
13+
1114
describe("transformRequestBodyObject", () => {
1215
const tests: TestCase[] = [
1316
[
@@ -50,6 +53,45 @@ describe("transformRequestBodyObject", () => {
5053
// options: DEFAULT_OPTIONS,
5154
},
5255
],
56+
[
57+
"optional blob property with transform",
58+
{
59+
given: {
60+
content: {
61+
"application/json": {
62+
schema: {
63+
type: "object",
64+
properties: {
65+
blob: { type: "string", format: "binary" },
66+
},
67+
},
68+
},
69+
},
70+
},
71+
want: `{
72+
content: {
73+
"application/json": {
74+
/** Format: binary */
75+
blob?: Blob;
76+
};
77+
};
78+
}`,
79+
options: {
80+
...DEFAULT_OPTIONS,
81+
ctx: {
82+
...DEFAULT_CTX,
83+
transform(schemaObject) {
84+
if (schemaObject.format === "binary") {
85+
return {
86+
schema: BLOB,
87+
questionToken: true,
88+
};
89+
}
90+
},
91+
},
92+
},
93+
},
94+
],
5395
];
5496

5597
for (const [

0 commit comments

Comments
 (0)