diff --git a/opapi/package.json b/opapi/package.json index e36be8828..03d63e231 100644 --- a/opapi/package.json +++ b/opapi/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/opapi", - "version": "0.17.1", + "version": "0.18.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/export-options.test.ts b/opapi/src/export-options.test.ts new file mode 100644 index 000000000..5a87380f3 --- /dev/null +++ b/opapi/src/export-options.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest' +import { createState } from './state' +import { z } from 'zod' +import { applyExportOptions } from './export-options' +import { addOperation } from './operation' + +type AnyProps = Parameters[0] + +function getApiState() { + 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 state = createState( + { + metadata, + sections, + defaultParameters: { + 'x-tree': { + description: 'Tree id', + in: 'header', + type: 'string', + }, + }, + schemas: { + Tree: { + section: 'trees', + schema: tree, + }, + }, + security: ['BearerAuth'], + }, + { allowUnions: true }, + ) + + addOperation(state, { + 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 state +} + +describe('Apply Export Options', () => { + it('defaultParameters should be removed when ignoreDefaultParameters is true', async () => { + const initState = getApiState() + + expect(initState.operations['getTree']?.parameters!['x-tree']).toBeDefined() + expect(initState.operations['getTree']?.parameters!['id']).toBeDefined() + + const updatedState = applyExportOptions(initState, { + 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 initState = getApiState() + + expect(initState.operations['getTree']?.parameters!['x-tree']).toBeDefined() + expect(initState.operations['getTree']?.parameters!['id']).toBeDefined() + + const updatedState = applyExportOptions(initState, { + 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 initState = getApiState() + + expect(initState.security).toBeDefined() + expect(initState.operations['getTree']?.security).toBeDefined() + + const updatedState = applyExportOptions(initState, { + ignoreSecurity: false, + }) + + expect(updatedState.security).toBeDefined() + expect(updatedState.operations['getTree']?.security).toBeDefined() + }) + + it('security should be removed when ignoreSecurity is true', async () => { + const initState = getApiState() + + expect(initState.security).toBeDefined() + expect(initState.operations['getTree']?.security).toBeDefined() + + const updatedState = applyExportOptions(initState, { + ignoreSecurity: true, + }) + + expect(updatedState.security).toBeUndefined() + expect(updatedState.operations['getTree']?.security).toBeUndefined() + }) +}) 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 0e894144d..5041d47c9 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]> @@ -62,7 +63,7 @@ export type OpenApiPostProcessors = { apiCode: CodePostProcessor } -export type GenerateClientProps = +export type GenerateClientProps = ( | { generator: 'openapi-generator' endpoint: string @@ -71,22 +72,34 @@ export type GenerateClientProps = | { generator: 'opapi' } +) & + ExportStateOptions function exportClient(state: State) { + function _exportClient(dir: string, props: GenerateClientProps): Promise function _exportClient( dir: string, openapiGeneratorEndpoint: string, - postProcessors?: OpenApiPostProcessors, + props?: OpenApiPostProcessors & ExportStateOptions, ): Promise - function _exportClient(dir: string, props: GenerateClientProps): Promise - function _exportClient(dir = '.', props: GenerateClientProps | string, postProcessors?: OpenApiPostProcessors) { + function _exportClient( + dir = '.', + propsOrEndpoint: GenerateClientProps | string, + postProcessorsAndStateOpts?: OpenApiPostProcessors & ExportStateOptions, + ) { let options: GenerateClientProps - if (typeof props === 'string') { - options = { generator: 'openapi-generator', endpoint: props, postProcessors } + if (typeof propsOrEndpoint === 'string') { + options = { + generator: 'openapi-generator', + endpoint: propsOrEndpoint, + postProcessors: postProcessorsAndStateOpts, + } } else { - options = props + options = propsOrEndpoint } + state = applyExportOptions(state, postProcessorsAndStateOpts) + if (options.generator === 'openapi-generator') { return generateClientWithOpenapiGenerator(state, dir, options.endpoint, options.postProcessors) } @@ -107,16 +120,20 @@ const createOpapiFromState = < ) => { return { getModelRef: (name: SchemaName): OpenApiZodAny => getRef(state, ComponentType.SCHEMAS, name), + getState: () => state, 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), + 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), } } diff --git a/opapi/test/state.test.ts b/opapi/test/state.test.ts index f6a06bc6e..82c214034 100644 --- a/opapi/test/state.test.ts +++ b/opapi/test/state.test.ts @@ -3,7 +3,7 @@ import z from 'zod' import { OpenApi, OpenApiProps } from '../src' import { join } from 'path' import { getFiles } from '../src/file' -import { validateTypescriptFile } from './util' +import { requireTsFile, validateTypescriptFile } from './util' type AnyProps = OpenApiProps @@ -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..65432d9bd 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,26 @@ 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 +}