diff --git a/.changeset/bright-spies-rescue.md b/.changeset/bright-spies-rescue.md new file mode 100644 index 000000000..e9d2739fc --- /dev/null +++ b/.changeset/bright-spies-rescue.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": minor +--- + +Add transformProperty hook for property signature modification diff --git a/docs/node.md b/docs/node.md index cad478d8a..bd837887a 100644 --- a/docs/node.md +++ b/docs/node.md @@ -83,13 +83,14 @@ const ast = await openapiTS(mySchema, { redocly }); The Node API supports all the [CLI flags](/cli#flags) in `camelCase` format, plus the following additional options: -| Name | Type | Default | Description | -| :-------------- | :-------------: | :-------------: | :------------------------------------------------------------------------------------------- | -| `transform` | `Function` | | Override the default Schema Object ➝ TypeScript transformer in certain scenarios | -| `postTransform` | `Function` | | Same as `transform` but runs _after_ the TypeScript transformation | -| `silent` | `boolean` | `false` | Silence warning messages (fatal errors will still show) | -| `cwd` | `string \| URL` | `process.cwd()` | (optional) Provide the current working directory to help resolve remote `$ref`s (if needed). | -| `inject` | `string` | | Inject arbitrary TypeScript types into the start of the file | +| Name | Type | Default | Description | +| :----------------- | :-------------: | :-------------: | :------------------------------------------------------------------------------------------- | +| `transform` | `Function` | | Override the default Schema Object ➝ TypeScript transformer in certain scenarios | +| `postTransform` | `Function` | | Same as `transform` but runs _after_ the TypeScript transformation | +| `transformProperty`| `Function` | | Transform individual property signatures for Schema Object properties | +| `silent` | `boolean` | `false` | Silence warning messages (fatal errors will still show) | +| `cwd` | `string \| URL` | `process.cwd()` | (optional) Provide the current working directory to help resolve remote `$ref`s (if needed). | +| `inject` | `string` | | Inject arbitrary TypeScript types into the start of the file | ### transform / postTransform @@ -254,4 +255,139 @@ file?: Blob | null; // [!code ++] 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. -There are many other uses for this besides checking `format`. Because this must return a **string** you can produce any arbitrary TypeScript code you’d like (even your own custom types). +There are many other uses for this besides checking `format`. Because this must return a **string** you can produce any arbitrary TypeScript code you'd like (even your own custom types). + +### transformProperty + +Use the `transformProperty()` option to modify individual property signatures within Schema Objects. This is particularly useful for adding JSDoc comments, validation annotations, or modifying property-level attributes that can't be achieved with `transform` or `postTransform`. + +- `transformProperty()` runs **after** type conversion but **before** JSDoc comments are added +- It receives the property signature, the original schema object, and transformation options +- It should return a modified `PropertySignature` or `undefined` to leave the property unchanged + +#### Example: JSDoc validation annotations + +A common use case is adding validation annotations based on OpenAPI schema constraints: + +::: code-group + +```yaml [my-openapi-3-schema.yaml] +components: + schemas: + User: + type: object + properties: + name: + type: string + minLength: 1 + pattern: "^[a-zA-Z0-9]+$" + email: + type: string + format: email + age: + type: integer + minimum: 0 + maximum: 120 + required: [name, email] +``` + +::: + +::: code-group + +```ts [src/my-project.ts] +import fs from "node:fs"; +import ts from "typescript"; +import openapiTS, { astToString } from "openapi-typescript"; + +const ast = await openapiTS(mySchema, { + transformProperty(property, schemaObject, options) { + const validationTags: string[] = []; + + // Add validation JSDoc tags based on schema constraints + if (schemaObject.minLength !== undefined) { + validationTags.push(`@minLength ${schemaObject.minLength}`); + } + if (schemaObject.maxLength !== undefined) { + validationTags.push(`@maxLength ${schemaObject.maxLength}`); + } + if (schemaObject.minimum !== undefined) { + validationTags.push(`@minimum ${schemaObject.minimum}`); + } + if (schemaObject.maximum !== undefined) { + validationTags.push(`@maximum ${schemaObject.maximum}`); + } + if (schemaObject.pattern !== undefined) { + validationTags.push(`@pattern ${schemaObject.pattern}`); + } + if (schemaObject.format !== undefined) { + validationTags.push(`@format ${schemaObject.format}`); + } + + // If we have validation tags, add them as JSDoc comments + if (validationTags.length > 0) { + // Create a new property signature + const newProperty = ts.factory.updatePropertySignature( + property, + property.modifiers, + property.name, + property.questionToken, + property.type, + ); + + // Add JSDoc comment + const jsDocText = `*\n * ${validationTags.join('\n * ')}\n `; + + ts.addSyntheticLeadingComment( + newProperty, + ts.SyntaxKind.MultiLineCommentTrivia, + jsDocText, + true, + ); + + return newProperty; + } + + return property; + }, +}); + +const contents = astToString(ast); +fs.writeFileSync("./my-schema.ts", contents); +``` + +::: + +This transforms the schema into TypeScript with validation annotations: + +::: code-group + +```ts [my-schema.d.ts] +export interface components { + schemas: { + User: { + /** + * @minLength 1 + * @pattern ^[a-zA-Z0-9]+$ + */ + name: string; + /** + * @format email + */ + email: string; + /** + * @minimum 0 + * @maximum 120 + */ + age?: number; + }; + }; +} +``` + +::: + +The `transformProperty` function provides access to: +- `property`: The TypeScript PropertySignature AST node +- `schemaObject`: The original OpenAPI Schema Object for this property +- `options`: Transformation context including path information and other utilities diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index cbd52bc93..dabd4e81b 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -89,6 +89,7 @@ export default async function openapiTS( silent: options.silent ?? false, inject: options.inject ?? undefined, transform: typeof options.transform === "function" ? options.transform : undefined, + transformProperty: typeof options.transformProperty === "function" ? options.transformProperty : undefined, makePathsEnum: options.makePathsEnum ?? false, generatePathParams: options.generatePathParams ?? false, resolve($ref) { diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 02a1114c4..cb06fa634 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -501,7 +501,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor } } - const property = ts.factory.createPropertySignature( + let property = ts.factory.createPropertySignature( /* modifiers */ tsModifiers({ readonly: options.ctx.immutable || readOnly, }), @@ -509,6 +509,18 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor /* questionToken */ optional, /* type */ type, ); + + // Apply transformProperty hook if available + if (typeof options.ctx.transformProperty === "function") { + const result = options.ctx.transformProperty(property, v as SchemaObject, { + ...options, + path: createRef([options.path, k]), + }); + if (result) { + property = result; + } + } + addJSDocComment(v, property); coreObjectType.push(property); } @@ -518,7 +530,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor if (schemaObject.$defs && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) { const defKeys: ts.TypeElement[] = []; for (const [k, v] of Object.entries(schemaObject.$defs)) { - const property = ts.factory.createPropertySignature( + let property = ts.factory.createPropertySignature( /* modifiers */ tsModifiers({ readonly: options.ctx.immutable || ("readonly" in v && !!v.readOnly), }), @@ -529,6 +541,18 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor path: createRef([options.path, "$defs", k]), }), ); + + // Apply transformProperty hook if available + if (typeof options.ctx.transformProperty === "function") { + const result = options.ctx.transformProperty(property, v as SchemaObject, { + ...options, + path: createRef([options.path, "$defs", k]), + }); + if (result) { + property = result; + } + } + addJSDocComment(v, property); defKeys.push(property); } diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index fc2dfccfa..53de94798 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -639,6 +639,12 @@ export interface OpenAPITSOptions { transform?: (schemaObject: SchemaObject, options: TransformNodeOptions) => ts.TypeNode | TransformObject | undefined; /** Modify TypeScript types built from Schema Objects */ postTransform?: (type: ts.TypeNode, options: TransformNodeOptions) => ts.TypeNode | undefined; + /** Modify property signatures for Schema Object properties */ + transformProperty?: ( + property: ts.PropertySignature, + schemaObject: SchemaObject, + options: TransformNodeOptions, + ) => ts.PropertySignature | undefined; /** Add readonly properties and readonly arrays? (default: false) */ immutable?: boolean; /** (optional) Should logging be suppressed? (necessary for STDOUT) */ @@ -701,6 +707,7 @@ export interface GlobalContext { redoc: RedoclyConfig; silent: boolean; transform: OpenAPITSOptions["transform"]; + transformProperty: OpenAPITSOptions["transformProperty"]; /** retrieve a node by $ref */ resolve($ref: string): T | undefined; inject?: string; diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index 3b0e80ad4..60ce75cb6 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -602,6 +602,163 @@ export type operations = Record;`, }, }, ], + [ + "options > transformProperty > JSDoc validation annotations", + { + given: { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + components: { + schemas: { + User: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + pattern: "^[a-zA-Z0-9]+$", + }, + email: { + type: "string", + format: "email", + }, + age: { + type: "integer", + minimum: 0, + maximum: 120, + }, + }, + required: ["name", "email"], + }, + }, + }, + }, + want: `export type paths = Record; +export type webhooks = Record; +export interface components { + schemas: { + User: { + /** + * @minLength 1 + * @pattern ^[a-zA-Z0-9]+$ + */ + name: string; + /** + * @format email + */ + /** Format: email */ + email: string; + /** + * @minimum 0 + * @maximum 120 + */ + age?: number; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + options: { + transformProperty(property, schemaObject, _options) { + const validationTags: string[] = []; + const schema = schemaObject as any; // Cast to access validation properties + + if (schema.minLength !== undefined) { + validationTags.push(`@minLength ${schema.minLength}`); + } + if (schema.maxLength !== undefined) { + validationTags.push(`@maxLength ${schema.maxLength}`); + } + if (schema.minimum !== undefined) { + validationTags.push(`@minimum ${schema.minimum}`); + } + if (schema.maximum !== undefined) { + validationTags.push(`@maximum ${schema.maximum}`); + } + if (schema.pattern !== undefined) { + validationTags.push(`@pattern ${schema.pattern}`); + } + if (schema.format !== undefined) { + validationTags.push(`@format ${schema.format}`); + } + + if (validationTags.length > 0) { + // Create a new property signature + const newProperty = ts.factory.updatePropertySignature( + property, + property.modifiers, + property.name, + property.questionToken, + property.type, + ); + + // Add JSDoc comment using the same format as addJSDocComment + const jsDocText = `*\n * ${validationTags.join("\n * ")}\n `; + + ts.addSyntheticLeadingComment(newProperty, ts.SyntaxKind.MultiLineCommentTrivia, jsDocText, true); + + return newProperty; + } + + return property; + }, + }, + }, + ], + [ + "options > transformProperty > no-op when returning undefined", + { + given: { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + components: { + schemas: { + User: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string", format: "email" }, + }, + required: ["name"], + }, + }, + }, + }, + want: `export type paths = Record; +export type webhooks = Record; +export interface components { + schemas: { + User: { + name: string; + /** Format: email */ + email?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + options: { + transformProperty(property, schemaObject, _options) { + const schema = schemaObject as any; // Cast to access validation properties + // Only transform properties with minLength, return undefined for others + if (schema.minLength === undefined) { + return undefined; // Should leave property unchanged + } + return property; + }, + }, + }, + ], [ "options > enum", { diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index 72e44fccc..13d46568a 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -31,6 +31,7 @@ export const DEFAULT_CTX: GlobalContext = { }, silent: true, transform: undefined, + transformProperty: undefined, makePathsEnum: false, generatePathParams: false, };