diff --git a/packages/shared/src/config/parser/patch.ts b/packages/shared/src/config/parser/patch.ts index 9f7c66178..4272f4f73 100644 --- a/packages/shared/src/config/parser/patch.ts +++ b/packages/shared/src/config/parser/patch.ts @@ -1,6 +1,7 @@ import type { MaybeFunc } from '@hey-api/types'; import type { + OpenApi, OpenApiMetaObject, OpenApiOperationObject, OpenApiParameterObject, @@ -9,119 +10,158 @@ import type { OpenApiSchemaObject, } from '../../openApi/types'; -export type Patch = { - /** - * Patch the OpenAPI meta object in place. Useful for modifying general metadata such as title, description, version, or custom fields before further processing. - * - * @param meta The OpenAPI meta object for the current version. - */ - meta?: ( - meta: OpenApiMetaObject.V2_0_X | OpenApiMetaObject.V3_0_X | OpenApiMetaObject.V3_1_X, - ) => void; - /** - * Patch OpenAPI operations in place. The key is the operation method and operation path, and the function receives the operation object to modify directly. - * - * @example - * operations: { - * 'GET /foo': (operation) => { - * operation.responses['200'].description = 'foo'; - * } - * } - */ - operations?: Record< - string, - ( - operation: - | OpenApiOperationObject.V2_0_X - | OpenApiOperationObject.V3_0_X - | OpenApiOperationObject.V3_1_X, - ) => void - >; - /** - * Patch OpenAPI parameters in place. The key is the parameter name, and the function receives the parameter object to modify directly. - * - * @example - * parameters: { - * limit: (parameter) => { - * parameter.schema.type = 'integer'; - * } - * } - */ - parameters?: Record< - string, - (parameter: OpenApiParameterObject.V3_0_X | OpenApiParameterObject.V3_1_X) => void - >; - /** - * Patch OpenAPI request bodies in place. The key is the request body name, and the function receives the request body object to modify directly. - * - * @example - * requestBodies: { - * CreateUserRequest: (requestBody) => { - * requestBody.required = true; - * } - * } - */ - requestBodies?: Record< - string, - (requestBody: OpenApiRequestBodyObject.V3_0_X | OpenApiRequestBodyObject.V3_1_X) => void - >; - /** - * Patch OpenAPI responses in place. The key is the response name, and the function receives the response object to modify directly. - * - * @example - * responses: { - * NotFound: (response) => { - * response.description = 'Resource not found.'; - * } - * } - */ - responses?: Record< - string, - (response: OpenApiResponseObject.V3_0_X | OpenApiResponseObject.V3_1_X) => void - >; - /** - * Each function receives the schema object to be modified in place. Common - * use cases include fixing incorrect data types, removing unwanted - * properties, adding missing fields, or standardizing date/time formats. - * - * @example - * ```js - * schemas: { - * Foo: (schema) => { - * // convert date-time format to timestamp - * delete schema.properties.updatedAt.format; - * schema.properties.updatedAt.type = 'number'; - * }, - * Bar: (schema) => { - * // add missing property - * schema.properties.metadata = { - * additionalProperties: true, - * type: 'object', - * }; - * schema.required = ['metadata']; - * }, - * Baz: (schema) => { - * // remove property - * delete schema.properties.internalField; - * } - * } - * ``` - */ - schemas?: Record< - string, - ( - schema: OpenApiSchemaObject.V2_0_X | OpenApiSchemaObject.V3_0_X | OpenApiSchemaObject.V3_1_X, - ) => void - >; - /** - * Patch the OpenAPI version string. The function receives the current version and should return the new version string. - * Useful for normalizing or overriding the version value before further processing. - * - * @param version The current OpenAPI version string. - * @returns The new version string to use. - * - * @example - * version: (version) => version.replace(/^v/, '') - */ - version?: MaybeFunc<(version: string) => string>; -}; +export type Patch = + | ((spec: OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X) => void) + | { + /** + * Patch the raw OpenAPI spec object in place. Called before all other + * patch callbacks. Useful for bulk/structural transformations such as + * adding new component definitions or modifying many operations at once. + * + * @param spec The OpenAPI spec object for the current version. + * + * @example + * ```ts + * input: (spec) => { + * // Create new component parameters + * if (!spec.components) spec.components = {}; + * if (!spec.components.parameters) spec.components.parameters = {}; + * spec.components.parameters.MyParam = { + * in: 'query', + * name: 'myParam', + * schema: { type: 'string' } + * }; + * + * // Inject parameters into operations + * for (const [path, pathItem] of Object.entries(spec.paths ?? {})) { + * if (pathItem?.get) { + * if (!Array.isArray(pathItem.get.parameters)) { + * pathItem.get.parameters = []; + * } + * pathItem.get.parameters.push({ + * $ref: '#/components/parameters/MyParam' + * }); + * } + * } + * } + * ``` + */ + input?: (spec: OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X) => void; + /** + * Patch the OpenAPI meta object in place. Useful for modifying general metadata such as title, description, version, or custom fields before further processing. + * + * @param meta The OpenAPI meta object for the current version. + */ + meta?: ( + meta: OpenApiMetaObject.V2_0_X | OpenApiMetaObject.V3_0_X | OpenApiMetaObject.V3_1_X, + ) => void; + /** + * Patch OpenAPI operations in place. The key is the operation method and operation path, and the function receives the operation object to modify directly. + * + * @example + * operations: { + * 'GET /foo': (operation) => { + * operation.responses['200'].description = 'foo'; + * } + * } + */ + operations?: Record< + string, + ( + operation: + | OpenApiOperationObject.V2_0_X + | OpenApiOperationObject.V3_0_X + | OpenApiOperationObject.V3_1_X, + ) => void + >; + /** + * Patch OpenAPI parameters in place. The key is the parameter name, and the function receives the parameter object to modify directly. + * + * @example + * parameters: { + * limit: (parameter) => { + * parameter.schema.type = 'integer'; + * } + * } + */ + parameters?: Record< + string, + (parameter: OpenApiParameterObject.V3_0_X | OpenApiParameterObject.V3_1_X) => void + >; + /** + * Patch OpenAPI request bodies in place. The key is the request body name, and the function receives the request body object to modify directly. + * + * @example + * requestBodies: { + * CreateUserRequest: (requestBody) => { + * requestBody.required = true; + * } + * } + */ + requestBodies?: Record< + string, + (requestBody: OpenApiRequestBodyObject.V3_0_X | OpenApiRequestBodyObject.V3_1_X) => void + >; + /** + * Patch OpenAPI responses in place. The key is the response name, and the function receives the response object to modify directly. + * + * @example + * responses: { + * NotFound: (response) => { + * response.description = 'Resource not found.'; + * } + * } + */ + responses?: Record< + string, + (response: OpenApiResponseObject.V3_0_X | OpenApiResponseObject.V3_1_X) => void + >; + /** + * Each function receives the schema object to be modified in place. Common + * use cases include fixing incorrect data types, removing unwanted + * properties, adding missing fields, or standardizing date/time formats. + * + * @example + * ```js + * schemas: { + * Foo: (schema) => { + * // convert date-time format to timestamp + * delete schema.properties.updatedAt.format; + * schema.properties.updatedAt.type = 'number'; + * }, + * Bar: (schema) => { + * // add missing property + * schema.properties.metadata = { + * additionalProperties: true, + * type: 'object', + * }; + * schema.required = ['metadata']; + * }, + * Baz: (schema) => { + * // remove property + * delete schema.properties.internalField; + * } + * } + * ``` + */ + schemas?: Record< + string, + ( + schema: + | OpenApiSchemaObject.V2_0_X + | OpenApiSchemaObject.V3_0_X + | OpenApiSchemaObject.V3_1_X, + ) => void + >; + /** + * Patch the OpenAPI version string. The function receives the current version and should return the new version string. + * Useful for normalizing or overriding the version value before further processing. + * + * @param version The current OpenAPI version string. + * @returns The new version string to use. + * + * @example + * version: (version) => version.replace(/^v/, '') + */ + version?: MaybeFunc<(version: string) => string>; + }; diff --git a/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts b/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts index 18a6af955..48b8256ba 100644 --- a/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts +++ b/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts @@ -19,6 +19,326 @@ const specMetadataV3: Pick = { }; describe('patchOpenApiSpec', () => { + describe('patch.input', () => { + describe('OpenAPI v3', () => { + it('calls patch.input function before other patches', () => { + const inputFn = vi.fn(); + const metaFn = vi.fn(); + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + }; + + patchOpenApiSpec({ + patchOptions: { + input: inputFn, + meta: metaFn, + }, + spec, + }); + + // Both should be called + expect(inputFn).toHaveBeenCalledOnce(); + expect(inputFn).toHaveBeenCalledWith(spec); + expect(metaFn).toHaveBeenCalledOnce(); + }); + + it('allows bulk creation of component parameters', () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + paths: {}, + }; + + patchOpenApiSpec({ + patchOptions: { + input: (spec) => { + if ('openapi' in spec) { + if (!spec.components) spec.components = {}; + if (!spec.components.parameters) spec.components.parameters = {}; + spec.components.parameters.MyParam = { + in: 'query', + name: 'myParam', + schema: { type: 'string' }, + } as any; + } + }, + }, + spec, + }); + + expect(spec.components?.parameters?.MyParam).toEqual({ + in: 'query', + name: 'myParam', + schema: { type: 'string' }, + }); + }); + + it('allows injecting parameters into multiple operations', () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + components: { + parameters: { + SharedParam: { + in: 'query', + name: 'shared', + schema: { type: 'string' }, + } as any, + }, + }, + paths: { + '/bar': { + get: { + responses: {}, + }, + }, + '/baz': { + post: { + responses: {}, + }, + }, + '/foo': { + get: { + responses: {}, + }, + }, + } as any, + }; + + patchOpenApiSpec({ + patchOptions: { + input: (spec) => { + // Inject parameter into all GET operations + for (const [, pathItem] of Object.entries(spec.paths ?? {})) { + if (pathItem?.get) { + if (!Array.isArray(pathItem.get.parameters)) { + pathItem.get.parameters = []; + } + (pathItem.get.parameters as any[]).push({ + $ref: '#/components/parameters/SharedParam', + }); + } + } + }, + }, + spec, + }); + + expect((spec.paths as any)['/foo'].get.parameters).toEqual([ + { $ref: '#/components/parameters/SharedParam' }, + ]); + expect((spec.paths as any)['/bar'].get.parameters).toEqual([ + { $ref: '#/components/parameters/SharedParam' }, + ]); + expect((spec.paths as any)['/baz'].post.parameters).toBeUndefined(); + }); + + it('allows complex Redfish-like transformations', () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + paths: { + '/other/path': { + get: { + responses: {}, + }, + }, + '/redfish/v1/Chassis': { + get: { + responses: {}, + }, + }, + '/redfish/v1/Systems': { + get: { + responses: {}, + }, + }, + } as any, + }; + + const QUERY_PARAMS = [ + { description: 'Expand related resources.', key: '$expand' }, + { description: 'Select subset.', key: '$select' }, + ]; + + patchOpenApiSpec({ + patchOptions: { + input: (spec) => { + if (!('openapi' in spec)) return; + + // 1. Create component parameters + if (!spec.components) spec.components = {}; + if (!spec.components.parameters) spec.components.parameters = {}; + + for (const param of QUERY_PARAMS) { + (spec.components.parameters as any)[`Redfish_${param.key}`] = { + description: param.description, + in: 'query', + name: param.key, + required: false, + schema: { type: 'string' }, + }; + } + + // 2. Inject into Redfish paths + for (const [path, pathItem] of Object.entries(spec.paths ?? {})) { + if (!path.startsWith('/redfish/v1')) continue; + const getOp = pathItem?.get; + if (!getOp) continue; + + if (!Array.isArray(getOp.parameters)) getOp.parameters = []; + for (const param of QUERY_PARAMS) { + (getOp.parameters as any[]).push({ + $ref: `#/components/parameters/Redfish_${param.key}`, + }); + } + } + }, + }, + spec, + }); + + // Verify component parameters were created + expect(spec.components?.parameters).toHaveProperty('Redfish_$expand'); + expect(spec.components?.parameters).toHaveProperty('Redfish_$select'); + + // Verify they were injected into Redfish paths + expect((spec.paths as any)['/redfish/v1/Systems'].get.parameters).toHaveLength(2); + expect((spec.paths as any)['/redfish/v1/Chassis'].get.parameters).toHaveLength(2); + + // Verify they were NOT injected into non-Redfish paths + expect((spec.paths as any)['/other/path'].get.parameters).toBeUndefined(); + }); + }); + + describe('OpenAPI v2', () => { + it('calls patch.input function for v2 specs', () => { + const inputFn = vi.fn(); + const spec: OpenApi.V2_0_X = { + ...specMetadataV2, + }; + + patchOpenApiSpec({ + patchOptions: { + input: inputFn, + }, + spec, + }); + + expect(inputFn).toHaveBeenCalledOnce(); + expect(inputFn).toHaveBeenCalledWith(spec); + }); + + it('allows adding definitions in v2 specs', () => { + const spec: OpenApi.V2_0_X = { + ...specMetadataV2, + }; + + patchOpenApiSpec({ + patchOptions: { + input: (spec) => { + if ('swagger' in spec) { + if (!spec.definitions) spec.definitions = {}; + spec.definitions.NewSchema = { + properties: { + id: { type: 'string' }, + }, + type: 'object', + } as any; + } + }, + }, + spec, + }); + + expect(spec.definitions?.NewSchema).toEqual({ + properties: { + id: { type: 'string' }, + }, + type: 'object', + }); + }); + }); + }); + + describe('shorthand patch function', () => { + describe('OpenAPI v3', () => { + it('calls shorthand patch function', () => { + const patchFn = vi.fn(); + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + }; + + patchOpenApiSpec({ + patchOptions: patchFn, + spec, + }); + + expect(patchFn).toHaveBeenCalledOnce(); + expect(patchFn).toHaveBeenCalledWith(spec); + }); + + it('allows modifications through shorthand function', () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + }; + + patchOpenApiSpec({ + patchOptions: (spec) => { + spec.info.title = 'Modified Title'; + }, + spec, + }); + + expect(spec.info.title).toBe('Modified Title'); + }); + + it('shorthand function prevents other patches from running', () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + }; + + patchOpenApiSpec({ + patchOptions: (spec) => { + spec.info.title = 'Shorthand Title'; + }, + spec, + }); + + expect(spec.info.title).toBe('Shorthand Title'); + }); + }); + + describe('OpenAPI v2', () => { + it('calls shorthand patch function for v2 specs', () => { + const patchFn = vi.fn(); + const spec: OpenApi.V2_0_X = { + ...specMetadataV2, + }; + + patchOpenApiSpec({ + patchOptions: patchFn, + spec, + }); + + expect(patchFn).toHaveBeenCalledOnce(); + expect(patchFn).toHaveBeenCalledWith(spec); + }); + + it('allows modifications through shorthand function in v2', () => { + const spec: OpenApi.V2_0_X = { + ...specMetadataV2, + }; + + patchOpenApiSpec({ + patchOptions: (spec) => { + spec.info.title = 'Modified V2 Title'; + }, + spec, + }); + + expect(spec.info.title).toBe('Modified V2 Title'); + }); + }); + }); + describe('edge cases', () => { it('does not modify spec', () => { const spec: OpenApi.V3_1_X = { diff --git a/packages/shared/src/openApi/shared/utils/patch.ts b/packages/shared/src/openApi/shared/utils/patch.ts index 08d181122..30f7be5ec 100644 --- a/packages/shared/src/openApi/shared/utils/patch.ts +++ b/packages/shared/src/openApi/shared/utils/patch.ts @@ -14,6 +14,17 @@ export function patchOpenApiSpec({ const spec = _spec as OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X; + // Handle shorthand function syntax: patch: (spec) => { ... } + if (typeof patchOptions === 'function') { + patchOptions(spec); + return; + } + + // Handle patch.input callback + if (patchOptions.input) { + patchOptions.input(spec); + } + if ('swagger' in spec) { if (patchOptions.version && spec.swagger) { spec.swagger = ( -- 2.51.2 From 01caf4adfb2a8942caf6db807bc8151f8761a4fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:50:50 +0000 Subject: [PATCH 3/6] Improve test coverage and remove comments per code review Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com> --- .../openApi/shared/utils/__tests__/patch.test.ts | 15 +++++++++++++-- packages/shared/src/openApi/shared/utils/patch.ts | 2 -- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts b/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts index 48b8256ba..b1230240e 100644 --- a/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts +++ b/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts @@ -291,18 +291,29 @@ describe('patchOpenApiSpec', () => { }); it('shorthand function prevents other patches from running', () => { + const metaFn = vi.fn(); + const schemasFn = vi.fn(); const spec: OpenApi.V3_1_X = { ...specMetadataV3, + components: { + schemas: { + Foo: { + type: 'string', + }, + }, + }, }; patchOpenApiSpec({ - patchOptions: (spec) => { + patchOptions: ((spec) => { spec.info.title = 'Shorthand Title'; - }, + }) as any, spec, }); expect(spec.info.title).toBe('Shorthand Title'); + expect(metaFn).not.toHaveBeenCalled(); + expect(schemasFn).not.toHaveBeenCalled(); }); }); diff --git a/packages/shared/src/openApi/shared/utils/patch.ts b/packages/shared/src/openApi/shared/utils/patch.ts index 30f7be5ec..c7f449c29 100644 --- a/packages/shared/src/openApi/shared/utils/patch.ts +++ b/packages/shared/src/openApi/shared/utils/patch.ts @@ -14,13 +14,11 @@ export function patchOpenApiSpec({ const spec = _spec as OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X; - // Handle shorthand function syntax: patch: (spec) => { ... } if (typeof patchOptions === 'function') { patchOptions(spec); return; } - // Handle patch.input callback if (patchOptions.input) { patchOptions.input(spec); } -- 2.51.2 From 243ea1fc932f559b34ef5e9524121661624ffd5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:52:59 +0000 Subject: [PATCH 4/6] Improve shorthand patch test to better demonstrate behavior Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com> --- .../openApi/shared/utils/__tests__/patch.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts b/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts index b1230240e..549232fd3 100644 --- a/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts +++ b/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts @@ -290,9 +290,7 @@ describe('patchOpenApiSpec', () => { expect(spec.info.title).toBe('Modified Title'); }); - it('shorthand function prevents other patches from running', () => { - const metaFn = vi.fn(); - const schemasFn = vi.fn(); + it('shorthand function replaces object-based patch configuration', () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -304,16 +302,19 @@ describe('patchOpenApiSpec', () => { }, }; + // When using shorthand syntax, only the function is called + // Object properties like meta or schemas would be ignored patchOpenApiSpec({ - patchOptions: ((spec) => { + patchOptions: (spec) => { spec.info.title = 'Shorthand Title'; - }) as any, + // This is the only code that runs + }, spec, }); expect(spec.info.title).toBe('Shorthand Title'); - expect(metaFn).not.toHaveBeenCalled(); - expect(schemasFn).not.toHaveBeenCalled(); + // Schemas remain untouched since no schema patch was applied + expect(spec.components?.schemas?.Foo).toEqual({ type: 'string' }); }); }); -- 2.51.2 From 7482afb4dd151f2f43cfb325a65fc9c026f4059c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:02:08 +0000 Subject: [PATCH 5/6] Add async support for patch.input and patch() shorthand Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com> --- packages/openapi-python/src/createClient.ts | 2 +- packages/openapi-ts/src/createClient.ts | 2 +- packages/shared/src/config/parser/patch.ts | 8 +- .../shared/utils/__tests__/patch.test.ts | 236 +++++++++++++----- .../shared/src/openApi/shared/utils/patch.ts | 6 +- 5 files changed, 185 insertions(+), 69 deletions(-) diff --git a/packages/openapi-python/src/createClient.ts b/packages/openapi-python/src/createClient.ts index 24c8f59ca..363204308 100644 --- a/packages/openapi-python/src/createClient.ts +++ b/packages/openapi-python/src/createClient.ts @@ -100,7 +100,7 @@ export async function createClient({ } const eventInputPatch = logger.timeEvent('input.patch'); - patchOpenApiSpec({ patchOptions: config.parser.patch, spec: data }); + await patchOpenApiSpec({ patchOptions: config.parser.patch, spec: data }); eventInputPatch.timeEnd(); const eventParser = logger.timeEvent('parser'); diff --git a/packages/openapi-ts/src/createClient.ts b/packages/openapi-ts/src/createClient.ts index 4f79cbfcf..7a948e2db 100644 --- a/packages/openapi-ts/src/createClient.ts +++ b/packages/openapi-ts/src/createClient.ts @@ -100,7 +100,7 @@ export async function createClient({ } const eventInputPatch = logger.timeEvent('input.patch'); - patchOpenApiSpec({ patchOptions: config.parser.patch, spec: data }); + await patchOpenApiSpec({ patchOptions: config.parser.patch, spec: data }); eventInputPatch.timeEnd(); const eventParser = logger.timeEvent('parser'); diff --git a/packages/shared/src/config/parser/patch.ts b/packages/shared/src/config/parser/patch.ts index 4272f4f73..e11bb0713 100644 --- a/packages/shared/src/config/parser/patch.ts +++ b/packages/shared/src/config/parser/patch.ts @@ -10,8 +10,12 @@ import type { OpenApiSchemaObject, } from '../../openApi/types'; +export type PatchInputFn = ( + spec: OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X, +) => void | Promise; + export type Patch = - | ((spec: OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X) => void) + | PatchInputFn | { /** * Patch the raw OpenAPI spec object in place. Called before all other @@ -46,7 +50,7 @@ export type Patch = * } * ``` */ - input?: (spec: OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X) => void; + input?: PatchInputFn; /** * Patch the OpenAPI meta object in place. Useful for modifying general metadata such as title, description, version, or custom fields before further processing. * diff --git a/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts b/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts index 549232fd3..c9dfbe850 100644 --- a/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts +++ b/packages/shared/src/openApi/shared/utils/__tests__/patch.test.ts @@ -21,14 +21,14 @@ const specMetadataV3: Pick = { describe('patchOpenApiSpec', () => { describe('patch.input', () => { describe('OpenAPI v3', () => { - it('calls patch.input function before other patches', () => { + it('calls patch.input function before other patches', async () => { const inputFn = vi.fn(); const metaFn = vi.fn(); const spec: OpenApi.V3_1_X = { ...specMetadataV3, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { input: inputFn, meta: metaFn, @@ -42,13 +42,13 @@ describe('patchOpenApiSpec', () => { expect(metaFn).toHaveBeenCalledOnce(); }); - it('allows bulk creation of component parameters', () => { + it('allows bulk creation of component parameters', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, paths: {}, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { input: (spec) => { if ('openapi' in spec) { @@ -72,7 +72,7 @@ describe('patchOpenApiSpec', () => { }); }); - it('allows injecting parameters into multiple operations', () => { + it('allows injecting parameters into multiple operations', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -103,7 +103,7 @@ describe('patchOpenApiSpec', () => { } as any, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { input: (spec) => { // Inject parameter into all GET operations @@ -131,7 +131,7 @@ describe('patchOpenApiSpec', () => { expect((spec.paths as any)['/baz'].post.parameters).toBeUndefined(); }); - it('allows complex Redfish-like transformations', () => { + it('allows complex Redfish-like transformations', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, paths: { @@ -158,7 +158,7 @@ describe('patchOpenApiSpec', () => { { description: 'Select subset.', key: '$select' }, ]; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { input: (spec) => { if (!('openapi' in spec)) return; @@ -209,13 +209,13 @@ describe('patchOpenApiSpec', () => { }); describe('OpenAPI v2', () => { - it('calls patch.input function for v2 specs', () => { + it('calls patch.input function for v2 specs', async () => { const inputFn = vi.fn(); const spec: OpenApi.V2_0_X = { ...specMetadataV2, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { input: inputFn, }, @@ -226,12 +226,12 @@ describe('patchOpenApiSpec', () => { expect(inputFn).toHaveBeenCalledWith(spec); }); - it('allows adding definitions in v2 specs', () => { + it('allows adding definitions in v2 specs', async () => { const spec: OpenApi.V2_0_X = { ...specMetadataV2, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { input: (spec) => { if ('swagger' in spec) { @@ -258,15 +258,127 @@ describe('patchOpenApiSpec', () => { }); }); + describe('async patch support', () => { + describe('patch.input async', () => { + it('supports async patch.input function', async () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + }; + + let asyncExecuted = false; + + await patchOpenApiSpec({ + patchOptions: { + input: async (spec) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + spec.info.title = 'Async Modified'; + asyncExecuted = true; + }, + }, + spec, + }); + + expect(asyncExecuted).toBe(true); + expect(spec.info.title).toBe('Async Modified'); + }); + + it('supports async operations in patch.input', async () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + paths: {}, + }; + + await patchOpenApiSpec({ + patchOptions: { + input: async (spec) => { + // Simulate async operation like fetching data + await new Promise((resolve) => setTimeout(resolve, 5)); + if ('openapi' in spec) { + if (!spec.components) spec.components = {}; + if (!spec.components.parameters) spec.components.parameters = {}; + spec.components.parameters.AsyncParam = { + in: 'query', + name: 'asyncParam', + schema: { type: 'string' }, + } as any; + } + }, + }, + spec, + }); + + expect(spec.components?.parameters?.AsyncParam).toEqual({ + in: 'query', + name: 'asyncParam', + schema: { type: 'string' }, + }); + }); + }); + + describe('shorthand async', () => { + it('supports async shorthand patch function', async () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + }; + + let asyncExecuted = false; + + await patchOpenApiSpec({ + patchOptions: async (spec) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + spec.info.title = 'Async Shorthand Modified'; + asyncExecuted = true; + }, + spec, + }); + + expect(asyncExecuted).toBe(true); + expect(spec.info.title).toBe('Async Shorthand Modified'); + }); + + it('supports async operations in shorthand function', async () => { + const spec: OpenApi.V3_1_X = { + ...specMetadataV3, + components: { + schemas: { + Foo: { + type: 'string', + }, + }, + }, + }; + + await patchOpenApiSpec({ + patchOptions: async (spec) => { + // Simulate async operation + await new Promise((resolve) => setTimeout(resolve, 5)); + spec.info.description = 'Added via async shorthand'; + if ('openapi' in spec && spec.components?.schemas) { + (spec.components.schemas as any).Bar = { + type: 'number', + }; + } + }, + spec, + }); + + expect(spec.info.description).toBe('Added via async shorthand'); + expect((spec.components?.schemas as any)?.Bar).toEqual({ + type: 'number', + }); + }); + }); + }); + describe('shorthand patch function', () => { describe('OpenAPI v3', () => { - it('calls shorthand patch function', () => { + it('calls shorthand patch function', async () => { const patchFn = vi.fn(); const spec: OpenApi.V3_1_X = { ...specMetadataV3, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: patchFn, spec, }); @@ -275,12 +387,12 @@ describe('patchOpenApiSpec', () => { expect(patchFn).toHaveBeenCalledWith(spec); }); - it('allows modifications through shorthand function', () => { + it('allows modifications through shorthand function', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: (spec) => { spec.info.title = 'Modified Title'; }, @@ -290,7 +402,7 @@ describe('patchOpenApiSpec', () => { expect(spec.info.title).toBe('Modified Title'); }); - it('shorthand function replaces object-based patch configuration', () => { + it('shorthand function replaces object-based patch configuration', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -304,7 +416,7 @@ describe('patchOpenApiSpec', () => { // When using shorthand syntax, only the function is called // Object properties like meta or schemas would be ignored - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: (spec) => { spec.info.title = 'Shorthand Title'; // This is the only code that runs @@ -319,13 +431,13 @@ describe('patchOpenApiSpec', () => { }); describe('OpenAPI v2', () => { - it('calls shorthand patch function for v2 specs', () => { + it('calls shorthand patch function for v2 specs', async () => { const patchFn = vi.fn(); const spec: OpenApi.V2_0_X = { ...specMetadataV2, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: patchFn, spec, }); @@ -334,12 +446,12 @@ describe('patchOpenApiSpec', () => { expect(patchFn).toHaveBeenCalledWith(spec); }); - it('allows modifications through shorthand function in v2', () => { + it('allows modifications through shorthand function in v2', async () => { const spec: OpenApi.V2_0_X = { ...specMetadataV2, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: (spec) => { spec.info.title = 'Modified V2 Title'; }, @@ -352,7 +464,7 @@ describe('patchOpenApiSpec', () => { }); describe('edge cases', () => { - it('does not modify spec', () => { + it('does not modify spec', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -364,7 +476,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: undefined, spec, }); @@ -381,7 +493,7 @@ describe('patchOpenApiSpec', () => { }); }); - it('does not modify spec', () => { + it('does not modify spec', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -393,7 +505,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: {}, spec, }); @@ -412,7 +524,7 @@ describe('patchOpenApiSpec', () => { }); describe('OpenAPI v3', () => { - it('calls patch function', () => { + it('calls patch function', async () => { const fnBar = vi.fn(); const fnFoo = vi.fn(); @@ -430,7 +542,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { schemas: { Bar: fnBar, @@ -450,7 +562,7 @@ describe('patchOpenApiSpec', () => { }); }); - it('patch function mutates spec', () => { + it('patch function mutates spec', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -494,7 +606,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { parameters: { Foo: (schema) => { @@ -588,14 +700,14 @@ describe('patchOpenApiSpec', () => { }); }); - it('handles spec without components', () => { + it('handles spec without components', async () => { const fn = vi.fn(); const spec: OpenApi.V3_1_X = { ...specMetadataV3, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { parameters: { Foo: fn, @@ -616,7 +728,7 @@ describe('patchOpenApiSpec', () => { expect(fn).not.toHaveBeenCalled(); }); - it('handles spec without component namespaces', () => { + it('handles spec without component namespaces', async () => { const fn = vi.fn(); const spec: OpenApi.V3_1_X = { @@ -624,7 +736,7 @@ describe('patchOpenApiSpec', () => { components: {}, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { parameters: { Foo: fn, @@ -645,7 +757,7 @@ describe('patchOpenApiSpec', () => { expect(fn).not.toHaveBeenCalled(); }); - it('handles spec without matching components', () => { + it('handles spec without matching components', async () => { const fn = vi.fn(); const spec: OpenApi.V3_1_X = { @@ -658,7 +770,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { parameters: { Foo: fn, @@ -679,7 +791,7 @@ describe('patchOpenApiSpec', () => { expect(fn).not.toHaveBeenCalled(); }); - it('skips invalid schemas', () => { + it('skips invalid schemas', async () => { const fn = vi.fn(); const spec: OpenApi.V3_1_X = { @@ -696,7 +808,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { schemas: { Bar: fn, @@ -714,14 +826,14 @@ describe('patchOpenApiSpec', () => { }); }); - it('applies meta patch function', () => { + it('applies meta patch function', async () => { const metaFn = vi.fn((meta) => { meta.title = 'Changed Title'; }); const spec: OpenApi.V3_1_X = { ...specMetadataV3, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { meta: metaFn, }, @@ -731,12 +843,12 @@ describe('patchOpenApiSpec', () => { expect(spec.info.title).toBe('Changed Title'); }); - it('applies version patch function', () => { + it('applies version patch function', async () => { const versionFn = vi.fn((version) => `patched-${version}`); const spec: OpenApi.V3_1_X = { ...specMetadataV3, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { version: versionFn, }, @@ -748,7 +860,7 @@ describe('patchOpenApiSpec', () => { }); describe('OpenAPI v2', () => { - it('calls patch function', () => { + it('calls patch function', async () => { const fnBar = vi.fn(); const fnFoo = vi.fn(); @@ -764,7 +876,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { schemas: { Bar: fnBar, @@ -784,7 +896,7 @@ describe('patchOpenApiSpec', () => { }); }); - it('patch function mutates schema', () => { + it('patch function mutates schema', async () => { const spec: OpenApi.V2_0_X = { ...specMetadataV2, definitions: { @@ -794,7 +906,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { schemas: { Foo: (schema) => { @@ -815,14 +927,14 @@ describe('patchOpenApiSpec', () => { }); }); - it('handles spec without definitions', () => { + it('handles spec without definitions', async () => { const fn = vi.fn(); const spec: OpenApi.V2_0_X = { ...specMetadataV2, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { parameters: { Foo: fn, @@ -843,7 +955,7 @@ describe('patchOpenApiSpec', () => { expect(fn).not.toHaveBeenCalled(); }); - it('handles spec without matching definitions', () => { + it('handles spec without matching definitions', async () => { const fn = vi.fn(); const spec: OpenApi.V2_0_X = { @@ -851,7 +963,7 @@ describe('patchOpenApiSpec', () => { definitions: {}, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { parameters: { Foo: fn, @@ -872,7 +984,7 @@ describe('patchOpenApiSpec', () => { expect(fn).not.toHaveBeenCalled(); }); - it('skips invalid schemas', () => { + it('skips invalid schemas', async () => { const fn = vi.fn(); const spec: OpenApi.V2_0_X = { @@ -887,7 +999,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { schemas: { Bar: fn, @@ -905,14 +1017,14 @@ describe('patchOpenApiSpec', () => { }); }); - it('applies meta patch function', () => { + it('applies meta patch function', async () => { const metaFn = vi.fn((meta) => { meta.title = 'Changed Title'; }); const spec: OpenApi.V2_0_X = { ...specMetadataV2, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { meta: metaFn, }, @@ -922,12 +1034,12 @@ describe('patchOpenApiSpec', () => { expect(spec.info.title).toBe('Changed Title'); }); - it('applies version patch function', () => { + it('applies version patch function', async () => { const versionFn = vi.fn((version) => `patched-${version}`); const spec: OpenApi.V2_0_X = { ...specMetadataV2, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { version: versionFn, }, @@ -939,7 +1051,7 @@ describe('patchOpenApiSpec', () => { }); describe('real-world usage', () => { - it('handles complex schema example from docs', () => { + it('handles complex schema example from docs', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -959,7 +1071,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { schemas: { Foo: (schema: any) => { @@ -992,7 +1104,7 @@ describe('patchOpenApiSpec', () => { }); }); - it('handles adding new schema properties', () => { + it('handles adding new schema properties', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -1007,7 +1119,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { schemas: { Foo: (schema: any) => { @@ -1042,7 +1154,7 @@ describe('patchOpenApiSpec', () => { }); }); - it('handles removing schema properties', () => { + it('handles removing schema properties', async () => { const spec: OpenApi.V3_1_X = { ...specMetadataV3, components: { @@ -1059,7 +1171,7 @@ describe('patchOpenApiSpec', () => { }, }; - patchOpenApiSpec({ + await patchOpenApiSpec({ patchOptions: { schemas: { Foo: (schema: any) => { diff --git a/packages/shared/src/openApi/shared/utils/patch.ts b/packages/shared/src/openApi/shared/utils/patch.ts index c7f449c29..9a209e036 100644 --- a/packages/shared/src/openApi/shared/utils/patch.ts +++ b/packages/shared/src/openApi/shared/utils/patch.ts @@ -1,7 +1,7 @@ import type { Patch } from '../../../config/parser/patch'; import type { OpenApi } from '../../../openApi/types'; -export function patchOpenApiSpec({ +export async function patchOpenApiSpec({ patchOptions, spec: _spec, }: { @@ -15,12 +15,12 @@ export function patchOpenApiSpec({ const spec = _spec as OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X; if (typeof patchOptions === 'function') { - patchOptions(spec); + await patchOptions(spec); return; } if (patchOptions.input) { - patchOptions.input(spec); + await patchOptions.input(spec); } if ('swagger' in spec) { -- 2.51.2 From ba9f893d71b959721e177717ae85fce34d697002 Mon Sep 17 00:00:00 2001 From: Lubos Date: Tue, 17 Feb 2026 23:17:45 +0200 Subject: [PATCH 6/6] Update heavy-hounds-listen.md with new parser options Add `patch.input` and shorthand `patch()` option for full specification transformations --- .changeset/heavy-hounds-listen.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/heavy-hounds-listen.md diff --git a/.changeset/heavy-hounds-listen.md b/.changeset/heavy-hounds-listen.md new file mode 100644 index 000000000..345abd733 --- /dev/null +++ b/.changeset/heavy-hounds-listen.md @@ -0,0 +1,6 @@ +--- +"@hey-api/openapi-ts": patch +"@hey-api/shared": patch +--- + +**parser**: add `patch.input` and shorthand `patch()` option for full specification transformations