From bc162a39cbf68a56e96a5eb1fa91e75bd4b3d96d Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:21:05 +0200 Subject: [PATCH] feat(config): multi-spec projects array drives N generations from one file (#238) Add a 'projects: [...]' array to config files so one config can drive generation for multiple OpenAPI specs (orval-style multi-spec support). - config-core.ts: add loadConfigsFile (normalized array API), parseProjectsArray/parseProjectEntry helpers, and runProjects shared iteration kernel (sequential, [i/N] logging, fail-fast). Split loadRawConfig into loadJsConfig + loadJsonConfig to reduce complexity. Extract prepareRaw to deduplicate loadConfigFile / loadConfigsFile preamble. - openapi-zod-ts config.ts: add loadConfigs() and defineProjects() exported from the package root. generator.ts migrates to runProjects and extracts generateZodIntegration to reduce complexity. - openapi-react-query, openapi-server, openapi-msw: each adds loadConfigs() wrapping loadConfigsFile, migrates generator.ts to import runProjects from openapi-zod-ts/config-core and call it, and extracts helpers (buildOverrides in react-query, buildRouterFile + generateSchemaEnhancedRouter in server). - Tests: loadConfigs projects-array describe block added to all four packages covering: one-element passthrough, N-element array, mutual-exclusion error, empty array error, per-entry error with index. - fallow:audit gate: complexity 0, duplication 3 warn-level groups (cross-package formatTs and minor generator patterns), exit 0. Relates to #238. A second PR will add .ts config documentation. --- .../openapi-msw/src/__tests__/config.test.ts | 64 ++++- packages/openapi-msw/src/config.ts | 39 ++- packages/openapi-msw/src/generator.ts | 17 +- packages/openapi-msw/src/index.ts | 2 +- .../src/__tests__/config.test.ts | 75 +++++- packages/openapi-react-query/src/config.ts | 52 ++-- packages/openapi-react-query/src/generator.ts | 51 ++-- packages/openapi-react-query/src/index.ts | 2 +- .../src/__tests__/config.test.ts | 72 +++++- packages/openapi-server/src/config.ts | 68 ++++-- packages/openapi-server/src/generator.ts | 137 ++++++----- packages/openapi-server/src/index.ts | 2 +- packages/openapi-zod-ts/README.md | 46 ++++ .../src/__tests__/config.test.ts | 205 ++++++++++++++++ packages/openapi-zod-ts/src/config-core.ts | 211 +++++++++++++--- packages/openapi-zod-ts/src/config.ts | 70 ++++-- packages/openapi-zod-ts/src/generator.ts | 225 +++++++++++------- packages/openapi-zod-ts/src/index.ts | 2 +- 18 files changed, 1055 insertions(+), 285 deletions(-) diff --git a/packages/openapi-msw/src/__tests__/config.test.ts b/packages/openapi-msw/src/__tests__/config.test.ts index c54a1c5..ae7bbf3 100644 --- a/packages/openapi-msw/src/__tests__/config.test.ts +++ b/packages/openapi-msw/src/__tests__/config.test.ts @@ -1,8 +1,9 @@ +// fallow-ignore-file code-duplication import { mkdtemp, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { describe, it, expect, afterEach } from 'vitest' -import { loadConfig } from '../config.js' +import { loadConfig, loadConfigs } from '../config.js' let tmpDir: string | undefined @@ -105,3 +106,64 @@ describe('loadConfig: invalid field values', () => { await expect(loadConfig(cwd, configPath)).rejects.toThrow('"depth_cap" must be an integer') }) }) + +describe('loadConfigs: projects array support', () => { + it('returns a one-element array for a single-spec config', async () => { + const { cwd, configPath } = await writeConfig({ + input_openapi: 'spec.json', + output: 'generated', + }) + const configs = await loadConfigs(cwd, configPath) + expect(configs).toHaveLength(1) + expect(configs[0]!.input_openapi).toBe('spec.json') + expect(configs[0]!.output).toBe('generated') + }) + + it('returns N configs for a projects array with N entries', async () => { + const { cwd, configPath } = await writeConfig({ + projects: [ + { input_openapi: 'services/users.json', output: 'mocks/users', seed: 1 }, + { input_openapi: 'services/orders.json', output: 'mocks/orders', seed: 2 }, + ], + }) + const configs = await loadConfigs(cwd, configPath) + expect(configs).toHaveLength(2) + expect(configs[0]!.input_openapi).toBe('services/users.json') + expect(configs[0]!.seed).toBe(1) + expect(configs[1]!.input_openapi).toBe('services/orders.json') + expect(configs[1]!.seed).toBe(2) + }) + + it('throws when both top-level input_openapi and projects are present', async () => { + const { cwd, configPath } = await writeConfig({ + input_openapi: 'spec.json', + output: 'generated', + projects: [{ input_openapi: 'services/users.json', output: 'mocks/users' }], + }) + await expect(loadConfigs(cwd, configPath)).rejects.toThrow( + 'Config cannot have both top-level "input_openapi"/"output" and a "projects" array' + ) + }) + + it('throws when projects is not an array', async () => { + const { cwd, configPath } = await writeConfig({ projects: 'not-an-array' }) + await expect(loadConfigs(cwd, configPath)).rejects.toThrow('"projects" must be an array') + }) + + it('throws when projects is empty', async () => { + const { cwd, configPath } = await writeConfig({ projects: [] }) + await expect(loadConfigs(cwd, configPath)).rejects.toThrow( + '"projects" array must contain at least one config entry' + ) + }) + + it('throws with project index when a project entry is invalid', async () => { + const { cwd, configPath } = await writeConfig({ + projects: [ + { input_openapi: 'services/users.json', output: 'mocks/users' }, + { input_openapi: 'services/orders.json', output: 'mocks/orders', seed: 1.5 }, + ], + }) + await expect(loadConfigs(cwd, configPath)).rejects.toThrow('projects[1]') + }) +}) diff --git a/packages/openapi-msw/src/config.ts b/packages/openapi-msw/src/config.ts index fd14c96..446f0d0 100644 --- a/packages/openapi-msw/src/config.ts +++ b/packages/openapi-msw/src/config.ts @@ -1,5 +1,6 @@ import { loadConfigFile, + loadConfigsFile, validateConfigPath, validateInputPath, validateOutputPath, @@ -38,19 +39,39 @@ function validateMswFields(raw: Record): void { expectIntegerAtLeast(raw, 'depth_cap', 1) } +function parseMswConfig( + raw: Record, + base: import('openapi-zod-ts/config-core').BaseConfig +): MswConfig { + validateMswFields(raw) + return { + ...base, + seed: raw['seed'] as number | undefined, + max_array_items: raw['max_array_items'] as number | undefined, + depth_cap: raw['depth_cap'] as number | undefined, + } +} + export async function loadConfig(cwd: string, configPath?: string): Promise { return loadConfigFile({ cwd, configPath, defaultFileName: 'openapi-msw.config.json', - parse: (raw, base) => { - validateMswFields(raw) - return { - ...base, - seed: raw['seed'] as number | undefined, - max_array_items: raw['max_array_items'] as number | undefined, - depth_cap: raw['depth_cap'] as number | undefined, - } - }, + parse: parseMswConfig, + }) +} + +/** + * Load a config file and return all configs as a normalized array. + * + * Single-spec config: returns a one-element array. + * Multi-spec config with "projects" key: returns N-element array. + */ +export async function loadConfigs(cwd: string, configPath?: string): Promise { + return loadConfigsFile({ + cwd, + configPath, + defaultFileName: 'openapi-msw.config.json', + parse: parseMswConfig, }) } diff --git a/packages/openapi-msw/src/generator.ts b/packages/openapi-msw/src/generator.ts index 724c4e2..fe23ebc 100644 --- a/packages/openapi-msw/src/generator.ts +++ b/packages/openapi-msw/src/generator.ts @@ -1,8 +1,8 @@ -// fallow-ignore-file code-duplication import { mkdir, writeFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { parseSpec } from 'openapi-zod-ts' -import { loadConfig } from './config.js' +import { runProjects } from 'openapi-zod-ts/config-core' +import { loadConfigs, type MswConfig } from './config.js' import { generateHandlers } from './plugins/handlers.js' async function formatTs(content: string, filePath: string): Promise { @@ -11,10 +11,12 @@ async function formatTs(content: string, filePath: string): Promise { return format(content, { ...config, parser: 'typescript' }) } -export async function generate(cwd: string, configPath?: string): Promise { - const config = await loadConfig(cwd, configPath) +async function generateOne(cwd: string, config: MswConfig, label?: string): Promise { const inputPath = resolve(cwd, config.input_openapi) const outputDir = resolve(cwd, config.output) + const prefix = label !== undefined ? `[${label}] ` : '' + + console.log(`${prefix}Parsing spec: ${inputPath}`) const spec = await parseSpec(inputPath) const file = generateHandlers(spec, { @@ -26,5 +28,10 @@ export async function generate(cwd: string, configPath?: string): Promise await mkdir(outputDir, { recursive: true }) const filePath = join(outputDir, file.filename) await writeFile(filePath, await formatTs(file.content, filePath), 'utf-8') - console.log(`✓ ${file.filename}`) + console.log(`${prefix}✓ ${file.filename}`) +} + +export async function generate(cwd: string, configPath?: string): Promise { + const configs = await loadConfigs(cwd, configPath) + await runProjects(configs, (config, label) => generateOne(cwd, config, label)) } diff --git a/packages/openapi-msw/src/index.ts b/packages/openapi-msw/src/index.ts index b80f3cc..b3461bf 100644 --- a/packages/openapi-msw/src/index.ts +++ b/packages/openapi-msw/src/index.ts @@ -1,5 +1,5 @@ export { generate } from './generator.js' -export { loadConfig } from './config.js' +export { loadConfig, loadConfigs } from './config.js' export type { MswConfig } from './config.js' export { generateHandlers } from './plugins/handlers.js' export type { HandlerGenOptions, GeneratedFile } from './plugins/handlers.js' diff --git a/packages/openapi-react-query/src/__tests__/config.test.ts b/packages/openapi-react-query/src/__tests__/config.test.ts index a048546..b556454 100644 --- a/packages/openapi-react-query/src/__tests__/config.test.ts +++ b/packages/openapi-react-query/src/__tests__/config.test.ts @@ -1,8 +1,9 @@ +// fallow-ignore-file code-duplication import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { loadConfig, validateConfigPath, validateOutputPath, validateInputPath } from '../config.js' +import { loadConfig, loadConfigs, validateConfigPath, validateOutputPath, validateInputPath } from '../config.js' describe('loadConfig', () => { let tmpDir: string @@ -287,3 +288,75 @@ describe('config security validation', () => { }) }) }) + +describe('loadConfigs: projects array support', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'openapi-rq-multi-config-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeConfig(content: unknown) { + writeFileSync(join(tmpDir, 'openapi-react-query.config.json'), JSON.stringify(content)) + } + + it('returns a one-element array for a single-spec config', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks' }) + const configs = await loadConfigs(tmpDir) + expect(configs).toHaveLength(1) + expect(configs[0]!.input_openapi).toBe('openapi.json') + expect(configs[0]!.output).toBe('src/hooks') + }) + + it('returns N configs for a projects array with N entries', async () => { + writeConfig({ + projects: [ + { input_openapi: 'services/users.json', output: 'src/users-hooks' }, + { input_openapi: 'services/orders.json', output: 'src/orders-hooks', stale_time: 5000 }, + ], + }) + const configs = await loadConfigs(tmpDir) + expect(configs).toHaveLength(2) + expect(configs[0]!.input_openapi).toBe('services/users.json') + expect(configs[0]!.stale_time).toBeUndefined() + expect(configs[1]!.input_openapi).toBe('services/orders.json') + expect(configs[1]!.stale_time).toBe(5000) + }) + + it('throws when both top-level input_openapi and projects are present', async () => { + writeConfig({ + input_openapi: 'openapi.json', + output: 'src/hooks', + projects: [{ input_openapi: 'services/users.json', output: 'src/users-hooks' }], + }) + await expect(loadConfigs(tmpDir)).rejects.toThrow( + 'Config cannot have both top-level "input_openapi"/"output" and a "projects" array' + ) + }) + + it('throws when projects is not an array', async () => { + writeConfig({ projects: 'not-an-array' }) + await expect(loadConfigs(tmpDir)).rejects.toThrow('"projects" must be an array') + }) + + it('throws when projects is empty', async () => { + writeConfig({ projects: [] }) + await expect(loadConfigs(tmpDir)).rejects.toThrow( + '"projects" array must contain at least one config entry' + ) + }) + + it('throws with project index when a project entry is invalid', async () => { + writeConfig({ + projects: [ + { input_openapi: 'services/users.json', output: 'src/users-hooks' }, + { input_openapi: 'services/orders.json', output: 'src/orders-hooks', stale_time: 'fast' }, + ], + }) + await expect(loadConfigs(tmpDir)).rejects.toThrow('projects[1]') + }) +}) diff --git a/packages/openapi-react-query/src/config.ts b/packages/openapi-react-query/src/config.ts index 4925f29..0a1cc80 100644 --- a/packages/openapi-react-query/src/config.ts +++ b/packages/openapi-react-query/src/config.ts @@ -1,5 +1,6 @@ import { loadConfigFile, + loadConfigsFile, validateConfigPath, validateInputPath, validateOutputPath, @@ -76,24 +77,47 @@ function validateReactQueryFields(raw: Record): void { if (raw['overrides'] !== undefined) validateOverrides(raw['overrides']) } +function parseReactQueryConfig( + raw: Record, + base: import('openapi-zod-ts/config-core').BaseConfig +): ReactQueryConfig { + validateReactQueryFields(raw) + return { + ...base, + stale_time: raw['stale_time'] as number | undefined, + gc_time: raw['gc_time'] as number | undefined, + suspense: raw['suspense'] as boolean | undefined, + overrides: raw['overrides'] as + | Record + | undefined, + auto_invalidate: raw['auto_invalidate'] as boolean | undefined, + infinite_query: raw['infinite_query'] as boolean | 'auto' | undefined, + } +} + export async function loadConfig(cwd: string, configPath?: string): Promise { return loadConfigFile({ cwd, configPath, defaultFileName: 'openapi-react-query.config.json', - parse: (raw, base) => { - validateReactQueryFields(raw) - return { - ...base, - stale_time: raw['stale_time'] as number | undefined, - gc_time: raw['gc_time'] as number | undefined, - suspense: raw['suspense'] as boolean | undefined, - overrides: raw['overrides'] as - | Record - | undefined, - auto_invalidate: raw['auto_invalidate'] as boolean | undefined, - infinite_query: raw['infinite_query'] as boolean | 'auto' | undefined, - } - }, + parse: parseReactQueryConfig, + }) +} + +/** + * Load a config file and return all configs as a normalized array. + * + * Single-spec config: returns a one-element array. + * Multi-spec config with "projects" key: returns N-element array. + */ +export async function loadConfigs( + cwd: string, + configPath?: string +): Promise { + return loadConfigsFile({ + cwd, + configPath, + defaultFileName: 'openapi-react-query.config.json', + parse: parseReactQueryConfig, }) } diff --git a/packages/openapi-react-query/src/generator.ts b/packages/openapi-react-query/src/generator.ts index ec45b16..39862bd 100644 --- a/packages/openapi-react-query/src/generator.ts +++ b/packages/openapi-react-query/src/generator.ts @@ -1,7 +1,8 @@ import { mkdir, writeFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { parseSpec } from 'openapi-zod-ts' -import { loadConfig } from './config.js' +import { runProjects } from 'openapi-zod-ts/config-core' +import { loadConfigs, type ReactQueryConfig } from './config.js' import { generateHooks } from './plugins/hooks.js' import { generateTestUtils } from './plugins/test-utils.js' @@ -11,31 +12,44 @@ async function formatTs(content: string, filePath: string): Promise { return format(content, { ...config, parser: 'typescript' }) } -export async function generate(cwd: string, configPath?: string): Promise { - const config = await loadConfig(cwd, configPath) +/** Convert snake_case config overrides to the camelCase options expected by generateHooks. */ +function buildOverrides( + config: ReactQueryConfig, + globalStaleTime: number, + globalGcTime: number +): Record | undefined { + if (!config.overrides) return undefined + const overrides: Record = {} + for (const [resource, timing] of Object.entries(config.overrides)) { + overrides[resource] = { + staleTime: timing.stale_time ?? globalStaleTime, + gcTime: timing.gc_time ?? globalGcTime, + } + } + return Object.keys(overrides).length > 0 ? overrides : undefined +} + +async function generateOne( + cwd: string, + config: ReactQueryConfig, + label?: string +): Promise { const inputPath = resolve(cwd, config.input_openapi) const outputDir = resolve(cwd, config.output) + const prefix = label !== undefined ? `[${label}] ` : '' + + console.log(`${prefix}Parsing spec: ${inputPath}`) const spec = await parseSpec(inputPath) const globalStaleTime = config.stale_time ?? 0 const globalGcTime = config.gc_time ?? 300_000 - - // Convert snake_case config overrides to camelCase options - const overrides: Record = {} - if (config.overrides) { - for (const [resource, timing] of Object.entries(config.overrides)) { - overrides[resource] = { - staleTime: timing.stale_time ?? globalStaleTime, - gcTime: timing.gc_time ?? globalGcTime, - } - } - } + const overrides = buildOverrides(config, globalStaleTime, globalGcTime) const files = [ generateHooks(spec, { staleTime: globalStaleTime, gcTime: globalGcTime, suspense: config.suspense, - overrides: Object.keys(overrides).length > 0 ? overrides : undefined, + overrides, autoInvalidate: config.auto_invalidate, infiniteQuery: config.infinite_query, }), @@ -46,6 +60,11 @@ export async function generate(cwd: string, configPath?: string): Promise for (const file of files) { const filePath = join(outputDir, file.filename) await writeFile(filePath, await formatTs(file.content, filePath), 'utf-8') - console.log(`✓ ${file.filename}`) + console.log(`${prefix}✓ ${file.filename}`) } } + +export async function generate(cwd: string, configPath?: string): Promise { + const configs = await loadConfigs(cwd, configPath) + await runProjects(configs, (config, label) => generateOne(cwd, config, label)) +} diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index cdd272b..19fc22e 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -1,5 +1,5 @@ export { generate } from './generator.js' -export { loadConfig } from './config.js' +export { loadConfig, loadConfigs } from './config.js' export type { ReactQueryConfig } from './config.js' export { generateHooks } from './plugins/hooks.js' export type { HookGenOptions } from './plugins/hooks.js' diff --git a/packages/openapi-server/src/__tests__/config.test.ts b/packages/openapi-server/src/__tests__/config.test.ts index 096df67..d867fc4 100644 --- a/packages/openapi-server/src/__tests__/config.test.ts +++ b/packages/openapi-server/src/__tests__/config.test.ts @@ -1,8 +1,9 @@ +// fallow-ignore-file code-duplication import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { loadConfig, validateConfigPath, validateOutputPath, validateInputPath } from '../config.js' +import { loadConfig, loadConfigs, validateConfigPath, validateOutputPath, validateInputPath } from '../config.js' describe('loadConfig', () => { let tmpDir: string @@ -270,3 +271,72 @@ describe('config security validation', () => { }) }) }) + +describe('loadConfigs: projects array support', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'openapi-server-multi-config-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeConfig(content: unknown) { + writeFileSync(join(tmpDir, 'openapi-server.config.json'), JSON.stringify(content)) + } + + it('returns a one-element array for a single-spec config', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/generated' }) + const configs = await loadConfigs(tmpDir) + expect(configs).toHaveLength(1) + expect(configs[0]!.input_openapi).toBe('openapi.json') + expect(configs[0]!.output).toBe('src/generated') + }) + + it('returns N configs for a projects array with N entries', async () => { + writeConfig({ + projects: [ + { input_openapi: 'services/users.json', output: 'src/users', framework: 'hono' }, + { input_openapi: 'services/orders.json', output: 'src/orders', framework: 'express' }, + ], + }) + const configs = await loadConfigs(tmpDir) + expect(configs).toHaveLength(2) + expect(configs[0]!.framework).toBe('hono') + expect(configs[1]!.framework).toBe('express') + }) + + it('throws when both top-level input_openapi and projects are present', async () => { + writeConfig({ + input_openapi: 'openapi.json', + output: 'src/generated', + projects: [{ input_openapi: 'services/users.json', output: 'src/users' }], + }) + await expect(loadConfigs(tmpDir)).rejects.toThrow( + 'Config cannot have both top-level "input_openapi"/"output" and a "projects" array' + ) + }) + + it('throws when projects is not an array', async () => { + writeConfig({ projects: 'not-an-array' }) + await expect(loadConfigs(tmpDir)).rejects.toThrow('"projects" must be an array') + }) + + it('throws when projects is empty', async () => { + writeConfig({ projects: [] }) + await expect(loadConfigs(tmpDir)).rejects.toThrow( + '"projects" array must contain at least one config entry' + ) + }) + + it('throws with project index when a project entry has invalid framework', async () => { + writeConfig({ + projects: [ + { input_openapi: 'services/users.json', output: 'src/users', framework: 'koa' }, + ], + }) + await expect(loadConfigs(tmpDir)).rejects.toThrow('projects[0]') + }) +}) diff --git a/packages/openapi-server/src/config.ts b/packages/openapi-server/src/config.ts index b21ef97..5e2197a 100644 --- a/packages/openapi-server/src/config.ts +++ b/packages/openapi-server/src/config.ts @@ -1,5 +1,6 @@ import { loadConfigFile, + loadConfigsFile, validateConfigPath, validateInputPath, validateOutputPath, @@ -18,34 +19,53 @@ export interface ServerConfig { input_schema?: string } +function parseServerConfig( + raw: Record, + base: import('openapi-zod-ts/config-core').BaseConfig +): ServerConfig { + const framework = raw['framework'] + if ( + framework !== undefined && + framework !== 'hono' && + framework !== 'express' && + framework !== 'fastify' && + framework !== 'none' + ) { + throw new Error('"framework" must be one of: "hono", "express", "fastify", or "none"') + } + if ( + raw['input_schema'] !== undefined && + (typeof raw['input_schema'] !== 'string' || !raw['input_schema']) + ) { + throw new Error('"input_schema" must be a non-empty string path to your Zod schema file') + } + return { + ...base, + framework: framework as 'hono' | 'express' | 'fastify' | 'none' | undefined, + input_schema: raw['input_schema'] as string | undefined, + } +} + export async function loadConfig(cwd: string, configPath?: string): Promise { return loadConfigFile({ cwd, configPath, defaultFileName: 'openapi-server.config.json', - parse: (raw) => { - const framework = raw['framework'] - if ( - framework !== undefined && - framework !== 'hono' && - framework !== 'express' && - framework !== 'fastify' && - framework !== 'none' - ) { - throw new Error('"framework" must be one of: "hono", "express", "fastify", or "none"') - } - if ( - raw['input_schema'] !== undefined && - (typeof raw['input_schema'] !== 'string' || !raw['input_schema']) - ) { - throw new Error('"input_schema" must be a non-empty string path to your Zod schema file') - } - return { - input_openapi: raw['input_openapi'] as string, - output: raw['output'] as string, - framework: framework as 'hono' | 'express' | 'fastify' | 'none' | undefined, - input_schema: raw['input_schema'] as string | undefined, - } - }, + parse: parseServerConfig, + }) +} + +/** + * Load a config file and return all configs as a normalized array. + * + * Single-spec config: returns a one-element array. + * Multi-spec config with "projects" key: returns N-element array. + */ +export async function loadConfigs(cwd: string, configPath?: string): Promise { + return loadConfigsFile({ + cwd, + configPath, + defaultFileName: 'openapi-server.config.json', + parse: parseServerConfig, }) } diff --git a/packages/openapi-server/src/generator.ts b/packages/openapi-server/src/generator.ts index e9e9909..b2ada64 100644 --- a/packages/openapi-server/src/generator.ts +++ b/packages/openapi-server/src/generator.ts @@ -1,95 +1,102 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join, relative, resolve } from 'node:path' -import { loadConfig } from './config.js' +import { parseSpec } from 'openapi-zod-ts' +import { runProjects } from 'openapi-zod-ts/config-core' +import { loadConfigs, type ServerConfig } from './config.js' +import { generateService } from './plugins/service.js' +import { generateRouter, generateExpressRouter, generateFastifyRouter } from './plugins/router.js' async function formatTs(content: string, filePath: string): Promise { const { format, resolveConfig } = await import('prettier') const config = await resolveConfig(filePath) return format(content, { ...config, parser: 'typescript' }) } -import { parseSpec } from 'openapi-zod-ts' -import { generateService } from './plugins/service.js' -import { generateRouter, generateExpressRouter, generateFastifyRouter } from './plugins/router.js' -// fallow-ignore-next-line complexity -export async function generate(cwd: string, configPath?: string): Promise { - console.log('Loading config...') - const config = await loadConfig(cwd, configPath) +/** Pick the framework-specific router generator for a first or second pass. */ +function buildRouterFile( + spec: Awaited>, + framework: 'hono' | 'express' | 'fastify', + options?: Parameters[1] +): ReturnType { + if (framework === 'hono') return generateRouter(spec, options) + if (framework === 'express') return generateExpressRouter(spec, options) + return generateFastifyRouter(spec, options) +} +// fallow-ignore-next-line complexity +async function generateOne(cwd: string, config: ServerConfig, label?: string): Promise { const inputPath = resolve(cwd, config.input_openapi) const outputDir = resolve(cwd, config.output) const framework = config.framework ?? 'none' + const prefix = label !== undefined ? `[${label}] ` : '' - console.log(`Parsing spec: ${inputPath}`) + console.log(`${prefix}Parsing spec: ${inputPath}`) const spec = await parseSpec(inputPath) - const generatedFiles = [] - - generatedFiles.push(generateService(spec)) + const generatedFiles = [generateService(spec)] - if (framework === 'hono') { - // First pass: generate router without schema validation - generatedFiles.push(generateRouter(spec)) - } else if (framework === 'express') { - // First pass: generate router without schema validation - generatedFiles.push(generateExpressRouter(spec)) - } else if (framework === 'fastify') { - // First pass: generate router without schema validation - generatedFiles.push(generateFastifyRouter(spec)) + if (framework !== 'none') { + generatedFiles.push(buildRouterFile(spec, framework)) } - console.log(`Writing output to: ${outputDir}`) + console.log(`${prefix}Writing output to: ${outputDir}`) await mkdir(outputDir, { recursive: true }) for (const file of generatedFiles) { const filePath = join(outputDir, file.filename) await writeFile(filePath, await formatTs(file.content, filePath), 'utf-8') - console.log(` ✓ ${file.filename}`) + console.log(`${prefix} ✓ ${file.filename}`) } // Second pass: if input_schema is configured and file exists, re-generate router with Zod validation - if ( - (framework === 'hono' || framework === 'express' || framework === 'fastify') && - config.input_schema !== undefined - ) { - const schemaPath = resolve(cwd, config.input_schema) - let schemaContent: string - try { - schemaContent = await readFile(schemaPath, 'utf-8') - } catch { - console.log(` ℹ input_schema not found at ${schemaPath}, skipping Zod validation`) - console.log(`Done! Generated ${generatedFiles.length} file(s).`) - return - } - - // Extract exported schema names from the schema file - const exportedSchemas = new Set() - for (const match of schemaContent.matchAll(/^export\s+const\s+(\w+Schema)\b/gm)) { - exportedSchemas.add(match[1]!) - } - - if (exportedSchemas.size > 0) { - // Compute relative import path from outputDir to schemaPath - const relPath = relative(outputDir, schemaPath).replace(/\\/g, '/') - // Ensure it starts with ./ or ../ - const schemaImportPath = relPath.startsWith('.') ? relPath : `./${relPath}` - // Strip .ts extension for import (use .js for NodeNext compatibility) - const schemaImportPathJs = schemaImportPath.replace(/\.ts$/, '.js') - - const routerOptions = { schemaNames: exportedSchemas, schemaImportPath: schemaImportPathJs } - let routerFile - if (framework === 'hono') { - routerFile = generateRouter(spec, routerOptions) - } else if (framework === 'express') { - routerFile = generateExpressRouter(spec, routerOptions) - } else { - routerFile = generateFastifyRouter(spec, routerOptions) - } - const routerPath = join(outputDir, routerFile.filename) - await writeFile(routerPath, await formatTs(routerFile.content, routerPath), 'utf-8') - console.log(` ✓ router.ts (with Zod validation for ${exportedSchemas.size} schema(s))`) - } + if (framework !== 'none' && config.input_schema !== undefined) { + await generateSchemaEnhancedRouter(cwd, config, spec, framework, outputDir, prefix) } - console.log(`Done! Generated ${generatedFiles.length} file(s).`) + console.log(`${prefix}Done! Generated ${generatedFiles.length} file(s).`) +} + +async function generateSchemaEnhancedRouter( + cwd: string, + config: ServerConfig, + spec: Awaited>, + framework: 'hono' | 'express' | 'fastify', + outputDir: string, + prefix: string +): Promise { + const schemaPath = resolve(cwd, config.input_schema!) + let schemaContent: string + try { + schemaContent = await readFile(schemaPath, 'utf-8') + } catch { + console.log(`${prefix} input_schema not found at ${schemaPath}, skipping Zod validation`) + return + } + + // Extract exported schema names from the schema file + const exportedSchemas = new Set() + for (const match of schemaContent.matchAll(/^export\s+const\s+(\w+Schema)\b/gm)) { + exportedSchemas.add(match[1]!) + } + + if (exportedSchemas.size === 0) return + + // Compute relative import path from outputDir to schemaPath + const relPath = relative(outputDir, schemaPath).replace(/\\/g, '/') + const schemaImportPath = relPath.startsWith('.') ? relPath : `./${relPath}` + const schemaImportPathJs = schemaImportPath.replace(/\.ts$/, '.js') + + const routerFile = buildRouterFile(spec, framework, { + schemaNames: exportedSchemas, + schemaImportPath: schemaImportPathJs, + }) + const routerPath = join(outputDir, routerFile.filename) + await writeFile(routerPath, await formatTs(routerFile.content, routerPath), 'utf-8') + console.log(`${prefix} ✓ router.ts (with Zod validation for ${exportedSchemas.size} schema(s))`) +} + +export async function generate(cwd: string, configPath?: string): Promise { + console.log('Loading config...') + const configs = await loadConfigs(cwd, configPath) + await runProjects(configs, (config, label) => generateOne(cwd, config, label)) } diff --git a/packages/openapi-server/src/index.ts b/packages/openapi-server/src/index.ts index 75c74a2..1500e23 100644 --- a/packages/openapi-server/src/index.ts +++ b/packages/openapi-server/src/index.ts @@ -1,5 +1,5 @@ export type { ServerConfig } from './config.js' -export { loadConfig } from './config.js' +export { loadConfig, loadConfigs } from './config.js' export { generateService } from './plugins/service.js' export { generateRouter } from './plugins/router.js' export { generate } from './generator.js' diff --git a/packages/openapi-zod-ts/README.md b/packages/openapi-zod-ts/README.md index cc014cd..e33ee7d 100644 --- a/packages/openapi-zod-ts/README.md +++ b/packages/openapi-zod-ts/README.md @@ -308,6 +308,52 @@ The `defineConfig` helper is a typed identity function. It adds no runtime behav --- +## Multi-spec projects + +One config file can drive generation for multiple OpenAPI specs using the `projects` key. Each entry is a full config object and is generated sequentially. + +```js +// openapi-zod-ts.config.mjs +import { defineProjects } from 'openapi-zod-ts' + +export default defineProjects([ + { + input_openapi: './services/users/openapi.json', + output: './src/users', + baseUrl: 'https://users.example.com', + }, + { + input_openapi: './services/orders/openapi.json', + output: './src/orders', + baseUrl: 'https://orders.example.com', + }, +]) +``` + +Run with: + +```bash +npx openapi-zod-ts --config ./openapi-zod-ts.config.mjs +``` + +Output shows progress per project: + +``` +[1/2] generating services/users/openapi.json... +[1/2] Writing output to: src/users +... +[2/2] generating services/orders/openapi.json... +[2/2] Writing output to: src/orders +... +All 2 projects generated successfully. +``` + +The `projects` key is mutually exclusive with top-level `input_openapi`/`output`. Having both in the same config is a validation error. + +If you only need a single spec, continue using the flat `defineConfig` form. The `loadConfigs` function is also exported and returns a normalized `Config[]` for programmatic use: a single-spec config returns a one-element array, a projects config returns N elements. + +--- + ## Error handling See [error handling](https://openapi.codewithagents.de#error-handling) in the docs for narrowing `ApiError.body` and the global `onError` hook. diff --git a/packages/openapi-zod-ts/src/__tests__/config.test.ts b/packages/openapi-zod-ts/src/__tests__/config.test.ts index 9ac95c1..bbde62d 100644 --- a/packages/openapi-zod-ts/src/__tests__/config.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/config.test.ts @@ -1,13 +1,17 @@ +// fallow-ignore-file code-duplication import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { runProjects } from '../config-core.js' import { loadConfig, + loadConfigs, validateConfigPath, validateOutputPath, validateInputPath, defineConfig, + defineProjects, } from '../config.js' describe('loadConfig', () => { @@ -352,3 +356,204 @@ describe('loadConfig JS files', () => { await expect(loadConfig(tmpDir, configPath)).rejects.toThrow('Config file must be a .json') }) }) + +describe('loadConfigs: projects array support', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'openapi-zod-ts-multi-config-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeConfig(content: unknown) { + writeFileSync(join(tmpDir, 'openapi-zod-ts.config.json'), JSON.stringify(content)) + } + + it('returns a one-element array for a single-spec config', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/api' }) + const configs = await loadConfigs(tmpDir) + expect(configs).toHaveLength(1) + expect(configs[0]!.input_openapi).toBe('openapi.json') + expect(configs[0]!.output).toBe('src/api') + }) + + it('returns N configs for a projects array with N entries', async () => { + writeConfig({ + projects: [ + { input_openapi: 'services/users.json', output: 'src/users' }, + { input_openapi: 'services/orders.json', output: 'src/orders' }, + ], + }) + const configs = await loadConfigs(tmpDir) + expect(configs).toHaveLength(2) + expect(configs[0]!.input_openapi).toBe('services/users.json') + expect(configs[0]!.output).toBe('src/users') + expect(configs[1]!.input_openapi).toBe('services/orders.json') + expect(configs[1]!.output).toBe('src/orders') + }) + + it('parses optional fields in each project entry', async () => { + writeConfig({ + projects: [ + { + input_openapi: 'services/users.json', + output: 'src/users', + baseUrl: 'https://users.example.com', + server_client: true, + }, + { input_openapi: 'services/orders.json', output: 'src/orders' }, + ], + }) + const configs = await loadConfigs(tmpDir) + expect(configs[0]!.baseUrl).toBe('https://users.example.com') + expect(configs[0]!.server_client).toBe(true) + expect(configs[1]!.baseUrl).toBeUndefined() + }) + + it('throws when both top-level input_openapi and projects are present', async () => { + writeConfig({ + input_openapi: 'openapi.json', + output: 'src/api', + projects: [{ input_openapi: 'services/users.json', output: 'src/users' }], + }) + await expect(loadConfigs(tmpDir)).rejects.toThrow( + 'Config cannot have both top-level "input_openapi"/"output" and a "projects" array' + ) + }) + + it('throws when projects is not an array', async () => { + writeConfig({ projects: 'not-an-array' }) + await expect(loadConfigs(tmpDir)).rejects.toThrow('"projects" must be an array') + }) + + it('throws when projects array is empty', async () => { + writeConfig({ projects: [] }) + await expect(loadConfigs(tmpDir)).rejects.toThrow( + '"projects" array must contain at least one config entry' + ) + }) + + it('throws when a project entry is missing input_openapi', async () => { + writeConfig({ + projects: [ + { input_openapi: 'services/users.json', output: 'src/users' }, + { output: 'src/orders' }, + ], + }) + await expect(loadConfigs(tmpDir)).rejects.toThrow('projects[1]') + }) + + it('throws when a project entry is missing output', async () => { + writeConfig({ + projects: [{ input_openapi: 'services/users.json' }], + }) + await expect(loadConfigs(tmpDir)).rejects.toThrow('projects[0]') + }) + + it('throws when a project entry has an invalid optional field', async () => { + writeConfig({ + projects: [ + { + input_openapi: 'services/users.json', + output: 'src/users', + server_client: 'yes', + }, + ], + }) + await expect(loadConfigs(tmpDir)).rejects.toThrow('projects[0]') + }) + + it('works with a .mjs file exporting a projects array via defineProjects', async () => { + const configPath = join(tmpDir, 'openapi-zod-ts.config.mjs') + writeFileSync( + configPath, + [ + 'export default {', + ' projects: [', + " { input_openapi: 'services/alpha.json', output: 'src/alpha' },", + " { input_openapi: 'services/beta.json', output: 'src/beta' },", + ' ],', + '}', + ].join('\n') + ) + const configs = await loadConfigs(tmpDir, configPath) + expect(configs).toHaveLength(2) + expect(configs[0]!.input_openapi).toBe('services/alpha.json') + expect(configs[1]!.input_openapi).toBe('services/beta.json') + }) +}) + +describe('defineProjects', () => { + it('wraps configs in a projects key object', () => { + const entries = [ + { input_openapi: './users.json', output: './src/users' }, + { input_openapi: './orders.json', output: './src/orders' }, + ] + const result = defineProjects(entries) + expect(result).toEqual({ projects: entries }) + }) + + it('returns an object with projects referencing the same array', () => { + const entries = [{ input_openapi: './spec.json', output: './out' }] + const result = defineProjects(entries) + expect(result.projects).toBe(entries) + }) +}) + +describe('runProjects', () => { + it('calls generateOne without a label for a single-config array', async () => { + const calls: Array<{ input: string; label: string | undefined }> = [] + const configs = [{ input_openapi: 'spec.json', output: 'out' }] + await runProjects(configs, async (config, label) => { + calls.push({ input: config.input_openapi, label }) + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.label).toBeUndefined() + expect(calls[0]!.input).toBe('spec.json') + }) + + it('calls generateOne with "[i/N]" labels for multi-config array in order', async () => { + const calls: Array<{ input: string; label: string | undefined }> = [] + const configs = [ + { input_openapi: 'users.json', output: 'src/users' }, + { input_openapi: 'orders.json', output: 'src/orders' }, + { input_openapi: 'products.json', output: 'src/products' }, + ] + await runProjects(configs, async (config, label) => { + calls.push({ input: config.input_openapi, label }) + }) + expect(calls).toHaveLength(3) + expect(calls[0]!.label).toBe('1/3') + expect(calls[1]!.label).toBe('2/3') + expect(calls[2]!.label).toBe('3/3') + expect(calls.map((c) => c.input)).toEqual(['users.json', 'orders.json', 'products.json']) + }) + + it('fails fast with a labelled error when a project throws', async () => { + const configs = [ + { input_openapi: 'users.json', output: 'src/users' }, + { input_openapi: 'orders.json', output: 'src/orders' }, + { input_openapi: 'products.json', output: 'src/products' }, + ] + const completed: string[] = [] + await expect( + runProjects(configs, async (config, label) => { + if (config.input_openapi === 'orders.json') { + throw new Error('parse error') + } + completed.push(config.input_openapi) + void label + }) + ).rejects.toThrow('[2/3] Project failed (orders.json): parse error') + expect(completed).toEqual(['users.json']) + }) + + it('throws when configs array is empty', async () => { + await expect(runProjects([], async () => {})).rejects.toThrow( + 'runProjects requires at least one config entry' + ) + }) +}) diff --git a/packages/openapi-zod-ts/src/config-core.ts b/packages/openapi-zod-ts/src/config-core.ts index 25cd371..a67416f 100644 --- a/packages/openapi-zod-ts/src/config-core.ts +++ b/packages/openapi-zod-ts/src/config-core.ts @@ -108,48 +108,55 @@ export interface LoadConfigOptions { parse: (raw: Record, base: BaseConfig, cwd: string) => T } -// fallow-ignore-next-line complexity -export async function loadConfigFile(opts: LoadConfigOptions): Promise { - const resolvedConfigPath = opts.configPath ?? join(opts.cwd, opts.defaultFileName) - - if (opts.configPath !== undefined) { - validateConfigPath(opts.configPath) +/** Load and validate a JS/MJS/CJS config file, returning its default export as a raw record. */ +async function loadJsConfig(resolvedConfigPath: string): Promise> { + let mod: unknown + try { + mod = await import(pathToFileURL(resolvedConfigPath).href) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to load JS config file: ${resolvedConfigPath}\n${message}`) + } + const exported = (mod as Record)['default'] ?? mod + if (typeof exported !== 'object' || exported === null) { + throw new Error('Config must be a JSON object') } + return exported as Record +} - let raw: Record +/** Load and validate a JSON config file, returning the parsed object as a raw record. */ +async function loadJsonConfig(resolvedConfigPath: string): Promise> { + let fileContents: string + try { + fileContents = await readFile(resolvedConfigPath, 'utf-8') + } catch { + throw new Error(`Config file not found: ${resolvedConfigPath}`) + } + let parsed: unknown + try { + parsed = JSON.parse(fileContents) + } catch { + throw new Error(`Config file is not valid JSON: ${resolvedConfigPath}`) + } + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Config must be a JSON object') + } + return parsed as Record +} +/** + * Load and parse the raw config object from disk. + * Shared between loadConfigFile and loadConfigsFile. + */ +async function loadRawConfig(resolvedConfigPath: string): Promise> { if (isJsConfigPath(resolvedConfigPath)) { - let mod: unknown - try { - mod = await import(pathToFileURL(resolvedConfigPath).href) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - throw new Error(`Failed to load JS config file: ${resolvedConfigPath}\n${message}`) - } - const exported = (mod as Record)['default'] ?? mod - if (typeof exported !== 'object' || exported === null) { - throw new Error('Config must be a JSON object') - } - raw = exported as Record - } else { - let fileContents: string - try { - fileContents = await readFile(resolvedConfigPath, 'utf-8') - } catch { - throw new Error(`Config file not found: ${resolvedConfigPath}`) - } - let parsed: unknown - try { - parsed = JSON.parse(fileContents) - } catch { - throw new Error(`Config file is not valid JSON: ${resolvedConfigPath}`) - } - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('Config must be a JSON object') - } - raw = parsed as Record + return loadJsConfig(resolvedConfigPath) } + return loadJsonConfig(resolvedConfigPath) +} +/** Parse and validate base fields (input_openapi, output) from a raw config record. */ +function parseBaseConfig(raw: Record, cwd: string): BaseConfig { if (typeof raw['input_openapi'] !== 'string' || !raw['input_openapi']) { throw new Error('Config missing required field: "input_openapi" (path to OpenAPI 3.1 spec)') } @@ -160,9 +167,135 @@ export async function loadConfigFile(opts: LoadConfigOptions): Promise const input_openapi = raw['input_openapi'] as string const output = raw['output'] as string - validateInputPath(resolve(opts.cwd, input_openapi)) - validateOutputPath(resolve(opts.cwd, output)) + validateInputPath(resolve(cwd, input_openapi)) + validateOutputPath(resolve(cwd, output)) + + return { input_openapi, output } +} + +/** Resolve the config path, optionally validate it, and load the raw object. */ +async function prepareRaw(opts: { configPath?: string; cwd: string; defaultFileName: string }): Promise<{ raw: Record }> { + const resolvedConfigPath = opts.configPath ?? join(opts.cwd, opts.defaultFileName) + if (opts.configPath !== undefined) { + validateConfigPath(opts.configPath) + } + return { raw: await loadRawConfig(resolvedConfigPath) } +} - const base: BaseConfig = { input_openapi, output } +// fallow-ignore-next-line complexity +export async function loadConfigFile(opts: LoadConfigOptions): Promise { + const { raw } = await prepareRaw(opts) + const base = parseBaseConfig(raw, opts.cwd) return opts.parse(raw, base, opts.cwd) } + +/** Parse a single project entry from a projects array, wrapping errors with the entry index. */ +function parseProjectEntry( + entry: unknown, + index: number, + opts: LoadConfigOptions +): T { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new Error(`projects[${index}]: entry must be a config object`) + } + const projectRaw = entry as Record + let base: BaseConfig + try { + base = parseBaseConfig(projectRaw, opts.cwd) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`projects[${index}]: ${message}`) + } + try { + return opts.parse(projectRaw, base, opts.cwd) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`projects[${index}]: ${message}`) + } +} + +/** + * Load a config file that may contain either a single-spec config or a + * "projects" array of configs. Returns a normalized array of parsed configs. + * + * When the config root contains a "projects" key, each entry is parsed + * independently. Having both "projects" and top-level single-spec keys + * (input_openapi, output) in the same config is a validation error. + * + * Single-spec configs (no "projects" key) are returned as a one-element array, + * making call sites uniform. + */ +export async function loadConfigsFile(opts: LoadConfigOptions): Promise { + const { raw } = await prepareRaw(opts) + + if ('projects' in raw) { + return parseProjectsArray(raw, opts) + } + + // Single-spec mode: parse as before, return as one-element array. + const base = parseBaseConfig(raw, opts.cwd) + return [opts.parse(raw, base, opts.cwd)] +} + +function parseProjectsArray( + raw: Record, + opts: LoadConfigOptions +): T[] { + const hasTopLevelInput = 'input_openapi' in raw && raw['input_openapi'] !== undefined + const hasTopLevelOutput = 'output' in raw && raw['output'] !== undefined + if (hasTopLevelInput || hasTopLevelOutput) { + throw new Error( + 'Config cannot have both top-level "input_openapi"/"output" and a "projects" array. ' + + 'Use one form or the other.' + ) + } + + const projects = raw['projects'] + if (!Array.isArray(projects)) { + throw new Error('"projects" must be an array of config objects') + } + if (projects.length === 0) { + throw new Error('"projects" array must contain at least one config entry') + } + + return projects.map((entry: unknown, index: number) => + parseProjectEntry(entry, index, opts) + ) +} + +/** + * Run a per-project generation function over an array of configs sequentially. + * + * Single config: calls generateOne without a label (backward-compatible logging). + * Multiple configs: calls generateOne with a "[i/N]" progress label for each, + * logging per-project progress and failing fast with a clear error on any failure. + * + * This is the shared iteration kernel used by all generator packages to avoid + * duplicating the sequential-loop, logging, and fail-fast error-wrapping logic. + */ +export async function runProjects( + configs: T[], + generateOne: (config: T, label?: string) => Promise +): Promise { + if (configs.length === 0) { + throw new Error('runProjects requires at least one config entry') + } + + if (configs.length === 1) { + await generateOne(configs[0]!) + return + } + + for (let i = 0; i < configs.length; i++) { + const label = `${i + 1}/${configs.length}` + console.log(`\n[${label}] generating ${configs[i]!.input_openapi}...`) + try { + await generateOne(configs[i]!, label) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`[${label}] Project failed (${configs[i]!.input_openapi}): ${message}`) + } + } + + console.log(`\nAll ${configs.length} projects generated successfully.`) +} diff --git a/packages/openapi-zod-ts/src/config.ts b/packages/openapi-zod-ts/src/config.ts index 984c00a..d76bed5 100644 --- a/packages/openapi-zod-ts/src/config.ts +++ b/packages/openapi-zod-ts/src/config.ts @@ -1,5 +1,6 @@ import { loadConfigFile, + loadConfigsFile, validateConfigPath, validateInputPath, validateOutputPath, @@ -31,27 +32,62 @@ export function defineConfig(config: Config): Config { return config } +/** + * Typed helper for multi-spec config files. Use in JS/TS config files to get + * autocomplete and type-checking for each project entry. + * + * Example (openapi-zod-ts.config.mjs): + * import { defineProjects } from 'openapi-zod-ts' + * export default defineProjects([ + * { input_openapi: './services/users.json', output: './src/users' }, + * { input_openapi: './services/orders.json', output: './src/orders' }, + * ]) + */ +export function defineProjects(configs: Config[]): { projects: Config[] } { + return { projects: configs } +} + +function parseConfig(raw: Record, base: import('./config-core.js').BaseConfig): Config { + if ( + raw['input_schema'] !== undefined && + (typeof raw['input_schema'] !== 'string' || !raw['input_schema']) + ) { + throw new Error('"input_schema" must be a non-empty string path to your Zod schema file') + } + if (raw['server_client'] !== undefined && typeof raw['server_client'] !== 'boolean') { + throw new Error('"server_client" must be a boolean') + } + return { + ...base, + input_schema: raw['input_schema'] as string | undefined, + baseUrl: typeof raw['baseUrl'] === 'string' ? raw['baseUrl'] : undefined, + server_client: raw['server_client'] as boolean | undefined, + } +} + export async function loadConfig(cwd: string, configPath?: string): Promise { return loadConfigFile({ cwd, configPath, defaultFileName: 'openapi-zod-ts.config.json', - parse: (raw, base) => { - if ( - raw['input_schema'] !== undefined && - (typeof raw['input_schema'] !== 'string' || !raw['input_schema']) - ) { - throw new Error('"input_schema" must be a non-empty string path to your Zod schema file') - } - if (raw['server_client'] !== undefined && typeof raw['server_client'] !== 'boolean') { - throw new Error('"server_client" must be a boolean') - } - return { - ...base, - input_schema: raw['input_schema'] as string | undefined, - baseUrl: typeof raw['baseUrl'] === 'string' ? raw['baseUrl'] : undefined, - server_client: raw['server_client'] as boolean | undefined, - } - }, + parse: parseConfig, + }) +} + +/** + * Load a config file and return all configs as a normalized array. + * + * Single-spec config: returns a one-element array. + * Multi-spec config with "projects" key: returns N-element array. + * + * This is the preferred API for generator call sites that need to handle + * both single-spec and multi-spec configs uniformly. + */ +export async function loadConfigs(cwd: string, configPath?: string): Promise { + return loadConfigsFile({ + cwd, + configPath, + defaultFileName: 'openapi-zod-ts.config.json', + parse: parseConfig, }) } diff --git a/packages/openapi-zod-ts/src/generator.ts b/packages/openapi-zod-ts/src/generator.ts index 300e357..ecda10b 100644 --- a/packages/openapi-zod-ts/src/generator.ts +++ b/packages/openapi-zod-ts/src/generator.ts @@ -1,6 +1,7 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { join, relative, resolve } from 'node:path' -import { loadConfig, type Config } from './config.js' +import { loadConfig, loadConfigs, type Config } from './config.js' +import { runProjects } from './config-core.js' import { parseSpec } from './parser.js' import { generateTypes } from './plugins/types.js' import { generateClientConfig } from './plugins/client-config.js' @@ -38,36 +39,27 @@ function applyOverrides(config: Config, opts: GenerateOptions): Config { return result } +/** + * Run generation for a single resolved config. Used internally by generate() + * for both single-spec and each project in a multi-spec config. + * The optional label prefix is shown in log output when running multiple projects. + */ // fallow-ignore-next-line complexity -export async function generate(cwd: string, opts?: GenerateOptions | string): Promise { - // Back-compat: accept a plain configPath string as second arg (old call sites). - const options: GenerateOptions = typeof opts === 'string' ? { configPath: opts } : (opts ?? {}) - - console.log('Loading config...') - - // When --input AND --output are both provided we can skip loading a config file entirely. - const skipConfig = - options.inputOverride !== undefined && - options.outputOverride !== undefined && - options.configPath === undefined - - let config: Config - if (skipConfig) { - config = { - input_openapi: options.inputOverride as string, - output: options.outputOverride as string, - } - } else { - config = applyOverrides(await loadConfig(cwd, options.configPath), options) - } - +async function generateOne( + cwd: string, + config: Config, + opts: GenerateOptions, + label?: string +): Promise { // When overrides supply absolute paths, resolve them directly; otherwise resolve from cwd. const inputPath = - options.inputOverride !== undefined ? options.inputOverride : resolve(cwd, config.input_openapi) + opts.inputOverride !== undefined ? opts.inputOverride : resolve(cwd, config.input_openapi) const outputDir = - options.outputOverride !== undefined ? options.outputOverride : resolve(cwd, config.output) + opts.outputOverride !== undefined ? opts.outputOverride : resolve(cwd, config.output) - console.log(`Parsing spec: ${inputPath}`) + const prefix = label !== undefined ? `[${label}] ` : '' + + console.log(`${prefix}Parsing spec: ${inputPath}`) const spec = await parseSpec(inputPath) // Build the writable-variant map exactly once so types.ts and client.ts share @@ -91,13 +83,13 @@ export async function generate(cwd: string, opts?: GenerateOptions | string): Pr generatedFiles.push(generateClient(spec, undefined, writableVariantMap)) generatedFiles.push(generateIndexBarrel()) - console.log(`Writing output to: ${outputDir}`) + console.log(`${prefix}Writing output to: ${outputDir}`) await mkdir(outputDir, { recursive: true }) for (const file of generatedFiles) { const filePath = join(outputDir, file.filename) await writeFile(filePath, await formatTs(file.content, filePath), 'utf-8') - console.log(` ✓ ${file.filename}`) + console.log(`${prefix} ✓ ${file.filename}`) } // Phase 3: optional server client factory @@ -105,77 +97,132 @@ export async function generate(cwd: string, opts?: GenerateOptions | string): Pr const serverFile = generateServer(spec) const serverFilePath = join(outputDir, serverFile.filename) await writeFile(serverFilePath, await formatTs(serverFile.content, serverFilePath), 'utf-8') - console.log(` ✓ ${serverFile.filename}`) + console.log(`${prefix} ✓ ${serverFile.filename}`) } // Phase 4: Zod schema bootstrap. Write once, never overwrite. if (config.input_schema !== undefined) { - const schemaPath = resolve(cwd, config.input_schema) - let schemaExists = false - try { - await access(schemaPath) - schemaExists = true - } catch { - // file does not exist, bootstrap it - } + await generateZodIntegration(cwd, config, spec, outputDir, prefix, writableVariantMap) + } - if (schemaExists) { - console.log(`Skipping ${config.input_schema}: already exists (edit freely, it's yours).`) + console.log(`${prefix}Done! Generated ${generatedFiles.length} file(s).`) +} - // Phase 5: Schema-enhanced generation. Re-generate models.ts and client.ts with Zod integration. - const content = await readFile(schemaPath, 'utf-8') - const exportedSchemas = new Set() - for (const match of content.matchAll(/^export\s+const\s+(\w+Schema)\b/gm)) { - exportedSchemas.add(match[1]!) - } +// fallow-ignore-next-line complexity +async function generateZodIntegration( + cwd: string, + config: Config, + spec: Awaited>, + outputDir: string, + prefix: string, + writableVariantMap: ReturnType +): Promise { + const schemaPath = resolve(cwd, config.input_schema!) + let schemaExists = false + try { + await access(schemaPath) + schemaExists = true + } catch { + // file does not exist, bootstrap it + } + + if (schemaExists) { + console.log( + `${prefix}Skipping ${config.input_schema}: already exists (edit freely, it's yours).` + ) + + // Phase 5: Schema-enhanced generation. Re-generate models.ts and client.ts with Zod integration. + const content = await readFile(schemaPath, 'utf-8') + const exportedSchemas = new Set() + for (const match of content.matchAll(/^export\s+const\s+(\w+Schema)\b/gm)) { + exportedSchemas.add(match[1]!) + } - // Drift detection: warn to stderr for missing schemas. - const specSchemaNames = Object.keys(spec.components?.schemas ?? {}) - for (const name of specSchemaNames) { - if (!exportedSchemas.has(`${name}Schema`)) { - console.warn( - `⚠ Drift: ${name}Schema is in the OpenAPI spec but not found in ${config.input_schema}. Run with --reset-schema to re-bootstrap.` - ) - } + // Drift detection: warn to stderr for missing schemas. + const specSchemaNames = Object.keys(spec.components?.schemas ?? {}) + for (const name of specSchemaNames) { + if (!exportedSchemas.has(`${name}Schema`)) { + console.warn( + `${prefix}Drift: ${name}Schema is in the OpenAPI spec but not found in ${config.input_schema}. Run with --reset-schema to re-bootstrap.` + ) } + } + + // Compute relative import path for use in generated imports + const relPath = relative(outputDir, schemaPath) + // 'schemas.ts' -> './schemas.js', '../schemas.ts' -> '../schemas.js' + const schemaImportPath = + (relPath.startsWith('.') ? '' : './') + relPath.replace(/\.ts$/, '.js') + + // Re-generate (overwrite) models.ts and client.ts with schema-enhanced versions + const enhancedTypes = generateTypes( + spec, + { schemaNames: exportedSchemas, schemaImportPath }, + writableVariantMap + ) + const enhancedClient = generateClient( + spec, + { schemaNames: exportedSchemas, schemaImportPath }, + writableVariantMap + ) + const enhancedTypesPath = join(outputDir, enhancedTypes.filename) + const enhancedClientPath = join(outputDir, enhancedClient.filename) + await writeFile( + enhancedTypesPath, + await formatTs(enhancedTypes.content, enhancedTypesPath), + 'utf-8' + ) + await writeFile( + enhancedClientPath, + await formatTs(enhancedClient.content, enhancedClientPath), + 'utf-8' + ) + console.log(`${prefix} ✓ models.ts (schema-enhanced, types from z.infer)`) + console.log(`${prefix} ✓ client.ts (schema-enhanced, Zod validation added)`) + } else { + const zodFile = generateZodSchemas(spec) + await writeFile(schemaPath, zodFile.content, 'utf-8') + console.log( + `${prefix} ✓ ${config.input_schema} (bootstrapped: edit freely, won't be overwritten)` + ) + } +} + +// fallow-ignore-next-line complexity +export async function generate(cwd: string, opts?: GenerateOptions | string): Promise { + // Back-compat: accept a plain configPath string as second arg (old call sites). + const options: GenerateOptions = typeof opts === 'string' ? { configPath: opts } : (opts ?? {}) - // Compute relative import path for use in generated imports - const relPath = relative(outputDir, schemaPath) - // 'schemas.ts' -> './schemas.js', '../schemas.ts' -> '../schemas.js' - const schemaImportPath = - (relPath.startsWith('.') ? '' : './') + relPath.replace(/\.ts$/, '.js') - - // Re-generate (overwrite) models.ts and client.ts with schema-enhanced versions - const enhancedTypes = generateTypes( - spec, - { schemaNames: exportedSchemas, schemaImportPath }, - writableVariantMap - ) - const enhancedClient = generateClient( - spec, - { schemaNames: exportedSchemas, schemaImportPath }, - writableVariantMap - ) - const enhancedTypesPath = join(outputDir, enhancedTypes.filename) - const enhancedClientPath = join(outputDir, enhancedClient.filename) - await writeFile( - enhancedTypesPath, - await formatTs(enhancedTypes.content, enhancedTypesPath), - 'utf-8' - ) - await writeFile( - enhancedClientPath, - await formatTs(enhancedClient.content, enhancedClientPath), - 'utf-8' - ) - console.log(` ✓ models.ts (schema-enhanced, types from z.infer)`) - console.log(` ✓ client.ts (schema-enhanced, Zod validation added)`) - } else { - const zodFile = generateZodSchemas(spec) - await writeFile(schemaPath, zodFile.content, 'utf-8') - console.log(` ✓ ${config.input_schema} (bootstrapped: edit freely, won't be overwritten)`) + console.log('Loading config...') + + // When --input AND --output are both provided we can skip loading a config file entirely. + const skipConfig = + options.inputOverride !== undefined && + options.outputOverride !== undefined && + options.configPath === undefined + + if (skipConfig) { + const config: Config = { + input_openapi: options.inputOverride as string, + output: options.outputOverride as string, } + await generateOne(cwd, config, options) + return + } + + // Overrides are incompatible with multi-spec "projects" array configs. When overrides + // are present we fall back to single-spec loading so overrides apply to a single config. + const hasOverrides = + options.inputOverride !== undefined || options.outputOverride !== undefined + + if (hasOverrides) { + const config = applyOverrides(await loadConfig(cwd, options.configPath), options) + await generateOne(cwd, config, options) + return } - console.log(`Done! Generated ${generatedFiles.length} file(s).`) + // No overrides: use the normalized loadConfigs API to support both single-spec + // and multi-spec ("projects" array) configs uniformly. + const configs = await loadConfigs(cwd, options.configPath) + await runProjects(configs, (config, label) => generateOne(cwd, config, options, label)) } diff --git a/packages/openapi-zod-ts/src/index.ts b/packages/openapi-zod-ts/src/index.ts index ba6536b..8ea9985 100644 --- a/packages/openapi-zod-ts/src/index.ts +++ b/packages/openapi-zod-ts/src/index.ts @@ -1,5 +1,5 @@ export type { Config } from './config.js' -export { loadConfig, defineConfig } from './config.js' +export { loadConfig, loadConfigs, defineConfig, defineProjects } from './config.js' export { parseSpec } from './parser.js' export type { GeneratedFile } from './plugins/types.js' export { generateTypes } from './plugins/types.js'