From dd971a987428049ec82f5bcdd2cac64f65a212d1 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 08:48:52 -0500 Subject: [PATCH 01/12] chore: convert OpenApi and State to classes --- opapi/package.json | 2 +- opapi/src/opapi.ts | 128 +++++++++++------------- opapi/src/state.ts | 243 +++++++++++++++++++++------------------------ 3 files changed, 173 insertions(+), 200 deletions(-) diff --git a/opapi/package.json b/opapi/package.json index e36be8828..33e71540e 100644 --- a/opapi/package.json +++ b/opapi/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/opapi", - "version": "0.17.1", + "version": "1.0.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/opapi.ts b/opapi/src/opapi.ts index 0e894144d..d96bf1c34 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -7,12 +7,9 @@ import { generateServer, generateTypesBySection, } from './generator' -import { addOperation } from './operation' import { ApiError, ComponentType, - createState, - getRef, Metadata, Operation, Options, @@ -35,11 +32,62 @@ export const schema = ( return extendApi(copy, schemaObject) } -export type OpenApi< - SchemaName extends string = string, - DefaultParameterName extends string = string, - SectionName extends string = string, -> = ReturnType> +export class OpenApi { + private _state: State + + private constructor(state: State) { + this._state = state + } + + static from( + props: OpenApiProps, + options: Partial = {}, + ) { + return new OpenApi(new State(props, options)) + } + + getModelRef(name: SchemaName): OpenApiZodAny { + return this._state.getRef(ComponentType.SCHEMAS, name) + } + + addOperation(operation: Operation) { + this._state.addOperation(operation) + } + + exportClient(dir: string, options: GenerateClientOptions) { + if (options.generator === 'openapi-generator') { + return generateClientWithOpenapiGenerator(this._state, dir, options.endpoint, options.postProcessors) + } + if (options.generator === 'opapi') { + return generateClientWithOpapi(this._state, dir) + } + throw new Error('Unknown generator') + } + + exportTypesBySection(dir = '.') { + generateTypesBySection(this._state, dir) + } + + exportServer(dir = '.', useExpressTypes: boolean) { + generateServer(this._state, dir, useExpressTypes) + } + + exportOpenapi(dir = '.') { + generateOpenapi(this._state, dir) + } + + exportState(dir = '.', options?: ExportStateAsTypescriptOptions) { + exportStateAsTypescript(this._state, dir, options) + } + + exportErrors(dir = '.') { + generateErrorsFile(this._state.errors ?? [], dir) + } + + exportHandler(dir = '.') { + generateHandler(this._state, dir) + } +} // TODO: ensure type inference comes from field 'sections' not 'schemas' export type OpenApiProps = { @@ -62,7 +110,7 @@ export type OpenApiPostProcessors = { apiCode: CodePostProcessor } -export type GenerateClientProps = +export type GenerateClientOptions = | { generator: 'openapi-generator' endpoint: string @@ -72,68 +120,6 @@ export type GenerateClientProps = generator: 'opapi' } -function exportClient(state: State) { - function _exportClient( - dir: string, - openapiGeneratorEndpoint: string, - postProcessors?: OpenApiPostProcessors, - ): 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 } - } else { - options = props - } - - if (options.generator === 'openapi-generator') { - return generateClientWithOpenapiGenerator(state, dir, options.endpoint, options.postProcessors) - } - if (options.generator === 'opapi') { - return generateClientWithOpapi(state, dir) - } - throw new Error('Unknown generator') - } - return _exportClient -} - -const createOpapiFromState = < - SchemaName extends string, - DefaultParameterName extends string, - SectionName extends string, ->( - state: State, -) => { - return { - getModelRef: (name: SchemaName): OpenApiZodAny => getRef(state, ComponentType.SCHEMAS, name), - addOperation: ( - 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), - exportErrors: (dir = '.') => generateErrorsFile(state.errors ?? [], dir), - exportHandler: (dir = '.') => generateHandler(state, dir), - } -} - -export function OpenApi( - props: OpenApiProps, - opts: Partial = {}, -) { - const state = createState(props, opts) - return createOpapiFromState(state) -} - -export namespace OpenApi { - export const fromState = ( - state: State, - ) => createOpapiFromState(state as State) -} - export type SchemaOf> = O extends OpenApi ? Skema : never diff --git a/opapi/src/state.ts b/opapi/src/state.ts index a602e5c85..a3e1af8f9 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -1,37 +1,128 @@ import type { SchemaObject } from 'openapi3-ts' import { VError } from 'verror' import { z } from 'zod' -import { schema } from './opapi' +import { OpenApiProps, schema } from './opapi' import type { PathParams } from './path-params' import { isAlphanumeric, isCapitalAlphabetical, uniqueBy } from './util' import { generateSchemaFromZod } from './jsonschema' import { OpenApiZodAny } from '@anatine/zod-openapi' import { objects } from './objects' +import { addOperation } from './operation' -type SchemaType = 'zod-schema' | 'json-schema' -type SchemaOfType = T extends 'zod-schema' ? OpenApiZodAny : SchemaObject - -export type Options = { allowUnions: boolean } -const DEFAULT_OPTIONS: Options = { allowUnions: false } - -export type State = { +export class State { metadata: Metadata refs: RefMap defaultParameters?: { [name in DefaultParameterName]: Parameter<'json-schema'> } - sections: { - name: SectionName - title: string - description: string - schema?: string - operations: string[] - }[] + sections: Section[] schemas: Record errors?: ApiError[] - operations: { [name: string]: Operation } + operations: { [name: string]: Operation } = {} options?: Options security?: Security[] + + constructor(props: OpenApiProps, opts: Partial = {}) { + this.options = { ...DEFAULT_OPTIONS, ...opts } + + const schemaEntries = props.schemas + ? Object.entries<(typeof props.schemas)[SchemaName]>(props.schemas).map(([name, data]) => ({ + name, + schema: data.schema, + section: data.section, + })) + : [] + + this.refs = { + parameters: {}, + requestBodies: {}, + responses: {}, + schemas: {}, + } + + const toPairs = (obj: Record): [K, T][] => Object.entries(obj) as [K, T][] + + this.sections = props.sections + ? toPairs(props.sections).map(([name, section]) => ({ + ...section, + name, + operations: [], + schema: schemaEntries.find((schemaEntry) => schemaEntry.section === name)?.name, + })) + : [] + + const schemas: Record = {} + schemaEntries.forEach((schemaEntry) => { + const name = schemaEntry.name + + if (!isAlphanumeric(name)) { + throw new VError(`Invalid operation name ${name}. It must be alphanumeric and start with a letter`) + } + + if (schemas[name]) { + throw new VError(`Schema ${name} already exists`) + } + + schemas[name] = { + section: schemaEntry.section, + schema: generateSchemaFromZod(schemaEntry.schema, this.options), + } + this.refs.schemas[name] = true + }) + + this.schemas = schemas + + const userErrors = props.errors ?? [] + const defaultErrors = [unknownError, internalError] + this.errors = uniqueBy([...defaultErrors, ...userErrors], 'type') + + this.errors.forEach((error) => { + if (!isCapitalAlphabetical(error.type)) { + throw new VError(`Invalid error type ${error.type}. It must be alphabetical and start with a capital letter`) + } + + if (error.description.includes('\n')) { + throw new VError(`Error ${error.type} description must not contain new lines`) + } + + if (error.description.includes("\\'")) { + throw new VError(`Error ${error.type} description must not contain single quotes`) + } + }) + + this.defaultParameters = props.defaultParameters + ? (objects.mapValues(props.defaultParameters, mapParameter) satisfies Record< + DefaultParameterName, + Parameter<'json-schema'> + >) + : undefined + + this.metadata = props.metadata + this.security = props.security + } + + addOperation(operation: Operation) { + addOperation(this, operation) + } + + getRef(type: ComponentType, name: string): OpenApiZodAny { + if (!this.refs[type][name]) { + throw new VError(`${type} ${name} does not exist`) + } + + return schema(z.object({}), { + type: undefined, + properties: undefined, + required: undefined, + $ref: `#/components/${type}/${name}`, + }) + } } +type SchemaType = 'zod-schema' | 'json-schema' +type SchemaOfType = T extends 'zod-schema' ? OpenApiZodAny : SchemaObject + +export type Options = { allowUnions: boolean } +const DEFAULT_OPTIONS: Options = { allowUnions: false } + const unknownError: ApiError = { status: 500, type: 'Unknown', @@ -52,6 +143,14 @@ export type ApiError = { description: string } +export type Section = { + name: SectionName + title: string + description: string + schema?: string + operations: string[] +} + export type Metadata = { // Server url endpoint of the api server: string @@ -228,118 +327,6 @@ type BaseOperationProps< deprecated?: boolean } -type CreateStateProps = { - metadata: Metadata - defaultParameters?: Record> - schemas?: Record - sections?: Record - errors?: readonly ApiError[] - security?: Security[] -} - -export function createState( - props: CreateStateProps, - opts: Partial = {}, -): State { - const options = { ...DEFAULT_OPTIONS, ...opts } - - const schemaEntries = props.schemas - ? Object.entries<(typeof props.schemas)[SchemaName]>(props.schemas).map(([name, data]) => ({ - name, - schema: data.schema, - section: data.section, - })) - : [] - - const schemas: Record = {} - - const refs: State['refs'] = { - parameters: {}, - requestBodies: {}, - responses: {}, - schemas: {}, - } - - const toPairs = (obj: Record): [K, T][] => Object.entries(obj) as [K, T][] - - const sections = props.sections - ? toPairs(props.sections).map(([name, section]) => ({ - ...section, - name, - operations: [], - schema: schemaEntries.find((schemaEntry) => schemaEntry.section === name)?.name, - })) - : [] - - schemaEntries.forEach((schemaEntry) => { - const name = schemaEntry.name - - if (!isAlphanumeric(name)) { - throw new VError(`Invalid operation name ${name}. It must be alphanumeric and start with a letter`) - } - - if (schemas[name]) { - throw new VError(`Schema ${name} already exists`) - } - - schemas[name] = { - section: schemaEntry.section, - schema: generateSchemaFromZod(schemaEntry.schema, options), - } - refs.schemas[name] = true - }) - - const userErrors = props.errors ?? [] - const defaultErrors = [unknownError, internalError] - const errors = uniqueBy([...defaultErrors, ...userErrors], 'type') - - errors.forEach((error) => { - if (!isCapitalAlphabetical(error.type)) { - throw new VError(`Invalid error type ${error.type}. It must be alphabetical and start with a capital letter`) - } - - if (error.description.includes('\n')) { - throw new VError(`Error ${error.type} description must not contain new lines`) - } - - if (error.description.includes("\\'")) { - throw new VError(`Error ${error.type} description must not contain single quotes`) - } - }) - - const defaultParameters = props.defaultParameters - ? (objects.mapValues(props.defaultParameters, mapParameter) satisfies Record< - DefaultParameterName, - Parameter<'json-schema'> - >) - : undefined - - return { - operations: {}, - metadata: props.metadata, - defaultParameters, - errors, - refs, - schemas, - sections, - options, - security: props.security, - } -} - -export function getRef(state: State, type: ComponentType, name: string): OpenApiZodAny { - if (!state.refs[type][name]) { - throw new VError(`${type} ${name} does not exist`) - } - - return schema(z.object({}), { - type: undefined, - properties: undefined, - required: undefined, - $ref: `#/components/${type}/${name}`, - }) -} - export const mapParameter = (param: Parameter<'zod-schema'>): Parameter<'json-schema'> => { if ('schema' in param) { return { From efdaf9885bdc956a202cfcb21d86c0d9ee6e56a4 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 10:04:20 -0500 Subject: [PATCH 02/12] chore: fix typings --- opapi/src/opapi.ts | 11 ++++------- opapi/src/openapi.ts | 6 +++--- opapi/src/state.ts | 4 +--- opapi/test/api.ts | 2 +- opapi/test/state.test.ts | 14 +++++++------- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index d96bf1c34..498eee082 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -35,15 +35,12 @@ export const schema = ( export class OpenApi { private _state: State - private constructor(state: State) { - this._state = state + constructor(props: OpenApiProps, options: Partial = {}) { + this._state = new State(props, options) } - static from( - props: OpenApiProps, - options: Partial = {}, - ) { - return new OpenApi(new State(props, options)) + getState() { + return this._state } getModelRef(name: SchemaName): OpenApiZodAny { diff --git a/opapi/src/openapi.ts b/opapi/src/openapi.ts index 2c1ca5cd4..6662e4778 100644 --- a/opapi/src/openapi.ts +++ b/opapi/src/openapi.ts @@ -3,7 +3,7 @@ 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, Security, State, isOperationWithBodyProps } from './state' import { formatBodyName, formatResponseName } from './util' export const createOpenapi = < @@ -61,7 +61,7 @@ export const createOpenapi = < }) const responseRefSchema = generateSchemaFromZod( - getRef(state, ComponentType.RESPONSES, responseName), + state.getRef(ComponentType.RESPONSES, responseName), ) as unknown as ReferenceObject operationObject.security?.forEach((name) => securitySchemes.add(name)) @@ -96,7 +96,7 @@ export const createOpenapi = < }) const bodyRefSchema = generateSchemaFromZod( - getRef(state, ComponentType.REQUESTS, bodyName), + state.getRef(ComponentType.REQUESTS, bodyName), ) as unknown as ReferenceObject operation.requestBody = bodyRefSchema diff --git a/opapi/src/state.ts b/opapi/src/state.ts index a3e1af8f9..3983a4de3 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -38,10 +38,8 @@ export class State(obj: Record): [K, T][] => Object.entries(obj) as [K, T][] - this.sections = props.sections - ? toPairs(props.sections).map(([name, section]) => ({ + ? objects.entries(props.sections).map(([name, section]) => ({ ...section, name, operations: [], diff --git a/opapi/test/api.ts b/opapi/test/api.ts index 9c39f55de..6cfe35f37 100644 --- a/opapi/test/api.ts +++ b/opapi/test/api.ts @@ -36,7 +36,7 @@ const nestedSchema = z.object({ }) export const getMockApi = () => { - const api = OpenApi({ + const api = OpenApi.from({ errors: [ { description: 'Foo not found', diff --git a/opapi/test/state.test.ts b/opapi/test/state.test.ts index f6a06bc6e..458dcf728 100644 --- a/opapi/test/state.test.ts +++ b/opapi/test/state.test.ts @@ -40,7 +40,7 @@ const expectedErrorMessage = 'allOf, anyOf and oneOf are not supported' describe('openapi generator with unions not allowed', () => { it('should not allow unions when creating api', async () => { expect(() => { - OpenApi({ + new OpenApi({ metadata, sections, schemas: { @@ -54,7 +54,7 @@ describe('openapi generator with unions not allowed', () => { }) it('should not allow unions in response when adding an operation', async () => { - const api = OpenApi({ metadata, sections }) + const api = new OpenApi({ metadata, sections }) expect(() => { api.addOperation({ name: 'getTree', @@ -77,7 +77,7 @@ describe('openapi generator with unions not allowed', () => { }) it('should not allow unions in request body when adding an operation', async () => { - const api = OpenApi({ metadata, sections }) + const api = new OpenApi({ metadata, sections }) expect(() => { api.addOperation({ name: 'createTree', @@ -100,7 +100,7 @@ describe('openapi generator with unions not allowed', () => { describe('openapi generator with unions allowed', () => { const opts = { allowUnions: true } as const it('should allow unions when creating api', async () => { - OpenApi( + new OpenApi( { metadata, sections, @@ -116,7 +116,7 @@ describe('openapi generator with unions allowed', () => { }) it('should allow unions in response when adding an operation', async () => { - const api = OpenApi({ metadata, sections }, opts) + const api = new OpenApi({ metadata, sections }, opts) api.addOperation({ name: 'getTree', description: 'Get a tree', @@ -137,7 +137,7 @@ describe('openapi generator with unions allowed', () => { }) it('should allow unions in request body when adding an operation', async () => { - const api = OpenApi({ metadata, sections }, opts) + const api = new OpenApi({ metadata, sections }, opts) api.addOperation({ name: 'createTree', description: 'Create a tree', @@ -157,7 +157,7 @@ describe('openapi generator with unions allowed', () => { describe('openapi state generator', () => { it('should export state', async () => { - const api = OpenApi( + const api = new OpenApi( { metadata, sections, From c7a6b50520d6b484df922687462cf3166c845370 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 10:07:36 -0500 Subject: [PATCH 03/12] chore: move types def --- opapi/src/state.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/opapi/src/state.ts b/opapi/src/state.ts index 3983a4de3..0b45d631d 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -9,6 +9,13 @@ import { OpenApiZodAny } from '@anatine/zod-openapi' import { objects } from './objects' import { addOperation } from './operation' +type SchemaType = 'zod-schema' | 'json-schema' +type SchemaOfType = T extends 'zod-schema' ? OpenApiZodAny : SchemaObject + +export type Options = { allowUnions: boolean } +const DEFAULT_OPTIONS: Options = { allowUnions: false } + + export class State { metadata: Metadata refs: RefMap @@ -115,12 +122,6 @@ export class State = T extends 'zod-schema' ? OpenApiZodAny : SchemaObject - -export type Options = { allowUnions: boolean } -const DEFAULT_OPTIONS: Options = { allowUnions: false } - const unknownError: ApiError = { status: 500, type: 'Unknown', From ce4a26319cab594a590cfdc32cc36c3a319d6208 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 13:05:23 -0500 Subject: [PATCH 04/12] fix: types --- opapi/src/state.ts | 1 - opapi/test/api-types.test.ts | 2 +- opapi/test/api.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/opapi/src/state.ts b/opapi/src/state.ts index 0b45d631d..50aff0b65 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -15,7 +15,6 @@ type SchemaOfType = T extends 'zod-schema' ? OpenApiZodAny export type Options = { allowUnions: boolean } const DEFAULT_OPTIONS: Options = { allowUnions: false } - export class State { metadata: Metadata refs: RefMap diff --git a/opapi/test/api-types.test.ts b/opapi/test/api-types.test.ts index c4c2a8900..a4c415928 100644 --- a/opapi/test/api-types.test.ts +++ b/opapi/test/api-types.test.ts @@ -10,7 +10,7 @@ describe('api types generator', () => { const api = getMockApi() - await api.exportTypesBySection(genClientFolder) + api.exportTypesBySection(genClientFolder) const files = getFiles(genClientFolder) diff --git a/opapi/test/api.ts b/opapi/test/api.ts index 6cfe35f37..d299a33e6 100644 --- a/opapi/test/api.ts +++ b/opapi/test/api.ts @@ -36,7 +36,7 @@ const nestedSchema = z.object({ }) export const getMockApi = () => { - const api = OpenApi.from({ + const api = new OpenApi({ errors: [ { description: 'Foo not found', From d68894b56545aba9615b8d863bf8e949ef1b426c Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 14:26:10 -0500 Subject: [PATCH 05/12] chore: fix tests and bugs --- opapi/src/opapi.ts | 24 ++-- opapi/src/openapi.ts | 6 +- opapi/src/state.ts | 229 ++++++++++++++++++++------------------ opapi/test/client.test.ts | 5 +- opapi/test/state.test.ts | 4 +- 5 files changed, 147 insertions(+), 121 deletions(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index aabb4b4f5..037fc32be 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -16,10 +16,13 @@ import { Parameter, State, Security, + createState, + getRef, } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' import { generateHandler } from './handler-generator' import { applyExportOptions, ExportStateOptions } from './export-options' +import { addOperation } from './operation' export { Operation, Parameter } from './state' type AnatineSchemaObject = NonNullable[1]> @@ -37,11 +40,11 @@ export class OpenApi constructor(props: OpenApiProps, options: Partial = {}) { - this._state = new State(props, options) + this._state = createState(props, options) } static fromState( - state: State + state: State, ) { const openapi = new OpenApi({ metadata: state.metadata }) openapi._state = state @@ -53,16 +56,21 @@ export class OpenApi(operation: Operation) { - this._state.addOperation(operation) + addOperation(this._state, operation) } exportClient(dir: string, options: GenerateClientOptions & ExportStateOptions) { if (options.generator === 'openapi-generator') { - return generateClientWithOpenapiGenerator(applyExportOptions(this._state, options), dir, options.endpoint, options.postProcessors) + return generateClientWithOpenapiGenerator( + applyExportOptions(this._state, options), + dir, + options.endpoint, + options.postProcessors, + ) } if (options.generator === 'opapi') { return generateClientWithOpapi(applyExportOptions(this._state, options), dir) @@ -71,11 +79,11 @@ export class OpenApi securitySchemes.add(name)) @@ -96,7 +96,7 @@ export const createOpenapi = < }) const bodyRefSchema = generateSchemaFromZod( - state.getRef(ComponentType.REQUESTS, bodyName), + getRef(state, ComponentType.REQUESTS, bodyName), ) as unknown as ReferenceObject operation.requestBody = bodyRefSchema diff --git a/opapi/src/state.ts b/opapi/src/state.ts index 50aff0b65..a602e5c85 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -1,13 +1,12 @@ import type { SchemaObject } from 'openapi3-ts' import { VError } from 'verror' import { z } from 'zod' -import { OpenApiProps, schema } from './opapi' +import { schema } from './opapi' import type { PathParams } from './path-params' import { isAlphanumeric, isCapitalAlphabetical, uniqueBy } from './util' import { generateSchemaFromZod } from './jsonschema' import { OpenApiZodAny } from '@anatine/zod-openapi' import { objects } from './objects' -import { addOperation } from './operation' type SchemaType = 'zod-schema' | 'json-schema' type SchemaOfType = T extends 'zod-schema' ? OpenApiZodAny : SchemaObject @@ -15,110 +14,22 @@ type SchemaOfType = T extends 'zod-schema' ? OpenApiZodAny export type Options = { allowUnions: boolean } const DEFAULT_OPTIONS: Options = { allowUnions: false } -export class State { +export type State = { metadata: Metadata refs: RefMap defaultParameters?: { [name in DefaultParameterName]: Parameter<'json-schema'> } - sections: Section[] + sections: { + name: SectionName + title: string + description: string + schema?: string + operations: string[] + }[] schemas: Record errors?: ApiError[] - operations: { [name: string]: Operation } = {} + operations: { [name: string]: Operation } options?: Options security?: Security[] - - constructor(props: OpenApiProps, opts: Partial = {}) { - this.options = { ...DEFAULT_OPTIONS, ...opts } - - const schemaEntries = props.schemas - ? Object.entries<(typeof props.schemas)[SchemaName]>(props.schemas).map(([name, data]) => ({ - name, - schema: data.schema, - section: data.section, - })) - : [] - - this.refs = { - parameters: {}, - requestBodies: {}, - responses: {}, - schemas: {}, - } - - this.sections = props.sections - ? objects.entries(props.sections).map(([name, section]) => ({ - ...section, - name, - operations: [], - schema: schemaEntries.find((schemaEntry) => schemaEntry.section === name)?.name, - })) - : [] - - const schemas: Record = {} - schemaEntries.forEach((schemaEntry) => { - const name = schemaEntry.name - - if (!isAlphanumeric(name)) { - throw new VError(`Invalid operation name ${name}. It must be alphanumeric and start with a letter`) - } - - if (schemas[name]) { - throw new VError(`Schema ${name} already exists`) - } - - schemas[name] = { - section: schemaEntry.section, - schema: generateSchemaFromZod(schemaEntry.schema, this.options), - } - this.refs.schemas[name] = true - }) - - this.schemas = schemas - - const userErrors = props.errors ?? [] - const defaultErrors = [unknownError, internalError] - this.errors = uniqueBy([...defaultErrors, ...userErrors], 'type') - - this.errors.forEach((error) => { - if (!isCapitalAlphabetical(error.type)) { - throw new VError(`Invalid error type ${error.type}. It must be alphabetical and start with a capital letter`) - } - - if (error.description.includes('\n')) { - throw new VError(`Error ${error.type} description must not contain new lines`) - } - - if (error.description.includes("\\'")) { - throw new VError(`Error ${error.type} description must not contain single quotes`) - } - }) - - this.defaultParameters = props.defaultParameters - ? (objects.mapValues(props.defaultParameters, mapParameter) satisfies Record< - DefaultParameterName, - Parameter<'json-schema'> - >) - : undefined - - this.metadata = props.metadata - this.security = props.security - } - - addOperation(operation: Operation) { - addOperation(this, operation) - } - - getRef(type: ComponentType, name: string): OpenApiZodAny { - if (!this.refs[type][name]) { - throw new VError(`${type} ${name} does not exist`) - } - - return schema(z.object({}), { - type: undefined, - properties: undefined, - required: undefined, - $ref: `#/components/${type}/${name}`, - }) - } } const unknownError: ApiError = { @@ -141,14 +52,6 @@ export type ApiError = { description: string } -export type Section = { - name: SectionName - title: string - description: string - schema?: string - operations: string[] -} - export type Metadata = { // Server url endpoint of the api server: string @@ -325,6 +228,118 @@ type BaseOperationProps< deprecated?: boolean } +type CreateStateProps = { + metadata: Metadata + defaultParameters?: Record> + schemas?: Record + sections?: Record + errors?: readonly ApiError[] + security?: Security[] +} + +export function createState( + props: CreateStateProps, + opts: Partial = {}, +): State { + const options = { ...DEFAULT_OPTIONS, ...opts } + + const schemaEntries = props.schemas + ? Object.entries<(typeof props.schemas)[SchemaName]>(props.schemas).map(([name, data]) => ({ + name, + schema: data.schema, + section: data.section, + })) + : [] + + const schemas: Record = {} + + const refs: State['refs'] = { + parameters: {}, + requestBodies: {}, + responses: {}, + schemas: {}, + } + + const toPairs = (obj: Record): [K, T][] => Object.entries(obj) as [K, T][] + + const sections = props.sections + ? toPairs(props.sections).map(([name, section]) => ({ + ...section, + name, + operations: [], + schema: schemaEntries.find((schemaEntry) => schemaEntry.section === name)?.name, + })) + : [] + + schemaEntries.forEach((schemaEntry) => { + const name = schemaEntry.name + + if (!isAlphanumeric(name)) { + throw new VError(`Invalid operation name ${name}. It must be alphanumeric and start with a letter`) + } + + if (schemas[name]) { + throw new VError(`Schema ${name} already exists`) + } + + schemas[name] = { + section: schemaEntry.section, + schema: generateSchemaFromZod(schemaEntry.schema, options), + } + refs.schemas[name] = true + }) + + const userErrors = props.errors ?? [] + const defaultErrors = [unknownError, internalError] + const errors = uniqueBy([...defaultErrors, ...userErrors], 'type') + + errors.forEach((error) => { + if (!isCapitalAlphabetical(error.type)) { + throw new VError(`Invalid error type ${error.type}. It must be alphabetical and start with a capital letter`) + } + + if (error.description.includes('\n')) { + throw new VError(`Error ${error.type} description must not contain new lines`) + } + + if (error.description.includes("\\'")) { + throw new VError(`Error ${error.type} description must not contain single quotes`) + } + }) + + const defaultParameters = props.defaultParameters + ? (objects.mapValues(props.defaultParameters, mapParameter) satisfies Record< + DefaultParameterName, + Parameter<'json-schema'> + >) + : undefined + + return { + operations: {}, + metadata: props.metadata, + defaultParameters, + errors, + refs, + schemas, + sections, + options, + security: props.security, + } +} + +export function getRef(state: State, type: ComponentType, name: string): OpenApiZodAny { + if (!state.refs[type][name]) { + throw new VError(`${type} ${name} does not exist`) + } + + return schema(z.object({}), { + type: undefined, + properties: undefined, + required: undefined, + $ref: `#/components/${type}/${name}`, + }) +} + export const mapParameter = (param: Parameter<'zod-schema'>): Parameter<'json-schema'> => { if ('schema' in param) { return { diff --git a/opapi/test/client.test.ts b/opapi/test/client.test.ts index 809c924c7..f3f763b89 100644 --- a/opapi/test/client.test.ts +++ b/opapi/test/client.test.ts @@ -10,7 +10,10 @@ describe('client generator', () => { const api = getMockApi() - await api.exportClient(genClientFolder, 'https://api.openapi-generator.tech') + await api.exportClient(genClientFolder, { + generator: 'openapi-generator', + endpoint: 'https://api.openapi-generator.tech', + }) const files = getFiles(genClientFolder) diff --git a/opapi/test/state.test.ts b/opapi/test/state.test.ts index 2b2574ec8..52b16bb4a 100644 --- a/opapi/test/state.test.ts +++ b/opapi/test/state.test.ts @@ -184,7 +184,7 @@ describe('openapi state generator', () => { }) it('should export state without defaultParameters when ignoreDefaultParameters is set', async () => { - const api = OpenApi( + const api = new OpenApi( { security: ['BearerAuth'], metadata, @@ -248,7 +248,7 @@ describe('openapi state generator', () => { }) it('should export state defaultParameters', async () => { - const api = OpenApi( + const api = new OpenApi( { security: ['BearerAuth'], metadata, From 726f7ade1d14533e98d7bb33b1669520a4ee4ff5 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 16:09:19 -0500 Subject: [PATCH 06/12] chore: fix tests --- opapi/test/api-types.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opapi/test/api-types.test.ts b/opapi/test/api-types.test.ts index a4c415928..c4c2a8900 100644 --- a/opapi/test/api-types.test.ts +++ b/opapi/test/api-types.test.ts @@ -10,7 +10,7 @@ describe('api types generator', () => { const api = getMockApi() - api.exportTypesBySection(genClientFolder) + await api.exportTypesBySection(genClientFolder) const files = getFiles(genClientFolder) From f450b793fe1f8b6074ba8c4f377f6e46e154b55c Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 16:31:37 -0500 Subject: [PATCH 07/12] chore: Simplify types --- opapi/src/opapi.ts | 22 ++-------------------- opapi/src/state.ts | 2 +- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 037fc32be..4dfb36aae 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -8,16 +8,13 @@ import { generateTypesBySection, } from './generator' import { - ApiError, ComponentType, - Metadata, Operation, Options, - Parameter, State, - Security, createState, getRef, + CreateStateProps, } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' import { generateHandler } from './handler-generator' @@ -39,7 +36,7 @@ export const schema = ( export class OpenApi { private _state: State - constructor(props: OpenApiProps, options: Partial = {}) { + constructor(props: CreateStateProps, options: Partial = {}) { this._state = createState(props, options) } @@ -103,21 +100,6 @@ export class OpenApi = { - metadata: Metadata - // adds default parameters to all operations - defaultParameters?: Record> - // adds the openapi schemas - schemas?: Record - // adds the openapi tags - sections?: Record - // add the openapi errors - errors?: readonly ApiError[] - // add security schemes - security?: Security[] -} - export type CodePostProcessor = (code: string) => Promise | string export type OpenApiPostProcessors = { diff --git a/opapi/src/state.ts b/opapi/src/state.ts index a602e5c85..5bc25a741 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -228,7 +228,7 @@ type BaseOperationProps< deprecated?: boolean } -type CreateStateProps = { +export type CreateStateProps = { metadata: Metadata defaultParameters?: Record> schemas?: Record From 6248466b8283fbbd7da581d3d863a7c860a31a45 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Fri, 7 Nov 2025 16:55:15 -0500 Subject: [PATCH 08/12] chore: Convert state to a class --- opapi/src/export-options.test.ts | 6 +- opapi/src/opapi.ts | 14 +- opapi/src/openapi.ts | 6 +- opapi/src/section-types-generator/helpers.ts | 3 +- opapi/src/state.ts | 215 +++++++++---------- 5 files changed, 119 insertions(+), 125 deletions(-) diff --git a/opapi/src/export-options.test.ts b/opapi/src/export-options.test.ts index 5a87380f3..770548767 100644 --- a/opapi/src/export-options.test.ts +++ b/opapi/src/export-options.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest' -import { createState } from './state' import { z } from 'zod' import { applyExportOptions } from './export-options' import { addOperation } from './operation' +import { State, CreateStateProps } from './state' -type AnyProps = Parameters[0] +type AnyProps = CreateStateProps function getApiState() { const metadata = { @@ -34,7 +34,7 @@ function getApiState() { }) const tree: z.ZodType = z.union([leaf, node]) - const state = createState( + const state = new State( { metadata, sections, diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 4dfb36aae..70c514cf4 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -12,8 +12,6 @@ import { Operation, Options, State, - createState, - getRef, CreateStateProps, } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' @@ -37,7 +35,7 @@ export class OpenApi constructor(props: CreateStateProps, options: Partial = {}) { - this._state = createState(props, options) + this._state = new State(props, options) } static fromState( @@ -49,11 +47,11 @@ export class OpenApi(operation: Operation) { @@ -84,15 +82,15 @@ export class OpenApi securitySchemes.add(name)) @@ -96,7 +96,7 @@ export const createOpenapi = < }) const bodyRefSchema = generateSchemaFromZod( - getRef(state, ComponentType.REQUESTS, bodyName), + state.getRef(ComponentType.REQUESTS, bodyName), ) as unknown as ReferenceObject operation.requestBody = bodyRefSchema diff --git a/opapi/src/section-types-generator/helpers.ts b/opapi/src/section-types-generator/helpers.ts index 6ceafaa42..8f59594cf 100644 --- a/opapi/src/section-types-generator/helpers.ts +++ b/opapi/src/section-types-generator/helpers.ts @@ -3,6 +3,7 @@ import type { SchemaObject } from 'openapi3-ts' import { pascal, title } from 'radash' import { createOpenapi } from '../openapi' import { Block, DefaultState } from './types' +import { State } from 'src/state' export const pascalize = (str: string) => pascal(title(str)) @@ -72,7 +73,7 @@ export function remove$RefPropertiesFromSchema( export async function getDereferencedSchema(state: DefaultState) { // this doesn't do a deep clone, which helps us in the dereference step // in other words, openapi still has references to the original objects in state - const clonedState = JSON.parse(JSON.stringify(state)) + const clonedState = state.clone() const openapi = createOpenapi(clonedState).getSpec() // this dereferences those objects in place await OpenAPIParser.dereference(openapi as any) diff --git a/opapi/src/state.ts b/opapi/src/state.ts index 5bc25a741..a8a2dded4 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -14,24 +14,6 @@ type SchemaOfType = T extends 'zod-schema' ? OpenApiZodAny export type Options = { allowUnions: boolean } const DEFAULT_OPTIONS: Options = { allowUnions: false } -export type State = { - metadata: Metadata - refs: RefMap - defaultParameters?: { [name in DefaultParameterName]: Parameter<'json-schema'> } - sections: { - name: SectionName - title: string - description: string - schema?: string - operations: string[] - }[] - schemas: Record - errors?: ApiError[] - operations: { [name: string]: Operation } - options?: Options - security?: Security[] -} - const unknownError: ApiError = { status: 500, type: 'Unknown', @@ -237,107 +219,120 @@ export type CreateStateProps( - props: CreateStateProps, - opts: Partial = {}, -): State { - const options = { ...DEFAULT_OPTIONS, ...opts } - - const schemaEntries = props.schemas - ? Object.entries<(typeof props.schemas)[SchemaName]>(props.schemas).map(([name, data]) => ({ - name, - schema: data.schema, - section: data.section, - })) - : [] - - const schemas: Record = {} - - const refs: State['refs'] = { - parameters: {}, - requestBodies: {}, - responses: {}, - schemas: {}, - } - - const toPairs = (obj: Record): [K, T][] => Object.entries(obj) as [K, T][] - - const sections = props.sections - ? toPairs(props.sections).map(([name, section]) => ({ - ...section, - name, - operations: [], - schema: schemaEntries.find((schemaEntry) => schemaEntry.section === name)?.name, - })) - : [] - - schemaEntries.forEach((schemaEntry) => { - const name = schemaEntry.name - - if (!isAlphanumeric(name)) { - throw new VError(`Invalid operation name ${name}. It must be alphanumeric and start with a letter`) - } - - if (schemas[name]) { - throw new VError(`Schema ${name} already exists`) - } +export class State { + metadata: Metadata + refs: RefMap + defaultParameters?: { [name in DefaultParameterName]: Parameter<'json-schema'> } + sections: { + name: SectionName + title: string + description: string + schema?: string + operations: string[] + }[] + schemas: Record + errors?: ApiError[] + operations: { [name: string]: Operation } = {} + options?: Options + security?: Security[] - schemas[name] = { - section: schemaEntry.section, - schema: generateSchemaFromZod(schemaEntry.schema, options), + constructor( + props: CreateStateProps, + opts: Partial = {}, + ) { + this.options = { ...DEFAULT_OPTIONS, ...opts } + + const schemaEntries = props.schemas + ? Object.entries<(typeof props.schemas)[SchemaName]>(props.schemas).map(([name, data]) => ({ + name, + schema: data.schema, + section: data.section, + })) + : [] + + const schemas: Record = {} + + this.refs = { + parameters: {}, + requestBodies: {}, + responses: {}, + schemas: {}, } - refs.schemas[name] = true - }) - - const userErrors = props.errors ?? [] - const defaultErrors = [unknownError, internalError] - const errors = uniqueBy([...defaultErrors, ...userErrors], 'type') - errors.forEach((error) => { - if (!isCapitalAlphabetical(error.type)) { - throw new VError(`Invalid error type ${error.type}. It must be alphabetical and start with a capital letter`) - } + this.sections = props.sections + ? objects.entries(props.sections).map(([name, section]) => ({ + ...section, + name, + operations: [], + schema: schemaEntries.find((schemaEntry) => schemaEntry.section === name)?.name, + })) + : [] + + schemaEntries.forEach((schemaEntry) => { + const name = schemaEntry.name + + if (!isAlphanumeric(name)) { + throw new VError(`Invalid operation name ${name}. It must be alphanumeric and start with a letter`) + } + + if (schemas[name]) { + throw new VError(`Schema ${name} already exists`) + } + + schemas[name] = { + section: schemaEntry.section, + schema: generateSchemaFromZod(schemaEntry.schema, this.options), + } + this.refs.schemas[name] = true + }) + + this.schemas = schemas + + const userErrors = props.errors ?? [] + const defaultErrors = [unknownError, internalError] + this.errors = uniqueBy([...defaultErrors, ...userErrors], 'type') + + this.errors.forEach((error) => { + if (!isCapitalAlphabetical(error.type)) { + throw new VError(`Invalid error type ${error.type}. It must be alphabetical and start with a capital letter`) + } + + if (error.description.includes('\n')) { + throw new VError(`Error ${error.type} description must not contain new lines`) + } + + if (error.description.includes("\\'")) { + throw new VError(`Error ${error.type} description must not contain single quotes`) + } + }) + + this.defaultParameters = props.defaultParameters + ? (objects.mapValues(props.defaultParameters, mapParameter) satisfies Record< + DefaultParameterName, + Parameter<'json-schema'> + >) + : undefined + + this.metadata = props.metadata + this.security = props.security + } - if (error.description.includes('\n')) { - throw new VError(`Error ${error.type} description must not contain new lines`) + getRef(type: ComponentType, name: string): OpenApiZodAny { + if (!this.refs[type][name]) { + throw new VError(`${type} ${name} does not exist`) } - if (error.description.includes("\\'")) { - throw new VError(`Error ${error.type} description must not contain single quotes`) - } - }) - - const defaultParameters = props.defaultParameters - ? (objects.mapValues(props.defaultParameters, mapParameter) satisfies Record< - DefaultParameterName, - Parameter<'json-schema'> - >) - : undefined - - return { - operations: {}, - metadata: props.metadata, - defaultParameters, - errors, - refs, - schemas, - sections, - options, - security: props.security, + return schema(z.object({}), { + type: undefined, + properties: undefined, + required: undefined, + $ref: `#/components/${type}/${name}`, + }) } -} -export function getRef(state: State, type: ComponentType, name: string): OpenApiZodAny { - if (!state.refs[type][name]) { - throw new VError(`${type} ${name} does not exist`) + clone() { + return Object.assign(Object.create(Object.getPrototypeOf(this)), this) } - - return schema(z.object({}), { - type: undefined, - properties: undefined, - required: undefined, - $ref: `#/components/${type}/${name}`, - }) } export const mapParameter = (param: Parameter<'zod-schema'>): Parameter<'json-schema'> => { From d6b5067544a02e2b257b2be6ec14ca2b73321f5d Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Mon, 10 Nov 2025 09:48:59 -0500 Subject: [PATCH 09/12] chore: revert state class changes --- opapi/src/export-options.test.ts | 35 ++- opapi/src/opapi.ts | 9 +- opapi/src/openapi.ts | 6 +- opapi/src/section-types-generator/helpers.ts | 4 +- opapi/src/state.ts | 219 ++++++++++--------- 5 files changed, 141 insertions(+), 132 deletions(-) diff --git a/opapi/src/export-options.test.ts b/opapi/src/export-options.test.ts index 770548767..ea9a72d45 100644 --- a/opapi/src/export-options.test.ts +++ b/opapi/src/export-options.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { z } from 'zod' import { applyExportOptions } from './export-options' import { addOperation } from './operation' -import { State, CreateStateProps } from './state' +import { CreateStateProps, createState } from './state' type AnyProps = CreateStateProps @@ -34,27 +34,24 @@ function getApiState() { }) const tree: z.ZodType = z.union([leaf, node]) - const state = new State( - { - metadata, - sections, - defaultParameters: { - 'x-tree': { - description: 'Tree id', - in: 'header', - type: 'string', - }, + const state = createState({ + metadata, + sections, + defaultParameters: { + 'x-tree': { + description: 'Tree id', + in: 'header', + type: 'string', }, - schemas: { - Tree: { - section: 'trees', - schema: tree, - }, + }, + schemas: { + Tree: { + section: 'trees', + schema: tree, }, - security: ['BearerAuth'], }, - { allowUnions: true }, - ) + security: ['BearerAuth'], + }, { allowUnions: true }) addOperation(state, { security: ['BearerAuth'], diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 70c514cf4..3d7de5afd 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -13,6 +13,9 @@ import { Options, State, CreateStateProps, + cloneState, + getRef, + createState, } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' import { generateHandler } from './handler-generator' @@ -35,7 +38,7 @@ export class OpenApi constructor(props: CreateStateProps, options: Partial = {}) { - this._state = new State(props, options) + this._state = createState(props, options) } static fromState( @@ -47,11 +50,11 @@ export class OpenApi(operation: Operation) { diff --git a/opapi/src/openapi.ts b/opapi/src/openapi.ts index 6662e4778..2c1ca5cd4 100644 --- a/opapi/src/openapi.ts +++ b/opapi/src/openapi.ts @@ -3,7 +3,7 @@ import VError from 'verror' import { defaultResponseStatus } from './const' import { generateSchemaFromZod } from './jsonschema' import { objects } from './objects' -import { ComponentType, Security, State, isOperationWithBodyProps } from './state' +import { ComponentType, Security, State, getRef, isOperationWithBodyProps } from './state' import { formatBodyName, formatResponseName } from './util' export const createOpenapi = < @@ -61,7 +61,7 @@ export const createOpenapi = < }) const responseRefSchema = generateSchemaFromZod( - state.getRef(ComponentType.RESPONSES, responseName), + getRef(state, ComponentType.RESPONSES, responseName), ) as unknown as ReferenceObject operationObject.security?.forEach((name) => securitySchemes.add(name)) @@ -96,7 +96,7 @@ export const createOpenapi = < }) const bodyRefSchema = generateSchemaFromZod( - state.getRef(ComponentType.REQUESTS, bodyName), + getRef(state, ComponentType.REQUESTS, bodyName), ) as unknown as ReferenceObject operation.requestBody = bodyRefSchema diff --git a/opapi/src/section-types-generator/helpers.ts b/opapi/src/section-types-generator/helpers.ts index 8f59594cf..4d8b78c4e 100644 --- a/opapi/src/section-types-generator/helpers.ts +++ b/opapi/src/section-types-generator/helpers.ts @@ -3,7 +3,7 @@ import type { SchemaObject } from 'openapi3-ts' import { pascal, title } from 'radash' import { createOpenapi } from '../openapi' import { Block, DefaultState } from './types' -import { State } from 'src/state' +import { cloneState } from 'src/state' export const pascalize = (str: string) => pascal(title(str)) @@ -73,7 +73,7 @@ export function remove$RefPropertiesFromSchema( export async function getDereferencedSchema(state: DefaultState) { // this doesn't do a deep clone, which helps us in the dereference step // in other words, openapi still has references to the original objects in state - const clonedState = state.clone() + const clonedState = cloneState(state) const openapi = createOpenapi(clonedState).getSpec() // this dereferences those objects in place await OpenAPIParser.dereference(openapi as any) diff --git a/opapi/src/state.ts b/opapi/src/state.ts index a8a2dded4..27fc02872 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -14,6 +14,24 @@ type SchemaOfType = T extends 'zod-schema' ? OpenApiZodAny export type Options = { allowUnions: boolean } const DEFAULT_OPTIONS: Options = { allowUnions: false } +export type State = { + metadata: Metadata + refs: RefMap + defaultParameters?: { [name in DefaultParameterName]: Parameter<'json-schema'> } + sections: { + name: SectionName + title: string + description: string + schema?: string + operations: string[] + }[] + schemas: Record + errors?: ApiError[] + operations: { [name: string]: Operation } + options?: Options + security?: Security[] +} + const unknownError: ApiError = { status: 500, type: 'Unknown', @@ -219,120 +237,111 @@ export type CreateStateProps { - metadata: Metadata - refs: RefMap - defaultParameters?: { [name in DefaultParameterName]: Parameter<'json-schema'> } - sections: { - name: SectionName - title: string - description: string - schema?: string - operations: string[] - }[] - schemas: Record - errors?: ApiError[] - operations: { [name: string]: Operation } = {} - options?: Options - security?: Security[] +export function createState( + props: CreateStateProps, + opts: Partial = {}, +): State { + const options = { ...DEFAULT_OPTIONS, ...opts } + + const schemaEntries = props.schemas + ? Object.entries<(typeof props.schemas)[SchemaName]>(props.schemas).map(([name, data]) => ({ + name, + schema: data.schema, + section: data.section, + })) + : [] + + const schemas: Record = {} + + const refs: State['refs'] = { + parameters: {}, + requestBodies: {}, + responses: {}, + schemas: {}, + } + + const toPairs = (obj: Record): [K, T][] => Object.entries(obj) as [K, T][] - constructor( - props: CreateStateProps, - opts: Partial = {}, - ) { - this.options = { ...DEFAULT_OPTIONS, ...opts } - - const schemaEntries = props.schemas - ? Object.entries<(typeof props.schemas)[SchemaName]>(props.schemas).map(([name, data]) => ({ - name, - schema: data.schema, - section: data.section, - })) - : [] - - const schemas: Record = {} - - this.refs = { - parameters: {}, - requestBodies: {}, - responses: {}, - schemas: {}, + const sections = props.sections + ? toPairs(props.sections).map(([name, section]) => ({ + ...section, + name, + operations: [], + schema: schemaEntries.find((schemaEntry) => schemaEntry.section === name)?.name, + })) + : [] + + schemaEntries.forEach((schemaEntry) => { + const name = schemaEntry.name + + if (!isAlphanumeric(name)) { + throw new VError(`Invalid operation name ${name}. It must be alphanumeric and start with a letter`) } - this.sections = props.sections - ? objects.entries(props.sections).map(([name, section]) => ({ - ...section, - name, - operations: [], - schema: schemaEntries.find((schemaEntry) => schemaEntry.section === name)?.name, - })) - : [] - - schemaEntries.forEach((schemaEntry) => { - const name = schemaEntry.name - - if (!isAlphanumeric(name)) { - throw new VError(`Invalid operation name ${name}. It must be alphanumeric and start with a letter`) - } - - if (schemas[name]) { - throw new VError(`Schema ${name} already exists`) - } - - schemas[name] = { - section: schemaEntry.section, - schema: generateSchemaFromZod(schemaEntry.schema, this.options), - } - this.refs.schemas[name] = true - }) - - this.schemas = schemas - - const userErrors = props.errors ?? [] - const defaultErrors = [unknownError, internalError] - this.errors = uniqueBy([...defaultErrors, ...userErrors], 'type') - - this.errors.forEach((error) => { - if (!isCapitalAlphabetical(error.type)) { - throw new VError(`Invalid error type ${error.type}. It must be alphabetical and start with a capital letter`) - } - - if (error.description.includes('\n')) { - throw new VError(`Error ${error.type} description must not contain new lines`) - } - - if (error.description.includes("\\'")) { - throw new VError(`Error ${error.type} description must not contain single quotes`) - } - }) - - this.defaultParameters = props.defaultParameters - ? (objects.mapValues(props.defaultParameters, mapParameter) satisfies Record< - DefaultParameterName, - Parameter<'json-schema'> - >) - : undefined - - this.metadata = props.metadata - this.security = props.security - } + if (schemas[name]) { + throw new VError(`Schema ${name} already exists`) + } + + schemas[name] = { + section: schemaEntry.section, + schema: generateSchemaFromZod(schemaEntry.schema, options), + } + refs.schemas[name] = true + }) + + const userErrors = props.errors ?? [] + const defaultErrors = [unknownError, internalError] + const errors = uniqueBy([...defaultErrors, ...userErrors], 'type') + + errors.forEach((error) => { + if (!isCapitalAlphabetical(error.type)) { + throw new VError(`Invalid error type ${error.type}. It must be alphabetical and start with a capital letter`) + } - getRef(type: ComponentType, name: string): OpenApiZodAny { - if (!this.refs[type][name]) { - throw new VError(`${type} ${name} does not exist`) + if (error.description.includes('\n')) { + throw new VError(`Error ${error.type} description must not contain new lines`) } - return schema(z.object({}), { - type: undefined, - properties: undefined, - required: undefined, - $ref: `#/components/${type}/${name}`, - }) + if (error.description.includes("\\'")) { + throw new VError(`Error ${error.type} description must not contain single quotes`) + } + }) + + const defaultParameters = props.defaultParameters + ? (objects.mapValues(props.defaultParameters, mapParameter) satisfies Record< + DefaultParameterName, + Parameter<'json-schema'> + >) + : undefined + + return { + operations: {}, + metadata: props.metadata, + defaultParameters, + errors, + refs, + schemas, + sections, + options, + security: props.security, } +} - clone() { - return Object.assign(Object.create(Object.getPrototypeOf(this)), this) +export function cloneState(state: State) { + return Object.assign(Object.create(Object.getPrototypeOf(state)), state) +} + +export function getRef(state: State, type: ComponentType, name: string): OpenApiZodAny { + if (!state.refs[type][name]) { + throw new VError(`${type} ${name} does not exist`) } + + return schema(z.object({}), { + type: undefined, + properties: undefined, + required: undefined, + $ref: `#/components/${type}/${name}`, + }) } export const mapParameter = (param: Parameter<'zod-schema'>): Parameter<'json-schema'> => { From 02c218c69e5234a90327c78a7f2847fc0fa7941c Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Mon, 10 Nov 2025 09:49:45 -0500 Subject: [PATCH 10/12] chore: fix readme with class use --- opapi/readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opapi/readme.md b/opapi/readme.md index 56330507e..56c429579 100644 --- a/opapi/readme.md +++ b/opapi/readme.md @@ -10,7 +10,7 @@ Install the package and start creating your OpenAPI specification. See the examp import { OpenApi, schema } from '@bpinternal/opapi' import { z } from 'zod' -const api = OpenApi({ +const api = new OpenApi({ metadata: { title: 'Example API', // This is the title of the API description: 'Description of this api', // This is the description of the API @@ -21,7 +21,7 @@ const api = OpenApi({ // This is metadata to be used in the documentation section: { User: { - tilte: 'User', + title: 'User', description: 'User related endpoints', }, }, From 39c2a528d6527811d2069f59b2adbeba040a65bc Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Mon, 10 Nov 2025 09:54:21 -0500 Subject: [PATCH 11/12] fix: format --- opapi/src/export-options.test.ts | 33 +++++++++++++++++--------------- opapi/src/opapi.ts | 11 +---------- opapi/src/state.ts | 6 +++++- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/opapi/src/export-options.test.ts b/opapi/src/export-options.test.ts index ea9a72d45..537c2f742 100644 --- a/opapi/src/export-options.test.ts +++ b/opapi/src/export-options.test.ts @@ -34,24 +34,27 @@ function getApiState() { }) const tree: z.ZodType = z.union([leaf, node]) - const state = createState({ - metadata, - sections, - defaultParameters: { - 'x-tree': { - description: 'Tree id', - in: 'header', - type: 'string', + const state = createState( + { + metadata, + sections, + defaultParameters: { + 'x-tree': { + description: 'Tree id', + in: 'header', + type: 'string', + }, }, - }, - schemas: { - Tree: { - section: 'trees', - schema: tree, + schemas: { + Tree: { + section: 'trees', + schema: tree, + }, }, + security: ['BearerAuth'], }, - security: ['BearerAuth'], - }, { allowUnions: true }) + { allowUnions: true }, + ) addOperation(state, { security: ['BearerAuth'], diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index 3d7de5afd..c0d407896 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -7,16 +7,7 @@ import { generateServer, generateTypesBySection, } from './generator' -import { - ComponentType, - Operation, - Options, - State, - CreateStateProps, - cloneState, - getRef, - createState, -} from './state' +import { ComponentType, Operation, Options, State, CreateStateProps, cloneState, getRef, createState } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' import { generateHandler } from './handler-generator' import { applyExportOptions, ExportStateOptions } from './export-options' diff --git a/opapi/src/state.ts b/opapi/src/state.ts index 27fc02872..2c98d6f7e 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -228,7 +228,11 @@ type BaseOperationProps< deprecated?: boolean } -export type CreateStateProps = { +export type CreateStateProps< + SchemaName extends string, + DefaultParameterName extends string, + SectionName extends string, +> = { metadata: Metadata defaultParameters?: Record> schemas?: Record From 6bbf3bfeea88101d8f0c62c16231bc22516ffac9 Mon Sep 17 00:00:00 2001 From: Xavier Hamel Date: Mon, 10 Nov 2025 11:03:42 -0500 Subject: [PATCH 12/12] chore: remove getState and cloneState --- opapi/src/opapi.ts | 6 +----- opapi/src/section-types-generator/helpers.ts | 3 +-- opapi/src/state.ts | 4 ---- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/opapi/src/opapi.ts b/opapi/src/opapi.ts index c0d407896..00d89d310 100644 --- a/opapi/src/opapi.ts +++ b/opapi/src/opapi.ts @@ -7,7 +7,7 @@ import { generateServer, generateTypesBySection, } from './generator' -import { ComponentType, Operation, Options, State, CreateStateProps, cloneState, getRef, createState } from './state' +import { ComponentType, Operation, Options, State, CreateStateProps, getRef, createState } from './state' import { exportStateAsTypescript, ExportStateAsTypescriptOptions } from './generators/ts-state' import { generateHandler } from './handler-generator' import { applyExportOptions, ExportStateOptions } from './export-options' @@ -40,10 +40,6 @@ export class OpenApi pascal(title(str)) @@ -73,7 +72,7 @@ export function remove$RefPropertiesFromSchema( export async function getDereferencedSchema(state: DefaultState) { // this doesn't do a deep clone, which helps us in the dereference step // in other words, openapi still has references to the original objects in state - const clonedState = cloneState(state) + const clonedState = JSON.parse(JSON.stringify(state)) const openapi = createOpenapi(clonedState).getSpec() // this dereferences those objects in place await OpenAPIParser.dereference(openapi as any) diff --git a/opapi/src/state.ts b/opapi/src/state.ts index 2c98d6f7e..a1da47133 100644 --- a/opapi/src/state.ts +++ b/opapi/src/state.ts @@ -331,10 +331,6 @@ export function createState) { - return Object.assign(Object.create(Object.getPrototypeOf(state)), state) -} - export function getRef(state: State, type: ComponentType, name: string): OpenApiZodAny { if (!state.refs[type][name]) { throw new VError(`${type} ${name} does not exist`)