From 15d50064209a6f033dd559250963e4b4c7750a26 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Tue, 4 Nov 2025 16:41:27 -0500 Subject: [PATCH 01/12] feat: Add tags and transformer to api --- opapi/package.json | 2 +- opapi/src/generator.ts | 19 ++++++++++++++----- opapi/src/opapi.ts | 8 ++++++-- opapi/src/openapi.ts | 15 ++++++++++++++- opapi/src/state.ts | 4 ++++ 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/opapi/package.json b/opapi/package.json index 45220b23f..1f82fca0a 100644 --- a/opapi/package.json +++ b/opapi/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/opapi", - "version": "0.16.1", + "version": "0.17.0", "description": "Opapi is a highly opinionated library to generate server, client and documentation from OpenAPI specification using typescript.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/opapi/src/generator.ts b/opapi/src/generator.ts index 839d6c342..c89ac7ec8 100644 --- a/opapi/src/generator.ts +++ b/opapi/src/generator.ts @@ -19,7 +19,7 @@ import { generateOpenapiTypescript } from './generators/openapi-typescript' import { schemaIsEmptyObject } from './jsonschema' import log from './log' import type { OpenApiPostProcessors } from './opapi' -import { createOpenapi } from './openapi' +import { createOpenapi, OpenApiOperationTransformer } from './openapi' import { DefaultState, composeFilesFromBlocks, @@ -50,11 +50,16 @@ export async function generateTypesBySection(state: DefaultState, targetDirector generateSectionsFile(state, targetDirectory) } -export const generateServer = async (state: State, dir: string, useExpressTypes: boolean) => { +export const generateServer = async ( + state: State, + dir: string, + useExpressTypes: boolean, + openapiOperationTransformer?: OpenApiOperationTransformer, +) => { initDirectory(dir) log.info('Generating OpenAPI content') - const openapi = createOpenapi(state) + const openapi = createOpenapi(state, openapiOperationTransformer) const openapiSpecString = openapi.getSpecAsJson() const openapiSpec = JSON.parse(openapiSpecString) @@ -201,11 +206,15 @@ export function generateErrorsFile(errors: ApiError[], dir = '.') { log.info('') } -export function generateOpenapi(state: State, dir = '.') { +export function generateOpenapi( + state: State, + dir = '.', + openapiOperationTransformer?: OpenApiOperationTransformer, +) { initDirectory(dir) log.info('Generating openapi content') - const openapi = createOpenapi(state) + const openapi = createOpenapi(state, openapiOperationTransformer) const openapiSpecString = openapi.getSpecAsJson() log.info('Generating metadata content') diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 0e894144d..4ef0d3ea2 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -22,6 +22,8 @@ import { } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' import { generateHandler } from './handler-generator' +import { OpenApiOperationTransformer } from './openapi' +export { OperationObject } from './openapi' export { Operation, Parameter } from './state' type AnatineSchemaObject = NonNullable[1]> @@ -112,8 +114,10 @@ const createOpapiFromState = < ) => addOperation(state, operationProps), exportClient: exportClient(state), exportTypesBySection: (dir = '.') => generateTypesBySection(state, dir), - exportServer: (dir = '.', useExpressTypes: boolean) => generateServer(state, dir, useExpressTypes), - exportOpenapi: (dir = '.') => generateOpenapi(state, dir), + exportServer: (dir = '.', useExpressTypes: boolean, transformer?: OpenApiOperationTransformer) => + generateServer(state, dir, useExpressTypes, transformer), + exportOpenapi: (dir = '.', transformer?: OpenApiOperationTransformer) => + generateOpenapi(state, dir, transformer), exportState: (dir = '.', opts?: ExportStateAsTypescriptOptions) => exportStateAsTypescript(state, dir, opts), exportErrors: (dir = '.') => generateErrorsFile(state.errors ?? [], dir), exportHandler: (dir = '.') => generateHandler(state, dir), diff --git a/opapi/src/openapi.ts b/opapi/src/openapi.ts index 28e1bb1dc..0242608f9 100644 --- a/opapi/src/openapi.ts +++ b/opapi/src/openapi.ts @@ -3,8 +3,14 @@ import VError from 'verror' import { defaultResponseStatus } from './const' import { generateSchemaFromZod } from './jsonschema' import { objects } from './objects' -import { ComponentType, Security, State, getRef, isOperationWithBodyProps } from './state' +import { ComponentType, Operation, Security, State, getRef, isOperationWithBodyProps } from './state' import { formatBodyName, formatResponseName } from './util' +export type { OperationObject } from 'openapi3-ts' + +export type OpenApiOperationTransformer = ( + opapiOp: Operation, + openapiOp: OperationObject, +) => OperationObject export const createOpenapi = < SchemaName extends string, @@ -12,6 +18,7 @@ export const createOpenapi = < SectionName extends string, >( state: State, + operationTransformer?: OpenApiOperationTransformer, ) => { const { metadata, schemas, operations, security } = state const { description, server, title, version } = metadata @@ -74,6 +81,8 @@ export const createOpenapi = < default: responseRefSchema as ReferenceObject, [response.status ?? defaultResponseStatus]: responseRefSchema as ReferenceObject, }, + tags: operationObject.tags, + deprecated: operationObject.deprecated, } if (isOperationWithBodyProps(operationObject)) { @@ -176,6 +185,10 @@ export const createOpenapi = < }) } + if (operationTransformer) { + operationTransformer(operationObject, operation) + } + if (!openapi.rootDoc.paths) { openapi.rootDoc.paths = {} } diff --git a/opapi/src/state.ts b/opapi/src/state.ts index ce7438b65..a602e5c85 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -222,6 +222,10 @@ type BaseOperationProps< schema: SchemaOfType format?: 'binary' } + // Tags of the operation + tags?: string[] + // If an operation is deprecated + deprecated?: boolean } type CreateStateProps = { From e146d4fc9c9d7904fa817c33987ab6def65b8544 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Wed, 5 Nov 2025 10:28:27 -0500 Subject: [PATCH 02/12] chore: remove transformer --- opapi/src/generator.ts | 19 +++++-------------- opapi/src/opapi.ts | 8 ++------ 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/opapi/src/generator.ts b/opapi/src/generator.ts index c89ac7ec8..839d6c342 100644 --- a/opapi/src/generator.ts +++ b/opapi/src/generator.ts @@ -19,7 +19,7 @@ import { generateOpenapiTypescript } from './generators/openapi-typescript' import { schemaIsEmptyObject } from './jsonschema' import log from './log' import type { OpenApiPostProcessors } from './opapi' -import { createOpenapi, OpenApiOperationTransformer } from './openapi' +import { createOpenapi } from './openapi' import { DefaultState, composeFilesFromBlocks, @@ -50,16 +50,11 @@ export async function generateTypesBySection(state: DefaultState, targetDirector generateSectionsFile(state, targetDirectory) } -export const generateServer = async ( - state: State, - dir: string, - useExpressTypes: boolean, - openapiOperationTransformer?: OpenApiOperationTransformer, -) => { +export const generateServer = async (state: State, dir: string, useExpressTypes: boolean) => { initDirectory(dir) log.info('Generating OpenAPI content') - const openapi = createOpenapi(state, openapiOperationTransformer) + const openapi = createOpenapi(state) const openapiSpecString = openapi.getSpecAsJson() const openapiSpec = JSON.parse(openapiSpecString) @@ -206,15 +201,11 @@ export function generateErrorsFile(errors: ApiError[], dir = '.') { log.info('') } -export function generateOpenapi( - state: State, - dir = '.', - openapiOperationTransformer?: OpenApiOperationTransformer, -) { +export function generateOpenapi(state: State, dir = '.') { initDirectory(dir) log.info('Generating openapi content') - const openapi = createOpenapi(state, openapiOperationTransformer) + const openapi = createOpenapi(state) const openapiSpecString = openapi.getSpecAsJson() log.info('Generating metadata content') diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 4ef0d3ea2..0e894144d 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -22,8 +22,6 @@ import { } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' import { generateHandler } from './handler-generator' -import { OpenApiOperationTransformer } from './openapi' -export { OperationObject } from './openapi' export { Operation, Parameter } from './state' type AnatineSchemaObject = NonNullable[1]> @@ -114,10 +112,8 @@ const createOpapiFromState = < ) => addOperation(state, operationProps), exportClient: exportClient(state), exportTypesBySection: (dir = '.') => generateTypesBySection(state, dir), - exportServer: (dir = '.', useExpressTypes: boolean, transformer?: OpenApiOperationTransformer) => - generateServer(state, dir, useExpressTypes, transformer), - exportOpenapi: (dir = '.', transformer?: OpenApiOperationTransformer) => - generateOpenapi(state, dir, transformer), + exportServer: (dir = '.', useExpressTypes: boolean) => generateServer(state, dir, useExpressTypes), + exportOpenapi: (dir = '.') => generateOpenapi(state, dir), exportState: (dir = '.', opts?: ExportStateAsTypescriptOptions) => exportStateAsTypescript(state, dir, opts), exportErrors: (dir = '.') => generateErrorsFile(state.errors ?? [], dir), exportHandler: (dir = '.') => generateHandler(state, dir), From f5c958d7cb969e5cd79372feff8339a324dbbc31 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Wed, 5 Nov 2025 10:32:51 -0500 Subject: [PATCH 03/12] chore: remove transofmer --- opapi/src/openapi.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/opapi/src/openapi.ts b/opapi/src/openapi.ts index 0242608f9..3ead42d5c 100644 --- a/opapi/src/openapi.ts +++ b/opapi/src/openapi.ts @@ -3,14 +3,8 @@ import VError from 'verror' import { defaultResponseStatus } from './const' import { generateSchemaFromZod } from './jsonschema' import { objects } from './objects' -import { ComponentType, Operation, Security, State, getRef, isOperationWithBodyProps } from './state' +import { ComponentType, Security, State, getRef, isOperationWithBodyProps } from './state' import { formatBodyName, formatResponseName } from './util' -export type { OperationObject } from 'openapi3-ts' - -export type OpenApiOperationTransformer = ( - opapiOp: Operation, - openapiOp: OperationObject, -) => OperationObject export const createOpenapi = < SchemaName extends string, @@ -18,7 +12,6 @@ export const createOpenapi = < SectionName extends string, >( state: State, - operationTransformer?: OpenApiOperationTransformer, ) => { const { metadata, schemas, operations, security } = state const { description, server, title, version } = metadata @@ -185,10 +178,6 @@ export const createOpenapi = < }) } - if (operationTransformer) { - operationTransformer(operationObject, operation) - } - if (!openapi.rootDoc.paths) { openapi.rootDoc.paths = {} } From 6307723558cd0087e2893685b5a8c3b90b5c2d12 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Thu, 6 Nov 2025 11:30:12 -0500 Subject: [PATCH 04/12] feat: Add export state options --- opapi/package.json | 5 +-- opapi/src/opapi.ts | 86 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/opapi/package.json b/opapi/package.json index 1f82fca0a..bfe20e9c5 100644 --- a/opapi/package.json +++ b/opapi/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/opapi", - "version": "0.17.0", + "version": "0.19.0", "description": "Opapi is a highly opinionated library to generate server, client and documentation from OpenAPI specification using typescript.", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -20,7 +20,8 @@ "build": "tsup src/index.ts --dts --format cjs,esm --clean", "check:type": "tsc --noEmit", "check:format": "prettier --check .", - "fix:format": "prettier --write ." + "fix:format": "prettier --write .", + "check": "pnpm run check:type && pnpm run check:format" }, "devDependencies": { "@swc/core": "1.9.3", diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 0e894144d..8dd783285 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -72,25 +72,80 @@ export type GenerateClientProps = generator: 'opapi' } +type ExportStateOptions = Partial<{ + ignoreDefaultParameters: boolean + ignoreSecurity: boolean +}> + +const applyExportOptions = ( + state: State, + options?: ExportStateOptions, +) => { + if (options?.ignoreDefaultParameters && state.defaultParameters) { + const defaultParametersName = Object.keys(state.defaultParameters) + for (const operationId of Object.keys(state.operations)) { + if (!state.operations[operationId]?.parameters) { + continue + } + state.operations[operationId].parameters = Object.fromEntries( + Object.entries(state.operations[operationId].parameters).filter( + ([parameterName]) => !defaultParametersName.includes(parameterName), + ), + ) + } + } + if (options?.ignoreSecurity) { + delete state.security + } + return state +} + +function isOpenApiPostProcessors(input?: OpenApiPostProcessors | ExportStateOptions): input is OpenApiPostProcessors { + return input !== undefined && 'apiCode' in input +} + +function isExportStateOptions(input?: OpenApiPostProcessors | ExportStateOptions): input is ExportStateOptions { + return input !== undefined && ('ignoreDefaultParameters' in input || 'ignoreSecurity' in input) +} + function exportClient(state: State) { + function _exportClient( + dir: string, + props: GenerateClientProps, + stateOpts?: ExportStateOptions, + ): Promise function _exportClient( dir: string, openapiGeneratorEndpoint: string, postProcessors?: OpenApiPostProcessors, + stateOpts?: ExportStateOptions, ): Promise - function _exportClient(dir: string, props: GenerateClientProps): Promise - function _exportClient(dir = '.', props: GenerateClientProps | string, postProcessors?: OpenApiPostProcessors) { - let options: GenerateClientProps - if (typeof props === 'string') { - options = { generator: 'openapi-generator', endpoint: props, postProcessors } + function _exportClient( + dir = '.', + propsOrEndpoint: GenerateClientProps | string, + postProcessorsOrStateOpts?: OpenApiPostProcessors | ExportStateOptions, + stateOpts?: ExportStateOptions, + ) { + let props: GenerateClientProps + if (typeof propsOrEndpoint === 'string') { + if (!postProcessorsOrStateOpts || isOpenApiPostProcessors(postProcessorsOrStateOpts)) { + props = { generator: 'openapi-generator', endpoint: propsOrEndpoint, postProcessors: postProcessorsOrStateOpts } + } else { + throw new Error('This is a bug. `postProcessors` options does not have a valid type') + } } else { - options = props + props = propsOrEndpoint + if (isExportStateOptions(postProcessorsOrStateOpts)) { + stateOpts = postProcessorsOrStateOpts + } } - if (options.generator === 'openapi-generator') { - return generateClientWithOpenapiGenerator(state, dir, options.endpoint, options.postProcessors) + state = applyExportOptions(state, stateOpts) + + if (props.generator === 'openapi-generator') { + return generateClientWithOpenapiGenerator(state, dir, props.endpoint, props.postProcessors) } - if (options.generator === 'opapi') { + if (props.generator === 'opapi') { return generateClientWithOpapi(state, dir) } throw new Error('Unknown generator') @@ -111,12 +166,15 @@ const createOpapiFromState = < operationProps: Operation, ) => addOperation(state, operationProps), exportClient: exportClient(state), - exportTypesBySection: (dir = '.') => generateTypesBySection(state, dir), - exportServer: (dir = '.', useExpressTypes: boolean) => generateServer(state, dir, useExpressTypes), - exportOpenapi: (dir = '.') => generateOpenapi(state, dir), - exportState: (dir = '.', opts?: ExportStateAsTypescriptOptions) => exportStateAsTypescript(state, dir, opts), + exportTypesBySection: (dir = '.', opts?: ExportStateOptions) => + generateTypesBySection(applyExportOptions(state, opts), dir), + exportServer: (dir = '.', useExpressTypes: boolean, opts?: ExportStateOptions) => + generateServer(applyExportOptions(state, opts), dir, useExpressTypes), + exportOpenapi: (dir = '.', opts?: ExportStateOptions) => generateOpenapi(applyExportOptions(state, opts), dir), + exportState: (dir = '.', opts?: ExportStateAsTypescriptOptions & ExportStateOptions) => + exportStateAsTypescript(applyExportOptions(state, opts), dir, opts), exportErrors: (dir = '.') => generateErrorsFile(state.errors ?? [], dir), - exportHandler: (dir = '.') => generateHandler(state, dir), + exportHandler: (dir = '.', opts?: ExportStateOptions) => generateHandler(applyExportOptions(state, opts), dir), } } From 65567d4b66aed1d726bf9607967313fb0ff1b634 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Thu, 6 Nov 2025 12:47:16 -0500 Subject: [PATCH 05/12] fix: format --- opapi/src/opapi.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 8dd783285..69ff55a39 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -109,11 +109,7 @@ function isExportStateOptions(input?: OpenApiPostProcessors | ExportStateOptions } function exportClient(state: State) { - function _exportClient( - dir: string, - props: GenerateClientProps, - stateOpts?: ExportStateOptions, - ): Promise + function _exportClient(dir: string, props: GenerateClientProps, stateOpts?: ExportStateOptions): Promise function _exportClient( dir: string, openapiGeneratorEndpoint: string, From 0f09a9934dcb41b0612d1a8d4725fb46487d859c Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Thu, 6 Nov 2025 13:28:25 -0500 Subject: [PATCH 06/12] chore: Add defaultParameters tests --- opapi/src/opapi.ts | 3 + opapi/test/state.test.ts | 125 ++++++++++++++++++++++++++++++++++++++- opapi/test/util.ts | 27 +++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 69ff55a39..dbecbcb30 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -96,6 +96,9 @@ const applyExportOptions = @@ -182,4 +182,127 @@ describe('openapi state generator', () => { } }) }) + + it('should export state without defaultParameters when ignoreDefaultParameters is set', async () => { + const api = OpenApi( + { + security: ['BearerAuth'], + metadata, + sections, + defaultParameters: { + 'x-tree': { + description: 'Tree id', + in: 'header', + type: 'string', + } + }, + schemas: { + Tree: { + section: 'trees', + schema: tree, + }, + }, + }, + { allowUnions: true }, + ) + + api.addOperation({ + security: ['BearerAuth'], + name: 'getTree', + description: 'Get a tree', + method: 'get', + path: '/trees/{id}', + parameters: { + id: { + description: 'Tree id', + in: 'path', + type: 'string', + }, + }, + response: { + description: 'Tree information', + schema: tree, + }, + }) + + const genStateFolder = join(__dirname, 'gen/state') + api.exportState(genStateFolder, { + importPath: '../../../src', + ignoreDefaultParameters: true, + ignoreSecurity: true, + }) + + const files = getFiles(genStateFolder) + + files.forEach((file) => { + if (file.endsWith('state.ts')) { + const state = requireTsFile(file) + expect(state.state.operations['getTree'].parameters['x-tree']).toBeUndefined() + expect(state.state.operations['getTree'].security).toBeUndefined() + expect(state.state.operations['getTree'].parameters['id']).toBeDefined() + } + if (file.endsWith('.ts')) { + validateTypescriptFile(file) + } + }) + }) + + it('should export state defaultParameters', async () => { + const api = OpenApi( + { + security: ['BearerAuth'], + metadata, + sections, + defaultParameters: { + 'x-tree': { + description: 'Tree id', + in: 'header', + type: 'string', + } + }, + schemas: { + Tree: { + section: 'trees', + schema: tree, + }, + }, + }, + { allowUnions: true }, + ) + + api.addOperation({ + security: ['BearerAuth'], + name: 'getTree', + description: 'Get a tree', + method: 'get', + path: '/trees/{id}', + parameters: { + id: { + description: 'Tree id', + in: 'path', + type: 'string', + }, + }, + response: { + description: 'Tree information', + schema: tree, + }, + }) + + const genStateFolder = join(__dirname, 'gen/state') + api.exportState(genStateFolder, { importPath: '../../../src' }) + const files = getFiles(genStateFolder) + + files.forEach((file) => { + if (file.endsWith('state.ts')) { + const state = requireTsFile(file) + expect(state.state.operations['getTree'].parameters['x-tree']).toBeDefined() + expect(state.state.operations['getTree'].security).toBeDefined() + expect(state.state.operations['getTree'].parameters['id']).toBeDefined() + } + if (file.endsWith('.ts')) { + validateTypescriptFile(file) + } + }) + }) }) diff --git a/opapi/test/util.ts b/opapi/test/util.ts index 4425736b1..d3af61562 100644 --- a/opapi/test/util.ts +++ b/opapi/test/util.ts @@ -1,4 +1,6 @@ import path from 'path' +import Module from 'module' +import fs from 'node:fs' import * as tsc from 'typescript' const host: tsc.FormatDiagnosticsHost = { @@ -35,3 +37,28 @@ export function getTypescriptErrors(filename: string, opts: tsc.CompilerOptions return diagnostics.map((diag) => tsc.formatDiagnostic(diag, host)) } + +export function requireTsFile(filename: string) { + const content = fs.readFileSync(filename).toString() + const { outputText } = tsc.transpileModule(content, { + compilerOptions: DEFAULT_OPTIONS, + fileName: filename, + }); + return requireJsCode(outputText) +} + +export function requireJsCode(code: string) { + const filedir = 'tmp' + const filename = `${Date.now()}.js` + + const fileid = path.join(filedir, filename) + + const m = new Module(fileid) + m.filename = filename + + /** @ts-ignore */ + m._compile(code, filename) + return m.exports +} + + From 9599486ccbfdece90c6a3ebed173919deec83459 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Thu, 6 Nov 2025 13:30:39 -0500 Subject: [PATCH 07/12] chore: fix lint --- opapi/test/state.test.ts | 4 ++-- opapi/test/util.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/opapi/test/state.test.ts b/opapi/test/state.test.ts index 1513f1615..82c214034 100644 --- a/opapi/test/state.test.ts +++ b/opapi/test/state.test.ts @@ -194,7 +194,7 @@ describe('openapi state generator', () => { description: 'Tree id', in: 'header', type: 'string', - } + }, }, schemas: { Tree: { @@ -258,7 +258,7 @@ describe('openapi state generator', () => { description: 'Tree id', in: 'header', type: 'string', - } + }, }, schemas: { Tree: { diff --git a/opapi/test/util.ts b/opapi/test/util.ts index d3af61562..65432d9bd 100644 --- a/opapi/test/util.ts +++ b/opapi/test/util.ts @@ -43,7 +43,7 @@ export function requireTsFile(filename: string) { const { outputText } = tsc.transpileModule(content, { compilerOptions: DEFAULT_OPTIONS, fileName: filename, - }); + }) return requireJsCode(outputText) } @@ -60,5 +60,3 @@ export function requireJsCode(code: string) { m._compile(code, filename) return m.exports } - - From 5ede91e6faefb503f89d17eac20fed31efda5685 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Thu, 6 Nov 2025 15:27:13 -0500 Subject: [PATCH 08/12] fix: remove exportClient options --- opapi/src/opapi.ts | 41 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index dbecbcb30..8b9dbb2c7 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -62,7 +62,7 @@ export type OpenApiPostProcessors = { apiCode: CodePostProcessor } -export type GenerateClientProps = +export type GenerateClientProps = ( | { generator: 'openapi-generator' endpoint: string @@ -71,6 +71,8 @@ export type GenerateClientProps = | { generator: 'opapi' } +) & + ExportStateOptions type ExportStateOptions = Partial<{ ignoreDefaultParameters: boolean @@ -103,48 +105,31 @@ const applyExportOptions = ) { - function _exportClient(dir: string, props: GenerateClientProps, stateOpts?: ExportStateOptions): Promise + function _exportClient(dir: string, props: GenerateClientProps): Promise function _exportClient( dir: string, openapiGeneratorEndpoint: string, - postProcessors?: OpenApiPostProcessors, - stateOpts?: ExportStateOptions, + props?: OpenApiPostProcessors & ExportStateOptions, ): Promise function _exportClient( dir = '.', propsOrEndpoint: GenerateClientProps | string, - postProcessorsOrStateOpts?: OpenApiPostProcessors | ExportStateOptions, - stateOpts?: ExportStateOptions, + postProcessorsOrStateOpts?: OpenApiPostProcessors & ExportStateOptions, ) { - let props: GenerateClientProps + let options: GenerateClientProps if (typeof propsOrEndpoint === 'string') { - if (!postProcessorsOrStateOpts || isOpenApiPostProcessors(postProcessorsOrStateOpts)) { - props = { generator: 'openapi-generator', endpoint: propsOrEndpoint, postProcessors: postProcessorsOrStateOpts } - } else { - throw new Error('This is a bug. `postProcessors` options does not have a valid type') - } + options = { generator: 'openapi-generator', endpoint: propsOrEndpoint, postProcessors: postProcessorsOrStateOpts } } else { - props = propsOrEndpoint - if (isExportStateOptions(postProcessorsOrStateOpts)) { - stateOpts = postProcessorsOrStateOpts - } + options = propsOrEndpoint } - state = applyExportOptions(state, stateOpts) + state = applyExportOptions(state, postProcessorsOrStateOpts) - if (props.generator === 'openapi-generator') { - return generateClientWithOpenapiGenerator(state, dir, props.endpoint, props.postProcessors) + if (options.generator === 'openapi-generator') { + return generateClientWithOpenapiGenerator(state, dir, options.endpoint, options.postProcessors) } - if (props.generator === 'opapi') { + if (options.generator === 'opapi') { return generateClientWithOpapi(state, dir) } throw new Error('Unknown generator') From 1955aaaf96b2ba8ad6328e619a47598ae322ce71 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 09:34:51 -0500 Subject: [PATCH 09/12] chore: Unit test applyExportOptions --- opapi/src/export-options.ts | 36 ++++++++ opapi/src/opapi.ts | 33 +------ opapi/test/export-options.test.ts | 140 ++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 31 deletions(-) create mode 100644 opapi/src/export-options.ts create mode 100644 opapi/test/export-options.test.ts diff --git a/opapi/src/export-options.ts b/opapi/src/export-options.ts new file mode 100644 index 000000000..3fa4bb2ef --- /dev/null +++ b/opapi/src/export-options.ts @@ -0,0 +1,36 @@ +import { State } from './state' + +export type ExportStateOptions = Partial<{ + ignoreDefaultParameters: boolean + ignoreSecurity: boolean +}> + +export const applyExportOptions = < + SchemaName extends string, + DefaultParameterName extends string, + SectionName extends string, +>( + state: State, + options?: ExportStateOptions, +) => { + if (options?.ignoreDefaultParameters && state.defaultParameters) { + const defaultParametersName = Object.keys(state.defaultParameters) + for (const operationId of Object.keys(state.operations)) { + if (!state.operations[operationId]?.parameters) { + continue + } + state.operations[operationId].parameters = Object.fromEntries( + Object.entries(state.operations[operationId].parameters).filter( + ([parameterName]) => !defaultParametersName.includes(parameterName), + ), + ) + } + } + if (options?.ignoreSecurity) { + delete state.security + for (const operationId of Object.keys(state.operations)) { + delete state.operations[operationId]?.security + } + } + return state +} diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 8b9dbb2c7..2ebe9c7fd 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -22,6 +22,7 @@ import { } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' import { generateHandler } from './handler-generator' +import { applyExportOptions, ExportStateOptions } from './export-options' export { Operation, Parameter } from './state' type AnatineSchemaObject = NonNullable[1]> @@ -74,37 +75,6 @@ export type GenerateClientProps = ( ) & ExportStateOptions -type ExportStateOptions = Partial<{ - ignoreDefaultParameters: boolean - ignoreSecurity: boolean -}> - -const applyExportOptions = ( - state: State, - options?: ExportStateOptions, -) => { - if (options?.ignoreDefaultParameters && state.defaultParameters) { - const defaultParametersName = Object.keys(state.defaultParameters) - for (const operationId of Object.keys(state.operations)) { - if (!state.operations[operationId]?.parameters) { - continue - } - state.operations[operationId].parameters = Object.fromEntries( - Object.entries(state.operations[operationId].parameters).filter( - ([parameterName]) => !defaultParametersName.includes(parameterName), - ), - ) - } - } - if (options?.ignoreSecurity) { - delete state.security - for (const operationId of Object.keys(state.operations)) { - delete state.operations[operationId]?.security - } - } - return state -} - function exportClient(state: State) { function _exportClient(dir: string, props: GenerateClientProps): Promise function _exportClient( @@ -146,6 +116,7 @@ const createOpapiFromState = < ) => { return { getModelRef: (name: SchemaName): OpenApiZodAny => getRef(state, ComponentType.SCHEMAS, name), + getState: () => state, addOperation: ( operationProps: Operation, ) => addOperation(state, operationProps), diff --git a/opapi/test/export-options.test.ts b/opapi/test/export-options.test.ts new file mode 100644 index 000000000..30fde434d --- /dev/null +++ b/opapi/test/export-options.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest' +import { OpenApi, OpenApiProps } from '../src' +import { z } from 'zod' +import { applyExportOptions } from '../src/export-options' + +type AnyProps = OpenApiProps + +function getApi() { + const metadata = { + title: 'Test API', + description: 'Test API', + server: 'http://localhost:3000', + version: '1.0.0', + prefix: '/v1', + } satisfies AnyProps['metadata'] + + const sections = { + trees: { + title: 'Trees', + description: 'Trees section', + }, + } satisfies AnyProps['sections'] + + const leaf: z.ZodType = z.object({ + type: z.literal('leaf'), + name: z.string(), + data: z.string(), + }) + const node: z.ZodType = z.object({ + type: z.literal('node'), + name: z.string(), + children: z.array(z.lazy(() => tree)), + }) + const tree: z.ZodType = z.union([leaf, node]) + + const api = OpenApi( + { + metadata, + sections, + defaultParameters: { + 'x-tree': { + description: 'Tree id', + in: 'header', + type: 'string', + }, + }, + schemas: { + Tree: { + section: 'trees', + schema: tree, + }, + }, + security: ['BearerAuth'], + }, + { allowUnions: true }, + ) + + api.addOperation({ + security: ['BearerAuth'], + name: 'getTree', + description: 'Get a tree', + method: 'get', + path: '/trees/{id}', + parameters: { + id: { + description: 'Tree id', + in: 'path', + type: 'string', + }, + }, + response: { + description: 'Tree information', + schema: tree, + }, + }) + + return api +} + +describe('Apply Export Options', () => { + it('defaultParameters should be removed when ignoreDefaultParameters is true', async () => { + const api = getApi() + const initState = api.getState() + + expect(initState.operations['getTree'].parameters!['x-tree']).toBeDefined() + expect(initState.operations['getTree'].parameters!['id']).toBeDefined() + + const updatedState = applyExportOptions(api.getState(), { + ignoreDefaultParameters: true, + }) + + expect(updatedState.operations['getTree'].parameters!['x-tree']).toBeUndefined() + expect(updatedState.operations['getTree'].parameters!['id']).toBeDefined() + }) + + it('defaultParameters should not be removed when ignoreDefaultParameters is false', async () => { + const api = getApi() + const initState = api.getState() + + expect(initState.operations['getTree'].parameters!['x-tree']).toBeDefined() + expect(initState.operations['getTree'].parameters!['id']).toBeDefined() + + const updatedState = applyExportOptions(api.getState(), { + ignoreDefaultParameters: false, + }) + + expect(updatedState.operations['getTree'].parameters!['x-tree']).toBeDefined() + expect(updatedState.operations['getTree'].parameters!['id']).toBeDefined() + }) + + it('security should not be removed when ignoreSecurity is false', async () => { + const api = getApi() + const initState = api.getState() + + expect(initState.security).toBeDefined() + expect(initState.operations['getTree'].security).toBeDefined() + + const updatedState = applyExportOptions(api.getState(), { + ignoreSecurity: false, + }) + + expect(updatedState.security).toBeDefined() + expect(updatedState.operations['getTree'].security).toBeDefined() + }) + + it('security should be removed when ignoreSecurity is true', async () => { + const api = getApi() + const initState = api.getState() + + expect(initState.security).toBeDefined() + expect(initState.operations['getTree'].security).toBeDefined() + + const updatedState = applyExportOptions(api.getState(), { + ignoreSecurity: true, + }) + + expect(updatedState.security).toBeUndefined() + expect(updatedState.operations['getTree'].security).toBeUndefined() + }) +}) From f09298bd46dbe5a69b16d9bc25f6718a1e3158c8 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 09:35:47 -0500 Subject: [PATCH 10/12] chore: rename arg --- opapi/src/opapi.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 2ebe9c7fd..473b97544 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -85,16 +85,16 @@ function exportClient(state: State) { function _exportClient( dir = '.', propsOrEndpoint: GenerateClientProps | string, - postProcessorsOrStateOpts?: OpenApiPostProcessors & ExportStateOptions, + postProcessorsAndStateOpts?: OpenApiPostProcessors & ExportStateOptions, ) { let options: GenerateClientProps if (typeof propsOrEndpoint === 'string') { - options = { generator: 'openapi-generator', endpoint: propsOrEndpoint, postProcessors: postProcessorsOrStateOpts } + options = { generator: 'openapi-generator', endpoint: propsOrEndpoint, postProcessors: postProcessorsAndStateOpts } } else { options = propsOrEndpoint } - state = applyExportOptions(state, postProcessorsOrStateOpts) + state = applyExportOptions(state, postProcessorsAndStateOpts) if (options.generator === 'openapi-generator') { return generateClientWithOpenapiGenerator(state, dir, options.endpoint, options.postProcessors) From bc93a842df2378d557fef4dbe0a0d9154a82de4f Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 09:37:18 -0500 Subject: [PATCH 11/12] fix: format --- opapi/src/opapi.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 473b97544..5041d47c9 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -89,7 +89,11 @@ function exportClient(state: State) { ) { let options: GenerateClientProps if (typeof propsOrEndpoint === 'string') { - options = { generator: 'openapi-generator', endpoint: propsOrEndpoint, postProcessors: postProcessorsAndStateOpts } + options = { + generator: 'openapi-generator', + endpoint: propsOrEndpoint, + postProcessors: postProcessorsAndStateOpts, + } } else { options = propsOrEndpoint } From c8e3b89392f90f43aad51741eccbd96797ac121f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Fri, 7 Nov 2025 10:59:24 -0500 Subject: [PATCH 12/12] update --- opapi/{test => src}/export-options.test.ts | 59 ++++++++++------------ 1 file changed, 28 insertions(+), 31 deletions(-) rename opapi/{test => src}/export-options.test.ts (57%) diff --git a/opapi/test/export-options.test.ts b/opapi/src/export-options.test.ts similarity index 57% rename from opapi/test/export-options.test.ts rename to opapi/src/export-options.test.ts index 30fde434d..5a87380f3 100644 --- a/opapi/test/export-options.test.ts +++ b/opapi/src/export-options.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest' -import { OpenApi, OpenApiProps } from '../src' +import { createState } from './state' import { z } from 'zod' -import { applyExportOptions } from '../src/export-options' +import { applyExportOptions } from './export-options' +import { addOperation } from './operation' -type AnyProps = OpenApiProps +type AnyProps = Parameters[0] -function getApi() { +function getApiState() { const metadata = { title: 'Test API', description: 'Test API', @@ -33,7 +34,7 @@ function getApi() { }) const tree: z.ZodType = z.union([leaf, node]) - const api = OpenApi( + const state = createState( { metadata, sections, @@ -55,7 +56,7 @@ function getApi() { { allowUnions: true }, ) - api.addOperation({ + addOperation(state, { security: ['BearerAuth'], name: 'getTree', description: 'Get a tree', @@ -74,67 +75,63 @@ function getApi() { }, }) - return api + return state } describe('Apply Export Options', () => { it('defaultParameters should be removed when ignoreDefaultParameters is true', async () => { - const api = getApi() - const initState = api.getState() + const initState = getApiState() - expect(initState.operations['getTree'].parameters!['x-tree']).toBeDefined() - expect(initState.operations['getTree'].parameters!['id']).toBeDefined() + expect(initState.operations['getTree']?.parameters!['x-tree']).toBeDefined() + expect(initState.operations['getTree']?.parameters!['id']).toBeDefined() - const updatedState = applyExportOptions(api.getState(), { + const updatedState = applyExportOptions(initState, { ignoreDefaultParameters: true, }) - expect(updatedState.operations['getTree'].parameters!['x-tree']).toBeUndefined() - expect(updatedState.operations['getTree'].parameters!['id']).toBeDefined() + expect(updatedState.operations['getTree']?.parameters!['x-tree']).toBeUndefined() + expect(updatedState.operations['getTree']?.parameters!['id']).toBeDefined() }) it('defaultParameters should not be removed when ignoreDefaultParameters is false', async () => { - const api = getApi() - const initState = api.getState() + const initState = getApiState() - expect(initState.operations['getTree'].parameters!['x-tree']).toBeDefined() - expect(initState.operations['getTree'].parameters!['id']).toBeDefined() + expect(initState.operations['getTree']?.parameters!['x-tree']).toBeDefined() + expect(initState.operations['getTree']?.parameters!['id']).toBeDefined() - const updatedState = applyExportOptions(api.getState(), { + const updatedState = applyExportOptions(initState, { ignoreDefaultParameters: false, }) - expect(updatedState.operations['getTree'].parameters!['x-tree']).toBeDefined() - expect(updatedState.operations['getTree'].parameters!['id']).toBeDefined() + expect(updatedState.operations['getTree']?.parameters!['x-tree']).toBeDefined() + expect(updatedState.operations['getTree']?.parameters!['id']).toBeDefined() }) it('security should not be removed when ignoreSecurity is false', async () => { - const api = getApi() - const initState = api.getState() + const initState = getApiState() expect(initState.security).toBeDefined() - expect(initState.operations['getTree'].security).toBeDefined() + expect(initState.operations['getTree']?.security).toBeDefined() - const updatedState = applyExportOptions(api.getState(), { + const updatedState = applyExportOptions(initState, { ignoreSecurity: false, }) expect(updatedState.security).toBeDefined() - expect(updatedState.operations['getTree'].security).toBeDefined() + expect(updatedState.operations['getTree']?.security).toBeDefined() }) it('security should be removed when ignoreSecurity is true', async () => { - const api = getApi() - const initState = api.getState() + const initState = getApiState() expect(initState.security).toBeDefined() - expect(initState.operations['getTree'].security).toBeDefined() + expect(initState.operations['getTree']?.security).toBeDefined() - const updatedState = applyExportOptions(api.getState(), { + const updatedState = applyExportOptions(initState, { ignoreSecurity: true, }) expect(updatedState.security).toBeUndefined() - expect(updatedState.operations['getTree'].security).toBeUndefined() + expect(updatedState.operations['getTree']?.security).toBeUndefined() }) })