Skip to content

Add questionToken option to transform #1417

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions docs/src/content/docs/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 The docs look great! Thank you.

```

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.
Expand Down
23 changes: 21 additions & 2 deletions packages/openapi-typescript/src/transform/components-object.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ts from "typescript";
import {
NEVER,
QUESTION_TOKEN,
addJSDocComment,
tsModifiers,
tsPropertyIndex,
Expand All @@ -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";
Expand Down Expand Up @@ -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, {
Copy link
Contributor

@drwpow drwpow Nov 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup I think this will work in all cases I can think of. Just handling keys on type: object schema objects should be the main usecase here (we don’t ever want to have question tokens, for example, directly on the components["schemas"] object as that doesn’t make sense)

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
Expand Down
27 changes: 15 additions & 12 deletions packages/openapi-typescript/src/transform/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,11 @@ export type SchemaObject = {
| {}
);

export interface TransformObject {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 This is great.

We can probably extend this pretty far before having to change this API.

schema: ts.TypeNode;
questionToken: boolean;
}

export interface StringSubtype {
type: "string" | ["string", "null"];
enum?: (string | ReferenceObject)[];
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 47 additions & 3 deletions packages/openapi-typescript/test/node-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, OpenAPITSOptions>[] = [
[
Expand Down Expand Up @@ -382,9 +386,49 @@ export type operations = Record<string, never>;`,
* 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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 Fantastic tests!

{
given: {
openapi: "3.1",
info: { title: "Test", version: "1.0" },
components: {
schemas: {
Date: { type: "string", format: "date-time" },
},
},
},
want: `export type paths = Record<string, never>;
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** Format: date-time */
Date?: Date;
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;`,
options: {
transform(schemaObject) {
if (
"format" in schemaObject &&
schemaObject.format === "date-time"
) {
return {
schema: DATE,
questionToken: true,
};
}
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any, GlobalContext>[] = [
[
Expand Down Expand Up @@ -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 [
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -8,6 +9,8 @@ const DEFAULT_OPTIONS = {
ctx: { ...DEFAULT_CTX },
};

const BLOB = ts.factory.createTypeReferenceNode("Blob");

describe("transformRequestBodyObject", () => {
const tests: TestCase[] = [
[
Expand Down Expand Up @@ -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 [
Expand Down