fork of hey-api/openapi-ts because I need some additional things

feat: clean up multi-config matrix

Lubos 4bb0b94d 41900bdd

Changed files
+703 -511
packages
+3 -1
packages/nuxt/src/module.ts
··· 60 60 config.watch = false; 61 61 } 62 62 63 + const output = 64 + config.output instanceof Array ? config.output[0] : config.output; 63 65 const folder = path.resolve( 64 66 nuxt.options.rootDir, 65 - typeof config.output === 'string' ? config.output : config.output.path, 67 + typeof output === 'string' ? output : (output?.path ?? ''), 66 68 ); 67 69 68 70 nuxt.options.alias[options.alias!] = folder;
+36 -27
packages/openapi-ts-tests/main/test/openapi-ts.config.ts
··· 18 18 // experimentalParser: false, 19 19 input: [ 20 20 { 21 - // branch: 'main', 22 21 // fetch: { 23 22 // headers: { 24 23 // 'x-foo': 'bar', 25 24 // }, 26 25 // }, 27 - // organization: 'hey-api', 28 26 // path: { 29 27 // components: {}, 30 28 // info: { ··· 50 48 // 'validators-circular-ref-2.yaml', 51 49 // 'zoom-video-sdk.json', 52 50 ), 53 - // path: 'scalar:@scalar/access-service', 54 - // path: 'hey-api/backend', 55 - // path: 'hey-api/backend?branch=main&version=1.0.0', 56 51 // path: 'https://get.heyapi.dev/hey-api/backend?branch=main&version=1.0.0', 57 52 // path: 'http://localhost:4000/', 58 53 // path: 'http://localhost:8000/openapi.json', 59 54 // path: 'https://mongodb-mms-prod-build-server.s3.amazonaws.com/openapi/2caffd88277a4e27c95dcefc7e3b6a63a3b03297-v2-2023-11-15.json', 60 55 // path: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', 61 - // project: 'backend', 62 - // project: 'upload-openapi-spec', 63 - // version: '1.0.0', 64 56 // watch: { 65 57 // enabled: true, 66 58 // interval: 500, 67 59 // timeout: 30_000, 68 60 // }, 69 61 }, 70 - path.resolve(getSpecsPath(), '3.1.x', 'full.yaml'), 62 + // path.resolve(getSpecsPath(), '3.1.x', 'full.yaml'), 63 + // { 64 + // branch: 'main', 65 + // organization: 'hey-api', 66 + // path: 'hey-api/backend', 67 + // project: 'backend', 68 + // project: 'upload-openapi-spec', 69 + // version: '1.0.0', 70 + // }, 71 + // 'hey-api/backend?branch=main&version=1.0.0', 72 + // 'scalar:@scalar/access-service', 73 + // 'readme:@developers/v2.0#nysezql0wwo236', 74 + // 'readme:nysezql0wwo236', 75 + // 'https://dash.readme.com/api/v1/api-registry/nysezql0wwo236', 76 + // 'https://somefakedomain.com/openapi.yaml', 71 77 ], 72 78 logs: { 73 - level: 'debug', 79 + // level: 'debug', 74 80 path: './logs', 75 81 }, 76 82 // name: 'foo', 77 - output: { 78 - // case: 'snake_case', 79 - clean: true, 80 - fileName: { 83 + output: [ 84 + { 81 85 // case: 'snake_case', 82 - // name: '{{name}}.renamed', 83 - suffix: '.meh', 86 + clean: true, 87 + fileName: { 88 + // case: 'snake_case', 89 + // name: '{{name}}.renamed', 90 + suffix: '.meh', 91 + }, 92 + // format: 'prettier', 93 + importFileExtension: '.ts', 94 + // indexFile: false, 95 + // lint: 'eslint', 96 + path: path.resolve(__dirname, 'generated', 'sample'), 97 + tsConfigPath: path.resolve( 98 + __dirname, 99 + 'tsconfig', 100 + 'tsconfig.nodenext.json', 101 + ), 84 102 }, 85 - // format: 'prettier', 86 - importFileExtension: '.ts', 87 - // indexFile: false, 88 - // lint: 'eslint', 89 - path: path.resolve(__dirname, 'generated', 'sample'), 90 - tsConfigPath: path.resolve( 91 - __dirname, 92 - 'tsconfig', 93 - 'tsconfig.nodenext.json', 94 - ), 95 - }, 103 + // path.resolve(__dirname, 'generated', 'sample2'), 104 + ], 96 105 parser: { 97 106 filters: { 98 107 // deprecated: false,
+2 -1
packages/openapi-ts/bin/index.cjs
··· 36 36 .option('-s, --silent', 'Set log level to silent') 37 37 .option( 38 38 '--no-log-file', 39 - 'Disable writing a log file. Works like --silent but without supressing console output', 39 + 'Disable writing a log file. Works like --silent but without suppressing console output', 40 40 ) 41 41 .option( 42 42 '-w, --watch [value]', ··· 119 119 } 120 120 121 121 userConfig.logs.file = userConfig.logFile; 122 + delete userConfig.logFile; 122 123 123 124 if (typeof params.watch === 'string') { 124 125 userConfig.watch = Number.parseInt(params.watch, 10);
+4
packages/openapi-ts/src/__tests__/createClient.test.ts
··· 34 34 it('with platform string', () => { 35 35 const path = compileInputPath({ 36 36 path: 'https://get.heyapi.dev/foo/bar?branch=main&commit_sha=sha&tags=a,b,c&version=1.0.0', 37 + registry: 'hey-api', 37 38 }); 38 39 expect(path).toEqual({ 39 40 branch: 'main', ··· 41 42 organization: 'foo', 42 43 path: 'https://get.heyapi.dev/foo/bar?branch=main&commit_sha=sha&tags=a,b,c&version=1.0.0', 43 44 project: 'bar', 45 + registry: 'hey-api', 44 46 tags: ['a', 'b', 'c'], 45 47 version: '1.0.0', 46 48 }); ··· 71 73 process.env.HEY_API_TOKEN = 'foo'; 72 74 const path = compileInputPath({ 73 75 path: 'https://get.heyapi.dev/foo/bar', 76 + registry: 'hey-api', 74 77 }); 75 78 delete process.env.HEY_API_TOKEN; 76 79 expect(path).toEqual({ ··· 78 81 organization: 'foo', 79 82 path: 'https://get.heyapi.dev/foo/bar?api_key=foo', 80 83 project: 'bar', 84 + registry: 'hey-api', 81 85 }); 82 86 }); 83 87 });
+20 -14
packages/openapi-ts/src/__tests__/interactive.test.ts
··· 5 5 6 6 describe('interactive config', () => { 7 7 it('should use detectInteractiveSession when not provided', async () => { 8 - const result = await initConfigs({ 9 - input: 'test.json', 10 - output: './test', 11 - }); 8 + const result = await initConfigs([ 9 + { 10 + input: 'test.json', 11 + output: './test', 12 + }, 13 + ]); 12 14 13 15 // In test environment, TTY is typically not available, so it should be false 14 16 expect(result.results[0]?.config.interactive).toBe(false); 15 17 }); 16 18 17 19 it('should respect user config when set to true', async () => { 18 - const result = await initConfigs({ 19 - input: 'test.json', 20 - interactive: true, 21 - output: './test', 22 - }); 20 + const result = await initConfigs([ 21 + { 22 + input: 'test.json', 23 + interactive: true, 24 + output: './test', 25 + }, 26 + ]); 23 27 24 28 expect(result.results[0]?.config.interactive).toBe(true); 25 29 }); 26 30 27 31 it('should respect user config when set to false', async () => { 28 - const result = await initConfigs({ 29 - input: 'test.json', 30 - interactive: false, 31 - output: './test', 32 - }); 32 + const result = await initConfigs([ 33 + { 34 + input: 'test.json', 35 + interactive: false, 36 + output: './test', 37 + }, 38 + ]); 33 39 34 40 expect(result.results[0]?.config.interactive).toBe(false); 35 41 });
+11 -11
packages/openapi-ts/src/config/__tests__/input.test.ts
··· 11 11 output: 'src/client', 12 12 }; 13 13 14 - const result = getInput(userConfig); 14 + const result = getInput(userConfig)[0]!; 15 15 expect(result.path).toBe('https://example.com/openapi.yaml'); 16 16 }); 17 17 ··· 21 21 output: 'src/client', 22 22 }; 23 23 24 - const result = getInput(userConfig); 24 + const result = getInput(userConfig)[0]!; 25 25 expect(result.path).toBe( 26 26 'https://dash.readme.com/api/v1/api-registry/abc123', 27 27 ); ··· 33 33 output: 'src/client', 34 34 }; 35 35 36 - const result = getInput(userConfig); 36 + const result = getInput(userConfig)[0]!; 37 37 expect(result.path).toBe( 38 38 'https://dash.readme.com/api/v1/api-registry/uuid123', 39 39 ); ··· 45 45 output: 'src/client', 46 46 }; 47 47 48 - const result = getInput(userConfig); 48 + const result = getInput(userConfig)[0]!; 49 49 expect(result.path).toBe( 50 50 'https://dash.readme.com/api/v1/api-registry/test-uuid-123', 51 51 ); ··· 64 64 output: 'src/client', 65 65 }; 66 66 67 - const result = getInput(userConfig); 67 + const result = getInput(userConfig)[0]!; 68 68 expect(result.path).toBe( 69 69 'https://dash.readme.com/api/v1/api-registry/abc123', 70 70 ); ··· 79 79 output: 'src/client', 80 80 }; 81 81 82 - const result = getInput(userConfig); 82 + const result = getInput(userConfig)[0]!; 83 83 expect(result.path).toBe( 84 84 'https://dash.readme.com/api/v1/api-registry/uuid', 85 85 ); ··· 95 95 output: 'src/client', 96 96 }; 97 97 98 - const result = getInput(userConfig); 98 + const result = getInput(userConfig)[0]!; 99 99 expect(result.path).toBe('https://get.heyapi.dev/myorg/myproject'); 100 100 }); 101 101 ··· 108 108 output: 'src/client', 109 109 }; 110 110 111 - const result = getInput(userConfig); 111 + const result = getInput(userConfig)[0]!; 112 112 expect(result.path).toEqual({ 113 113 info: { title: 'Test API', version: '1.0.0' }, 114 114 openapi: '3.0.0', ··· 125 125 126 126 inputs.forEach((input) => { 127 127 const userConfig: UserConfig = { input, output: 'src/client' }; 128 - const result = getInput(userConfig); 128 + const result = getInput(userConfig)[0]!; 129 129 expect(result.path).toBe(input); 130 130 }); 131 131 }); ··· 140 140 }, 141 141 }; 142 142 143 - const result = getInput(userConfig); 143 + const result = getInput(userConfig)[0]!; 144 144 expect(result.path).toBe( 145 145 'https://dash.readme.com/api/v1/api-registry/abc123', 146 146 ); ··· 160 160 output: 'src/client', 161 161 }; 162 162 163 - const result = getInput(userConfig); 163 + const result = getInput(userConfig)[0]!; 164 164 expect(result.path).toBe( 165 165 'https://dash.readme.com/api/v1/api-registry/test123', 166 166 );
+59 -68
packages/openapi-ts/src/config/init.ts
··· 1 1 import path from 'node:path'; 2 2 3 + import colors from 'ansi-colors'; 3 4 import { loadConfig } from 'c12'; 4 5 5 6 import { ConfigError } from '../error'; 6 - import type { 7 - Config, 8 - UserConfig, 9 - UserConfigMultiOutputs, 10 - } from '../types/config'; 7 + import type { Config, UserConfig } from '../types/config'; 11 8 import { isLegacyClient, setConfig } from '../utils/config'; 12 9 import { getInput } from './input'; 13 10 import { getLogs } from './logs'; ··· 32 29 ); 33 30 34 31 /** 35 - * Expands a UserConfig with multiple outputs into multiple UserConfigs with single outputs 36 - * @internal 37 - */ 38 - const expandMultiOutputConfigs = ( 39 - userConfigs: ReadonlyArray<UserConfigMultiOutputs>, 40 - ): ReadonlyArray<UserConfig> => { 41 - const expandedConfigs: Array<UserConfig> = []; 42 - 43 - for (const userConfig of userConfigs) { 44 - if (Array.isArray(userConfig.output)) { 45 - // Multi-output configuration - expand into multiple single-output configs 46 - for (const output of userConfig.output) { 47 - expandedConfigs.push({ 48 - ...userConfig, 49 - output, 50 - }); 51 - } 52 - } else { 53 - // Single output configuration - keep as is 54 - expandedConfigs.push(userConfig as UserConfig); 55 - } 56 - } 57 - 58 - return expandedConfigs; 59 - }; 60 - 61 - /** 62 32 * @internal 63 33 */ 64 34 export const initConfigs = async ( 65 - userConfig: 66 - | UserConfigMultiOutputs 67 - | ReadonlyArray<UserConfigMultiOutputs> 68 - | undefined, 35 + userConfigs: ReadonlyArray<UserConfig>, 69 36 ): Promise<{ 70 37 dependencies: Record<string, string>; 71 38 results: ReadonlyArray<{ ··· 73 40 errors: ReadonlyArray<Error>; 74 41 }>; 75 42 }> => { 76 - let configurationFile: string | undefined = undefined; 77 - if (userConfig && !(userConfig instanceof Array)) { 78 - const cf = userConfig.configFile; 79 - if (cf) { 80 - const parts = cf.split('.'); 43 + const configs: Array<UserConfig> = []; 44 + let dependencies: Record<string, string> = {}; 45 + 46 + for (const userConfig of userConfigs) { 47 + let configurationFile: string | undefined = undefined; 48 + if (userConfig?.configFile) { 49 + const parts = userConfig.configFile.split('.'); 81 50 configurationFile = parts.slice(0, parts.length - 1).join('.'); 82 51 } 83 - } 52 + 53 + const { config: configFromFile, configFile: loadedConfigFile } = 54 + await loadConfig<UserConfig>({ 55 + configFile: configurationFile, 56 + name: 'openapi-ts', 57 + }); 58 + 59 + if (!Object.keys(dependencies).length) { 60 + // TODO: handle dependencies for multiple configs properly? 61 + dependencies = getProjectDependencies( 62 + Object.keys(configFromFile).length ? loadedConfigFile : undefined, 63 + ); 64 + } 84 65 85 - const { config: configFromFile, configFile: loadedConfigFile } = 86 - await loadConfig<UserConfigMultiOutputs>({ 87 - configFile: configurationFile, 88 - name: 'openapi-ts', 89 - }); 66 + const mergedConfigs = 67 + configFromFile instanceof Array 68 + ? configFromFile.map((config) => mergeConfigs(config, userConfig)) 69 + : [mergeConfigs(configFromFile, userConfig)]; 90 70 91 - const dependencies = getProjectDependencies( 92 - Object.keys(configFromFile).length ? loadedConfigFile : undefined, 93 - ); 94 - const baseUserConfigs: ReadonlyArray<UserConfigMultiOutputs> = Array.isArray( 95 - userConfig, 96 - ) 97 - ? userConfig 98 - : Array.isArray(configFromFile) 99 - ? configFromFile.map((config) => 100 - mergeConfigs(config, userConfig as UserConfig | undefined), 101 - ) 102 - : [ 103 - mergeConfigs( 104 - (configFromFile as UserConfig) ?? ({} as UserConfig), 105 - userConfig as UserConfig | undefined, 106 - ), 107 - ]; 71 + for (const mergedConfig of mergedConfigs) { 72 + const input = getInput(mergedConfig); 108 73 109 - // Expand multi-output configurations into multiple single-output configurations 110 - const userConfigs = expandMultiOutputConfigs(baseUserConfigs); 74 + if (mergedConfig.output instanceof Array) { 75 + const countInputs = input.length; 76 + const countOutputs = mergedConfig.output.length; 77 + if (countOutputs > 1) { 78 + if (countInputs !== countOutputs) { 79 + console.warn( 80 + `⚙️ ${colors.yellow('Warning:')} You provided ${colors.cyan(String(countInputs))} ${colors.cyan(countInputs === 1 ? 'input' : 'inputs')} and ${colors.yellow(String(countOutputs))} ${colors.yellow('outputs')}. This is probably not what you want as it will produce identical output in multiple locations. You most likely want to provide a single output or the same number of outputs as inputs.`, 81 + ); 82 + for (const output of mergedConfig.output) { 83 + configs.push({ ...mergedConfig, input, output }); 84 + } 85 + } else { 86 + mergedConfig.output.forEach((output, index) => { 87 + configs.push({ ...mergedConfig, input: input[index]!, output }); 88 + }); 89 + } 90 + } else { 91 + configs.push({ 92 + ...mergedConfig, 93 + input, 94 + output: mergedConfig.output[0] ?? '', 95 + }); 96 + } 97 + } else { 98 + configs.push({ ...mergedConfig, input }); 99 + } 100 + } 101 + } 111 102 112 103 const results: Array<{ 113 104 config: Config; 114 105 errors: Array<Error>; 115 106 }> = []; 116 107 117 - for (const userConfig of userConfigs) { 108 + for (const userConfig of configs) { 118 109 const { 119 110 base, 120 111 configFile = '', ··· 143 134 const output = getOutput(userConfig); 144 135 const parser = getParser(userConfig); 145 136 146 - if (!input.path) { 137 + if (!input.length) { 147 138 errors.push( 148 139 new ConfigError( 149 140 'missing input - which OpenAPI specification should we use to generate your output?',
+53 -46
packages/openapi-ts/src/config/input.ts
··· 1 1 import type { Config, UserConfig } from '../types/config'; 2 - import type { Input } from '../types/input'; 2 + import type { Input, Watch } from '../types/input'; 3 3 import { inputToApiRegistry } from '../utils/input'; 4 4 import { heyApiRegistryBaseUrl } from '../utils/input/heyApi'; 5 5 6 - const defaultWatch: Config['input']['watch'] = { 6 + const defaultWatch: Watch = { 7 7 enabled: false, 8 8 interval: 1_000, 9 9 timeout: 60_000, 10 10 }; 11 11 12 - const getWatch = ( 13 - input: Pick<Config['input'], 'path' | 'watch'>, 14 - ): Config['input']['watch'] => { 12 + // watch only remote files 13 + const getWatch = (input: Pick<Input, 'path' | 'watch'>): Watch => { 15 14 let watch = { ...defaultWatch }; 16 15 17 16 // we cannot watch spec passed as an object ··· 35 34 }; 36 35 37 36 export const getInput = (userConfig: UserConfig): Config['input'] => { 38 - let input: Config['input'] = { 39 - path: '', 40 - watch: defaultWatch, 41 - }; 37 + const userInputs = 38 + userConfig.input instanceof Array ? userConfig.input : [userConfig.input]; 42 39 43 - if (typeof userConfig.input === 'string') { 44 - input.path = userConfig.input; 45 - } else if ( 46 - userConfig.input && 47 - !(userConfig.input instanceof Array) && 48 - (userConfig.input.path !== undefined || 49 - userConfig.input.organization !== undefined) 50 - ) { 51 - // @ts-expect-error 52 - input = { 53 - ...input, 54 - path: heyApiRegistryBaseUrl, 55 - ...userConfig.input, 40 + const inputs: Array<Input> = []; 41 + 42 + for (const userInput of userInputs) { 43 + let input: Input = { 44 + path: '', 45 + watch: defaultWatch, 56 46 }; 57 47 58 - // watch only remote files 59 - if (input.watch !== undefined) { 60 - input.watch = getWatch(input); 48 + if (typeof userInput === 'string') { 49 + input.path = userInput; 50 + } else if ( 51 + userInput && 52 + (userInput.path !== undefined || userInput.organization !== undefined) 53 + ) { 54 + // @ts-expect-error 55 + input = { 56 + ...input, 57 + path: heyApiRegistryBaseUrl, 58 + ...userInput, 59 + }; 60 + 61 + if (input.watch !== undefined) { 62 + input.watch = getWatch(input); 63 + } 64 + } else { 65 + input = { 66 + ...input, 67 + path: userInput, 68 + }; 69 + } 70 + 71 + if (typeof input.path === 'string') { 72 + inputToApiRegistry(input as Input & { path: string }); 61 73 } 62 - } else { 63 - input = { 64 - ...input, 65 - path: userConfig.input as Record<string, unknown>, 66 - }; 67 - } 68 74 69 - if (typeof input.path === 'string') { 70 - inputToApiRegistry(input as Input & { path: string }); 71 - } 75 + if ( 76 + userConfig.watch !== undefined && 77 + input.watch.enabled === defaultWatch.enabled && 78 + input.watch.interval === defaultWatch.interval && 79 + input.watch.timeout === defaultWatch.timeout 80 + ) { 81 + input.watch = getWatch({ 82 + path: input.path, 83 + // @ts-expect-error 84 + watch: userConfig.watch, 85 + }); 86 + } 72 87 73 - if ( 74 - userConfig.watch !== undefined && 75 - input.watch.enabled === defaultWatch.enabled && 76 - input.watch.interval === defaultWatch.interval && 77 - input.watch.timeout === defaultWatch.timeout 78 - ) { 79 - input.watch = getWatch({ 80 - path: input.path, 81 - // @ts-expect-error 82 - watch: userConfig.watch, 83 - }); 88 + if (input.path) { 89 + inputs.push(input); 90 + } 84 91 } 85 92 86 - return input; 93 + return inputs; 87 94 };
+6
packages/openapi-ts/src/config/output.ts
··· 5 5 import { valueToObject } from './utils/config'; 6 6 7 7 export const getOutput = (userConfig: UserConfig): Config['output'] => { 8 + if (userConfig.output instanceof Array) { 9 + throw new Error( 10 + 'Unexpected array of outputs in user configuration. This should have been expanded already.', 11 + ); 12 + } 13 + 8 14 const output = valueToObject({ 9 15 defaultValue: { 10 16 clean: true,
+182 -89
packages/openapi-ts/src/createClient.ts
··· 1 1 import path from 'node:path'; 2 2 3 + import { $RefParser } from '@hey-api/json-schema-ref-parser'; 3 4 import colors from 'ansi-colors'; 4 5 5 6 import { generateLegacyOutput } from './generate/legacy/output'; ··· 11 12 import { processOutput } from './processOutput'; 12 13 import type { Client } from './types/client'; 13 14 import type { Config } from './types/config'; 15 + import type { Input } from './types/input'; 14 16 import type { WatchValues } from './types/types'; 15 17 import { isLegacyClient, legacyNameFromConfig } from './utils/config'; 16 18 import type { Templates } from './utils/handlebars'; 17 - import { heyApiRegistryBaseUrl } from './utils/input/heyApi'; 18 19 import type { Logger } from './utils/logger'; 19 20 import { postProcessClient } from './utils/postprocess'; 20 21 21 - const isHeyApiRegistryPath = (path: string) => 22 - path.startsWith(heyApiRegistryBaseUrl); 23 - // || path.startsWith('http://localhost:4000') 24 - 25 - export const compileInputPath = (input: Omit<Config['input'], 'watch'>) => { 22 + export const compileInputPath = (input: Omit<Input, 'watch'>) => { 26 23 const result: Pick< 27 - Partial<Config['input']>, 24 + Partial<Input>, 28 25 | 'api_key' 29 26 | 'branch' 30 27 | 'commit_sha' 31 28 | 'organization' 32 29 | 'project' 30 + | 'registry' 33 31 | 'tags' 34 32 | 'version' 35 33 > & 36 - Pick<Required<Config['input']>, 'path'> = { 34 + Pick<Input, 'path'> = { 35 + ...input, 37 36 path: '', 38 37 }; 39 38 40 39 if ( 41 40 input.path && 42 - (typeof input.path !== 'string' || !isHeyApiRegistryPath(input.path)) 41 + (typeof input.path !== 'string' || input.registry !== 'hey-api') 43 42 ) { 44 43 result.path = input.path; 45 44 return result; ··· 131 130 return result; 132 131 }; 133 132 134 - const logInputPath = (inputPath: ReturnType<typeof compileInputPath>) => { 135 - const baseString = colors.cyan('Generating from'); 133 + const logInputPaths = ( 134 + inputPaths: ReadonlyArray<ReturnType<typeof compileInputPath>>, 135 + configIndex: number, 136 + ) => { 137 + const lines: Array<string> = []; 138 + 139 + const jobIndexPrefix = colors.gray(`[Job ${configIndex + 1}] `); 140 + const count = inputPaths.length; 141 + const baseString = colors.cyan( 142 + `Generating from ${count} ${count === 1 ? 'input' : 'inputs'}:`, 143 + ); 144 + lines.push(`${jobIndexPrefix}⏳ ${baseString}`); 145 + 146 + inputPaths.forEach((inputPath, index) => { 147 + const inputIndexPrefixStr = ` [${index + 1}] `; 148 + const inputIndexPrefix = colors.cyan(inputIndexPrefixStr); 149 + const detailIndent = ' '.repeat(inputIndexPrefixStr.length); 150 + 151 + if (typeof inputPath.path !== 'string') { 152 + lines.push( 153 + `${jobIndexPrefix}${inputIndexPrefix}raw OpenAPI specification`, 154 + ); 155 + return; 156 + } 136 157 137 - if (typeof inputPath.path === 'string') { 138 - const baseInput = isHeyApiRegistryPath(inputPath.path) 139 - ? `${inputPath.organization ?? ''}/${inputPath.project ?? ''}` 140 - : inputPath.path; 141 - console.log(`⏳ ${baseString} ${baseInput}`); 142 - if (isHeyApiRegistryPath(inputPath.path)) { 143 - if (inputPath.branch) { 144 - console.log( 145 - `${colors.gray('branch:')} ${colors.green(inputPath.branch)}`, 146 - ); 147 - } 148 - if (inputPath.commit_sha) { 149 - console.log( 150 - `${colors.gray('commit:')} ${colors.green(inputPath.commit_sha)}`, 158 + switch (inputPath.registry) { 159 + case 'hey-api': { 160 + const baseInput = [inputPath.organization, inputPath.project] 161 + .filter(Boolean) 162 + .join('/'); 163 + lines.push(`${jobIndexPrefix}${inputIndexPrefix}${baseInput}`); 164 + if (inputPath.branch) { 165 + lines.push( 166 + `${jobIndexPrefix}${detailIndent}${colors.gray('branch:')} ${colors.green( 167 + inputPath.branch, 168 + )}`, 169 + ); 170 + } 171 + if (inputPath.commit_sha) { 172 + lines.push( 173 + `${jobIndexPrefix}${detailIndent}${colors.gray('commit:')} ${colors.green( 174 + inputPath.commit_sha, 175 + )}`, 176 + ); 177 + } 178 + if (inputPath.tags?.length) { 179 + lines.push( 180 + `${jobIndexPrefix}${detailIndent}${colors.gray('tags:')} ${colors.green( 181 + inputPath.tags.join(', '), 182 + )}`, 183 + ); 184 + } 185 + if (inputPath.version) { 186 + lines.push( 187 + `${jobIndexPrefix}${detailIndent}${colors.gray('version:')} ${colors.green( 188 + inputPath.version, 189 + )}`, 190 + ); 191 + } 192 + lines.push( 193 + `${jobIndexPrefix}${detailIndent}${colors.gray('registry:')} ${colors.green('Hey API')}`, 151 194 ); 195 + break; 152 196 } 153 - if (inputPath.tags?.length) { 154 - console.log( 155 - `${colors.gray('tags:')} ${colors.green(inputPath.tags.join(', '))}`, 197 + case 'readme': { 198 + const baseInput = [inputPath.organization, inputPath.project] 199 + .filter(Boolean) 200 + .join('/'); 201 + if (!baseInput) { 202 + lines.push(`${jobIndexPrefix}${inputIndexPrefix}${inputPath.path}`); 203 + } else { 204 + lines.push(`${jobIndexPrefix}${inputIndexPrefix}${baseInput}`); 205 + } 206 + // @ts-expect-error 207 + if (inputPath.uuid) { 208 + lines.push( 209 + `${jobIndexPrefix}${detailIndent}${colors.gray('uuid:')} ${colors.green( 210 + // @ts-expect-error 211 + inputPath.uuid, 212 + )}`, 213 + ); 214 + } 215 + lines.push( 216 + `${jobIndexPrefix}${detailIndent}${colors.gray('registry:')} ${colors.green('ReadMe')}`, 156 217 ); 218 + break; 157 219 } 158 - if (inputPath.version) { 159 - console.log( 160 - `${colors.gray('version:')} ${colors.green(inputPath.version)}`, 220 + case 'scalar': { 221 + const baseInput = [inputPath.organization, inputPath.project] 222 + .filter(Boolean) 223 + .join('/'); 224 + lines.push(`${jobIndexPrefix}${inputIndexPrefix}${baseInput}`); 225 + lines.push( 226 + `${jobIndexPrefix}${detailIndent}${colors.gray('registry:')} ${colors.green('Scalar')}`, 161 227 ); 228 + break; 162 229 } 230 + default: 231 + lines.push(`${jobIndexPrefix}${inputIndexPrefix}${inputPath.path}`); 232 + break; 163 233 } 164 - } else { 165 - console.log(`⏳ ${baseString} raw OpenAPI specification`); 234 + }); 235 + 236 + for (const line of lines) { 237 + console.log(line); 166 238 } 167 239 }; 168 240 169 241 export const createClient = async ({ 170 242 config, 243 + configIndex, 171 244 dependencies, 172 245 logger, 173 246 templates, 174 - watch: _watch, 247 + watches: _watches, 175 248 }: { 176 249 config: Config; 250 + configIndex: number; 177 251 dependencies: Record<string, string>; 178 252 logger: Logger; 179 253 templates: Templates; 180 254 /** 181 - * Always falsy on the first run, truthy on subsequent runs. 255 + * Always undefined on the first run, defined on subsequent runs. 182 256 */ 183 - watch?: WatchValues; 184 - }) => { 185 - // Support single or multiple input paths 186 - const { watch: watchOptions, ...inputWithoutWatch } = config.input as Omit< 187 - Config['input'], 188 - 'watch' 189 - > & { watch: Config['input']['watch'] }; 190 - const inputPathsArray: Array<Config['input']['path']> = Array.isArray( 191 - inputWithoutWatch.path, 192 - ) 193 - ? (inputWithoutWatch.path as Array<Config['input']['path']>) 194 - : [inputWithoutWatch.path as unknown as Config['input']['path']]; 195 - const compiledInputs = inputPathsArray.map((p) => { 196 - if (typeof p === 'string') { 197 - return compileInputPath({ ...inputWithoutWatch, path: p }); 198 - } 199 - if (p && typeof p === 'object') { 200 - // If the entry is an object (Input), spread it at the top level so we 201 - // don't end up with a double-nested `path: { path: '...' }`. 202 - return compileInputPath({ ...inputWithoutWatch, ...(p as any) }); 203 - } 204 - return compileInputPath({ ...inputWithoutWatch, path: p as any }); 205 - }); 206 - const { timeout } = watchOptions; 257 + watches?: ReadonlyArray<WatchValues>; 258 + }): Promise<Client | undefined | IR.Context> => { 259 + const watches: ReadonlyArray<WatchValues> = 260 + _watches || 261 + Array.from({ length: config.input.length }, () => ({ 262 + headers: new Headers(), 263 + })); 207 264 208 - const watch: WatchValues = _watch || { headers: new Headers(), inputs: {} }; 265 + const inputPaths = config.input.map((input) => compileInputPath(input)); 209 266 210 267 // on first run, print the message as soon as possible 211 - if (config.logs.level !== 'silent' && !_watch) { 212 - compiledInputs.forEach((ci) => logInputPath(ci)); 268 + if (config.logs.level !== 'silent' && !_watches) { 269 + logInputPaths(inputPaths, configIndex); 213 270 } 214 271 215 - const eventSpec = logger.timeEvent('spec'); 216 - const { data, error, response } = await getSpec({ 217 - fetchOptions: config.input.fetch, 218 - inputPaths: compiledInputs.map((ci) => ci.path), 219 - timeout, 220 - watch, 221 - }); 222 - eventSpec.timeEnd(); 272 + const getSpecData = async (input: Input, index: number) => { 273 + const eventSpec = logger.timeEvent('spec'); 274 + const { arrayBuffer, error, resolvedInput, response } = await getSpec({ 275 + fetchOptions: input.fetch, 276 + inputPath: inputPaths[index]!.path, 277 + timeout: input.watch.timeout, 278 + watch: watches[index]!, 279 + }); 280 + eventSpec.timeEnd(); 281 + 282 + // throw on first run if there's an error to preserve user experience 283 + // if in watch mode, subsequent errors won't throw to gracefully handle 284 + // cases where server might be reloading 285 + if (error && !_watches) { 286 + throw new Error( 287 + `Request failed with status ${response.status}: ${response.statusText}`, 288 + ); 289 + } 223 290 224 - // throw on first run if there's an error to preserve user experience 225 - // if in watch mode, subsequent errors won't throw to gracefully handle 226 - // cases where server might be reloading 227 - if (error && !_watch) { 228 - throw new Error( 229 - `Request failed with status ${response.status}: ${response.statusText}`, 230 - ); 231 - } 291 + return { arrayBuffer, resolvedInput }; 292 + }; 293 + const specData = ( 294 + await Promise.all( 295 + config.input.map((input, index) => getSpecData(input, index)), 296 + ) 297 + ).filter((data) => data.arrayBuffer || data.resolvedInput); 232 298 233 299 let client: Client | undefined; 234 300 let context: IR.Context | undefined; 235 301 236 - if (data) { 237 - // on subsequent runs in watch mode, print the mssage only if we know we're 302 + if (specData.length) { 303 + const refParser = new $RefParser(); 304 + const data = 305 + specData.length > 1 306 + ? await refParser.bundleMany({ 307 + arrayBuffer: specData.map((data) => data.arrayBuffer!), 308 + pathOrUrlOrSchemas: [], 309 + resolvedInputs: specData.map((data) => data.resolvedInput!), 310 + }) 311 + : await refParser.bundle({ 312 + arrayBuffer: specData[0]!.arrayBuffer, 313 + pathOrUrlOrSchema: undefined, 314 + resolvedInput: specData[0]!.resolvedInput, 315 + }); 316 + 317 + // on subsequent runs in watch mode, print the message only if we know we're 238 318 // generating the output 239 - if (config.logs.level !== 'silent' && _watch) { 319 + if (config.logs.level !== 'silent' && _watches) { 240 320 console.clear(); 241 - compiledInputs.forEach((ci) => logInputPath(ci)); 321 + logInputPaths(inputPaths, configIndex); 242 322 } 243 323 244 324 const eventInputPatch = logger.timeEvent('input.patch'); ··· 277 357 const outputPath = process.env.INIT_CWD 278 358 ? `./${path.relative(process.env.INIT_CWD, config.output.path)}` 279 359 : config.output.path; 360 + const jobPrefix = 361 + typeof configIndex === 'number' 362 + ? colors.gray(`[Job ${configIndex + 1}] `) 363 + : ''; 280 364 console.log( 281 - `${colors.green('🚀 Done!')} Your output is in ${colors.cyanBright(outputPath)}`, 365 + `${jobPrefix}${colors.green('🚀 Done!')} Your output is in ${colors.cyanBright(outputPath)}`, 282 366 ); 283 367 } 284 368 } 285 369 eventPostprocess.timeEnd(); 286 370 } 287 371 288 - if ( 289 - config.input.watch.enabled && 290 - compiledInputs.some((ci) => typeof ci.path === 'string') 291 - ) { 372 + const watchedInput = config.input.find( 373 + (input, index) => 374 + input.watch.enabled && typeof inputPaths[index]!.path === 'string', 375 + ); 376 + 377 + if (watchedInput) { 292 378 setTimeout(() => { 293 - createClient({ config, dependencies, logger, templates, watch }); 294 - }, config.input.watch.interval); 379 + createClient({ 380 + config, 381 + configIndex, 382 + dependencies, 383 + logger, 384 + templates, 385 + watches, 386 + }); 387 + }, watchedInput.watch.interval); 295 388 } 296 389 297 390 return context || client;
+1 -1
packages/openapi-ts/src/debug/ir.ts
··· 43 43 if (verbosity === 'summary' && obj && typeof obj === 'object') { 44 44 if (kind === 'responses') { 45 45 const count = Object.keys(obj).length; 46 - const noun = count !== 1 ? 'codes' : 'code'; 46 + const noun = count === 1 ? 'code' : 'codes'; 47 47 log(`responses: ${colors.yellow(`${count} ${noun}`)}`, level); 48 48 } else if (kind === 'requestBody') { 49 49 log(`requestBody: ${Object.keys(obj).join(', ')}`, level);
+121 -156
packages/openapi-ts/src/getSpec.ts
··· 1 - import { 2 - $RefParser, 3 - getResolvedInput, 4 - type JSONSchema, 5 - sendRequest, 6 - } from '@hey-api/json-schema-ref-parser'; 1 + import { getResolvedInput, sendRequest } from '@hey-api/json-schema-ref-parser'; 7 2 8 3 import { mergeHeaders } from './plugins/@hey-api/client-fetch/bundle'; 9 - import type { Config } from './types/config'; 4 + import type { Input } from './types/input'; 10 5 import type { WatchValues } from './types/types'; 11 6 12 - interface SpecResponse { 13 - data: JSONSchema; 14 - error?: undefined; 15 - response?: undefined; 16 - } 7 + type SpecResponse = { 8 + arrayBuffer: ArrayBuffer | undefined; 9 + error?: never; 10 + resolvedInput: ReturnType<typeof getResolvedInput>; 11 + response?: never; 12 + }; 17 13 18 - interface SpecError { 19 - data?: undefined; 14 + type SpecError = { 15 + arrayBuffer?: never; 20 16 error: 'not-modified' | 'not-ok'; 17 + resolvedInput?: never; 21 18 response: Response; 22 - } 19 + }; 23 20 24 21 /** 25 22 * @internal 26 23 */ 27 24 export const getSpec = async ({ 28 25 fetchOptions, 29 - inputPaths, 26 + inputPath, 30 27 timeout, 31 28 watch, 32 29 }: { 33 30 fetchOptions?: RequestInit; 34 - inputPaths: Array<Config['input']['path']>; 31 + inputPath: Input['path']; 35 32 timeout: number | undefined; 36 33 watch: WatchValues; 37 34 }): Promise<SpecResponse | SpecError> => { 38 - const refParser = new $RefParser(); 39 - const resolvedInputs = inputPaths.map((inputPath) => 40 - getResolvedInput({ pathOrUrlOrSchema: inputPath }), 41 - ); 35 + const resolvedInput = getResolvedInput({ pathOrUrlOrSchema: inputPath }); 42 36 43 - const arrayBuffer: ArrayBuffer[] = []; 44 - let anyChanged = false; 45 - let lastResponse: Response | undefined; 46 - watch.inputs = watch.inputs || {}; 47 - 48 - for (const resolvedInput of resolvedInputs) { 49 - let hasChanged: boolean | undefined; 50 - let response: Response | undefined; 37 + let arrayBuffer: ArrayBuffer | undefined; 38 + // boolean signals whether the file has **definitely** changed 39 + let hasChanged: boolean | undefined; 40 + let response: Response | undefined; 51 41 52 - const key = `${resolvedInput.type}:${resolvedInput.path ?? ''}`; 53 - const state = (watch.inputs[key] = watch.inputs[key] || { 54 - headers: new Headers(), 55 - }); 56 - 57 - if (resolvedInput.type === 'url') { 58 - if (state.lastValue && state.isHeadMethodSupported !== false) { 59 - try { 60 - const request = await sendRequest({ 61 - fetchOptions: { 62 - method: 'HEAD', 63 - ...fetchOptions, 64 - headers: mergeHeaders(fetchOptions?.headers, state.headers), 65 - }, 66 - timeout, 67 - url: resolvedInput.path, 68 - }); 69 - 70 - lastResponse = request.response; 71 - 72 - if (request.response.status >= 300) { 73 - return { 74 - error: 'not-ok', 75 - response: request.response, 76 - }; 77 - } 78 - 79 - response = request.response; 80 - } catch (error) { 81 - return { 82 - error: 'not-ok', 83 - response: new Response((error as Error).message), 84 - }; 85 - } 86 - 87 - if (response.status === 304) { 88 - hasChanged = false; 89 - } else if (!response.ok && state.isHeadMethodSupported) { 90 - return { 91 - error: 'not-ok', 92 - response, 93 - }; 94 - } 95 - 96 - if (state.isHeadMethodSupported === undefined) { 97 - state.isHeadMethodSupported = response.ok; 98 - } 99 - 100 - if (hasChanged === undefined) { 101 - const eTag = response.headers.get('ETag'); 102 - if (eTag) { 103 - hasChanged = eTag !== state.headers.get('If-None-Match'); 104 - if (hasChanged) { 105 - state.headers.set('If-None-Match', eTag); 106 - } else { 107 - // Definitely not changed based on ETag 108 - hasChanged = false; 109 - } 110 - } 111 - } 112 - 113 - if (hasChanged === undefined) { 114 - const lastModified = response.headers.get('Last-Modified'); 115 - if (lastModified) { 116 - hasChanged = 117 - lastModified !== state.headers.get('If-Modified-Since'); 118 - if (hasChanged) { 119 - state.headers.set('If-Modified-Since', lastModified); 120 - } else { 121 - hasChanged = false; 122 - } 123 - } 124 - } 125 - 126 - if (hasChanged === false && state.lastValue !== undefined) { 127 - // Use cached content without GET 128 - const encoded = new TextEncoder().encode(state.lastValue); 129 - const cachedBuffer = new ArrayBuffer(encoded.byteLength); 130 - new Uint8Array(cachedBuffer).set(encoded); 131 - arrayBuffer.push(cachedBuffer); 132 - anyChanged = anyChanged || false; 133 - continue; 134 - } 135 - } 136 - 42 + if (resolvedInput.type === 'url') { 43 + // do NOT send HEAD request on first run or if unsupported 44 + if (watch.lastValue && watch.isHeadMethodSupported !== false) { 137 45 try { 138 46 const request = await sendRequest({ 139 47 fetchOptions: { 140 - method: 'GET', 48 + method: 'HEAD', 141 49 ...fetchOptions, 50 + headers: mergeHeaders(fetchOptions?.headers, watch.headers), 142 51 }, 143 52 timeout, 144 53 url: resolvedInput.path, 145 54 }); 146 55 147 - lastResponse = request.response; 148 - 149 56 if (request.response.status >= 300) { 150 57 return { 151 58 error: 'not-ok', ··· 157 64 } catch (error) { 158 65 return { 159 66 error: 'not-ok', 160 - response: new Response((error as Error).message), 67 + response: new Response(error.message), 161 68 }; 162 69 } 163 70 164 - if (!response.ok) { 71 + if (!response.ok && watch.isHeadMethodSupported) { 72 + // assume the server is no longer running 73 + // do nothing, it might be restarted later 165 74 return { 166 75 error: 'not-ok', 167 76 response, 168 77 }; 169 78 } 170 79 171 - const lastBuffer = response.body 172 - ? await response.arrayBuffer() 173 - : new ArrayBuffer(0); 174 - arrayBuffer.push(lastBuffer); 80 + if (watch.isHeadMethodSupported === undefined) { 81 + watch.isHeadMethodSupported = response.ok; 82 + } 83 + 84 + if (response.status === 304) { 85 + return { 86 + error: 'not-modified', 87 + response, 88 + }; 89 + } 90 + 91 + if (hasChanged === undefined) { 92 + const eTag = response.headers.get('ETag'); 93 + if (eTag) { 94 + hasChanged = eTag !== watch.headers.get('If-None-Match'); 95 + 96 + if (hasChanged) { 97 + watch.headers.set('If-None-Match', eTag); 98 + } 99 + } 100 + } 175 101 176 102 if (hasChanged === undefined) { 177 - const content = new TextDecoder().decode(lastBuffer); 178 - hasChanged = content !== state.lastValue; 179 - state.lastValue = content; 180 - } else if (hasChanged) { 181 - // Update lastValue since it changed 182 - const content = new TextDecoder().decode(lastBuffer); 183 - state.lastValue = content; 103 + const lastModified = response.headers.get('Last-Modified'); 104 + if (lastModified) { 105 + hasChanged = lastModified !== watch.headers.get('If-Modified-Since'); 106 + 107 + if (hasChanged) { 108 + watch.headers.set('If-Modified-Since', lastModified); 109 + } 110 + } 184 111 } 185 - } else { 186 - // we do not support watch mode for files or raw spec data 187 - if (!state.lastValue) { 188 - state.lastValue = resolvedInput.type; 189 - } else { 190 - hasChanged = false; 112 + 113 + // we definitely know the input has not changed 114 + if (hasChanged === false) { 115 + return { 116 + error: 'not-modified', 117 + response, 118 + }; 191 119 } 192 - // Maintain alignment with resolvedInputs 193 - arrayBuffer.push(new ArrayBuffer(0)); 194 120 } 195 121 196 - anyChanged = anyChanged || hasChanged !== false; 197 - } 122 + try { 123 + const request = await sendRequest({ 124 + fetchOptions: { 125 + method: 'GET', 126 + ...fetchOptions, 127 + }, 128 + timeout, 129 + url: resolvedInput.path, 130 + }); 198 131 199 - let data: JSONSchema; 200 - if (resolvedInputs.length === 1) { 201 - data = await refParser.bundle({ 202 - arrayBuffer: arrayBuffer[0], 203 - pathOrUrlOrSchema: undefined, 204 - resolvedInput: resolvedInputs[0], 205 - }); 132 + if (request.response.status >= 300) { 133 + return { 134 + error: 'not-ok', 135 + response: request.response, 136 + }; 137 + } 138 + 139 + response = request.response; 140 + } catch (error) { 141 + return { 142 + error: 'not-ok', 143 + response: new Response(error.message), 144 + }; 145 + } 146 + 147 + if (!response.ok) { 148 + // assume the server is no longer running 149 + // do nothing, it might be restarted later 150 + return { 151 + error: 'not-ok', 152 + response, 153 + }; 154 + } 155 + 156 + arrayBuffer = response.body 157 + ? await response.arrayBuffer() 158 + : new ArrayBuffer(0); 159 + 160 + if (hasChanged === undefined) { 161 + const content = new TextDecoder().decode(arrayBuffer); 162 + hasChanged = content !== watch.lastValue; 163 + watch.lastValue = content; 164 + } 206 165 } else { 207 - data = await refParser.bundleMany({ 208 - arrayBuffer, 209 - pathOrUrlOrSchemas: [], 210 - resolvedInputs, 211 - }); 166 + // we do not support watch mode for files or raw spec data 167 + if (!watch.lastValue) { 168 + watch.lastValue = resolvedInput.type; 169 + } else { 170 + hasChanged = false; 171 + } 212 172 } 213 - if (!anyChanged) { 173 + 174 + if (hasChanged === false) { 214 175 return { 215 176 error: 'not-modified', 216 - response: lastResponse || new Response('', { status: 304 }), 217 - } as SpecError; 177 + response: response!, 178 + }; 218 179 } 219 - return { data }; 180 + 181 + return { 182 + arrayBuffer, 183 + resolvedInput, 184 + }; 220 185 };
+21 -30
packages/openapi-ts/src/index.ts
··· 6 6 7 7 import { checkNodeVersion } from './config/engine'; 8 8 import { initConfigs } from './config/init'; 9 + import { getLogs } from './config/logs'; 9 10 import { createClient as pCreateClient } from './createClient'; 10 11 import { 11 12 logCrashReport, ··· 15 16 } from './error'; 16 17 import type { IR } from './ir/types'; 17 18 import type { Client } from './types/client'; 18 - import type { Config, UserConfigMultiOutputs } from './types/config'; 19 + import type { Config, UserConfig } from './types/config'; 20 + import type { LazyOrAsync, MaybeArray } from './types/utils'; 19 21 import { registerHandlebarTemplates } from './utils/handlebars'; 20 22 import { Logger } from './utils/logger'; 21 23 22 - type ConfigValue = 23 - | UserConfigMultiOutputs 24 - | ReadonlyArray<UserConfigMultiOutputs>; 25 - // Generic input shape for config that may be a value or a (possibly async) factory 26 - type ConfigInput<T extends ConfigValue> = T | (() => T) | (() => Promise<T>); 27 - 28 24 colors.enabled = colorSupport().hasBasic; 29 25 30 26 /** 31 27 * Generate a client from the provided configuration. 32 28 * 33 - * @param userConfig User provided {@link UserConfigMultiOutputs} configuration. 29 + * @param userConfig User provided {@link UserConfig} configuration(s). 34 30 */ 35 31 export const createClient = async ( 36 - userConfig?: ConfigInput<ConfigValue>, 32 + userConfig?: LazyOrAsync<MaybeArray<UserConfig>>, 37 33 logger = new Logger(), 38 34 ): Promise<ReadonlyArray<Client | IR.Context>> => { 39 35 const resolvedConfig = 40 36 typeof userConfig === 'function' ? await userConfig() : userConfig; 37 + const userConfigs = resolvedConfig 38 + ? resolvedConfig instanceof Array 39 + ? resolvedConfig 40 + : [resolvedConfig] 41 + : []; 41 42 42 43 const configs: Array<Config> = []; 43 44 ··· 47 48 const eventCreateClient = logger.timeEvent('createClient'); 48 49 49 50 const eventConfig = logger.timeEvent('config'); 50 - const configResults = await initConfigs(resolvedConfig); 51 - 52 - // Check for configuration errors and fail immediately 51 + const configResults = await initConfigs(userConfigs); 53 52 for (const result of configResults.results) { 54 53 configs.push(result.config); 55 54 if (result.errors.length) { ··· 63 62 eventHandlebars.timeEnd(); 64 63 65 64 const clients = await Promise.all( 66 - configs.map((config) => 65 + configs.map((config, index) => 67 66 pCreateClient({ 68 67 config, 68 + configIndex: index, 69 69 dependencies: configResults.dependencies, 70 70 logger, 71 71 templates, ··· 83 83 84 84 return result; 85 85 } catch (error) { 86 - // Handle both configuration errors and runtime errors 87 - // For multi-config scenarios, use first available config or reasonable defaults 88 - const firstConfig = configs[0]; 89 - const resolvedSingle = ( 90 - Array.isArray(resolvedConfig) ? resolvedConfig[0] : resolvedConfig 91 - ) as UserConfigMultiOutputs | undefined; 92 - 93 - const logs = firstConfig?.logs ?? { 94 - file: false, 95 - level: 'warn' as const, 96 - path: '', 97 - }; 98 - const dryRun = firstConfig?.dryRun ?? resolvedSingle?.dryRun ?? false; 86 + const resolvedConfig = userConfigs[0]; 87 + const config = configs[0]; 88 + const dryRun = config?.dryRun ?? resolvedConfig?.dryRun ?? false; 99 89 const isInteractive = 100 - firstConfig?.interactive ?? resolvedSingle?.interactive ?? false; 90 + config?.interactive ?? resolvedConfig?.interactive ?? false; 91 + const logs = config?.logs ?? getLogs(resolvedConfig); 101 92 102 93 let logPath: string | undefined; 103 94 ··· 117 108 }; 118 109 119 110 /** 120 - * Type helper for openapi-ts.config.ts, returns {@link ConfigValue} object 111 + * Type helper for openapi-ts.config.ts, returns {@link MaybeArray<UserConfig>} object(s) 121 112 */ 122 - export const defineConfig = async <T extends ConfigValue>( 123 - config: ConfigInput<T>, 113 + export const defineConfig = async <T extends MaybeArray<UserConfig>>( 114 + config: LazyOrAsync<T>, 124 115 ): Promise<T> => (typeof config === 'function' ? await config() : config); 125 116 126 117 export { defaultPaginationKeywords } from './config/parser';
+1 -1
packages/openapi-ts/src/openApi/2.0.x/parser/schema.ts
··· 232 232 const isEmptyObjectInAllOf = 233 233 state.inAllOf && 234 234 schema.additionalProperties === false && 235 - (!schema.properties || Object.keys(schema.properties).length === 0); 235 + (!schema.properties || !Object.keys(schema.properties).length); 236 236 237 237 if (!isEmptyObjectInAllOf) { 238 238 irSchema.additionalProperties = {
+1 -1
packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts
··· 238 238 const isEmptyObjectInAllOf = 239 239 state.inAllOf && 240 240 schema.additionalProperties === false && 241 - (!schema.properties || Object.keys(schema.properties).length === 0); 241 + (!schema.properties || !Object.keys(schema.properties).length); 242 242 243 243 if (!isEmptyObjectInAllOf) { 244 244 irSchema.additionalProperties = {
+2 -2
packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts
··· 292 292 const isEmptyObjectInAllOf = 293 293 state.inAllOf && 294 294 schema.additionalProperties === false && 295 - (!schema.properties || Object.keys(schema.properties).length === 0) && 295 + (!schema.properties || !Object.keys(schema.properties).length) && 296 296 (!schema.patternProperties || 297 - Object.keys(schema.patternProperties).length === 0); 297 + !Object.keys(schema.patternProperties).length); 298 298 299 299 if (!isEmptyObjectInAllOf) { 300 300 irSchema.additionalProperties = {
+2 -1
packages/openapi-ts/src/openApi/3.1.x/types/json-schema-draft-2020-12.d.ts
··· 1 + import type { MaybeArray } from '../../../types/utils'; 1 2 import type { EnumExtensions } from '../../shared/types/openapi-spec-extensions'; 2 3 import type { OpenApiSchemaExtensions } from './spec-extensions'; 3 4 ··· 144 145 /** 145 146 * If it is an array, it must be an array of strings, where each string is the name of one of the basic types, and each element is unique. In this case, the JSON snippet is valid if it matches any of the given types. 146 147 */ 147 - type?: JsonSchemaTypes | ReadonlyArray<JsonSchemaTypes>; 148 + type?: MaybeArray<JsonSchemaTypes>; 148 149 /** 149 150 * The boolean keywords `readOnly` and `writeOnly` are typically used in an API context. `readOnly` indicates that a value should not be modified. It could be used to indicate that a `PUT` request that changes a value would result in a `400 Bad Request` response. `writeOnly` indicates that a value may be set, but will remain hidden. In could be used to indicate you can set a value with a `PUT` request, but it would not be included when retrieving that record with a `GET` request. 150 151 */
+1 -1
packages/openapi-ts/src/openApi/shared/transforms/readWrite.ts
··· 233 233 (prop) => !removedProperties.has(prop), 234 234 ); 235 235 236 - if (filteredRequired.length === 0) { 236 + if (!filteredRequired.length) { 237 237 delete (schema as Record<string, unknown>).required; 238 238 } else { 239 239 (schema as Record<string, unknown>).required = filteredRequired;
+19 -10
packages/openapi-ts/src/types/config.d.ts
··· 1 1 import type { PluginConfigMap } from '../plugins/config'; 2 2 import type { Plugin, PluginNames } from '../plugins/types'; 3 - import type { Input, InputPath, Watch } from './input'; 3 + import type { Input, UserInput, Watch } from './input'; 4 4 import type { Logs } from './logs'; 5 5 import type { Output, UserOutput } from './output'; 6 6 import type { Parser, UserParser } from './parser'; 7 + import type { MaybeArray } from './utils'; 7 8 8 - export interface UserConfigMultiOutputs extends Omit<UserConfig, 'output'> { 9 - output: string | UserOutput | ReadonlyArray<string | UserOutput>; 10 - } 11 9 export interface UserConfig { 12 10 /** 13 11 * Path to the config file. Set this value if you don't use the default ··· 30 28 * object directly if you're fetching the file yourself. 31 29 * 32 30 * Alternatively, you can define a configuration object with more options. 31 + * 32 + * If you define an array, we will generate a single output from multiple 33 + * inputs. If you define an array of outputs with the same length, we will 34 + * generate multiple outputs, one for each input. 33 35 */ 34 - input: InputPath | Input | ReadonlyArray<InputPath | Input>; 36 + input: MaybeArray<UserInput | Required<UserInput>['path']>; 35 37 /** 36 38 * Show an interactive error reporting tool when the program crashes? You 37 39 * generally want to keep this disabled (default). ··· 46 48 */ 47 49 logs?: string | Logs; 48 50 /** 49 - * Path to the output folder. You can define an array to generate 50 - * multiple outputs from your input. 51 + * Path to the output folder. 52 + * 53 + * If you define an array of outputs with the same length as inputs, we will 54 + * generate multiple outputs, one for each input. 51 55 */ 52 - output: string | UserOutput; 56 + output: MaybeArray<string | UserOutput>; 53 57 /** 54 58 * Customize how the input is parsed and transformed before it's passed to 55 59 * plugins. ··· 138 142 | 'watch' 139 143 > & 140 144 Pick<UserConfig, 'base' | 'name' | 'request'> & { 141 - input: Omit<Input, 'path' | 'watch'> & 142 - Pick<Required<Input>, 'path'> & { watch: Watch }; 145 + /** 146 + * Path to the input specification. 147 + */ 148 + input: ReadonlyArray<Input>; 143 149 logs: Logs; 150 + /** 151 + * Path to the output folder. 152 + */ 144 153 output: Output; 145 154 /** 146 155 * Customize how the input is parsed and transformed before it's passed to
+89 -6
packages/openapi-ts/src/types/input.d.ts
··· 1 - export type InputPath = 1 + type JsonSchema = Record<string, unknown>; 2 + 3 + type ApiRegistryShorthands = 2 4 | `https://get.heyapi.dev/${string}/${string}` 3 5 | `${string}/${string}` 4 6 | `readme:@${string}/${string}#${string}` 5 7 | `readme:${string}` 6 - | `scalar:@${string}/${string}` 7 - | (string & {}) 8 - | Record<string, unknown>; 8 + | `scalar:@${string}/${string}`; 9 9 10 - export type Input = { 10 + export type UserInput = { 11 11 /** 12 12 * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 13 13 * ··· 53 53 * Both JSON and YAML file formats are supported. You can also pass the parsed 54 54 * object directly if you're fetching the file yourself. 55 55 */ 56 - path?: InputPath; 56 + path?: ApiRegistryShorthands | (string & {}) | JsonSchema; 57 57 /** 58 58 * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 59 59 * ··· 83 83 * @default false 84 84 */ 85 85 watch?: boolean | number | Watch; 86 + }; 87 + 88 + export type Input = { 89 + /** 90 + * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 91 + * 92 + * Projects are private by default, you will need to be authenticated 93 + * to download OpenAPI specifications. We recommend using project API 94 + * keys in CI workflows and personal API keys for local development. 95 + * 96 + * API key isn't required for public projects. You can also omit this 97 + * parameter and provide an environment variable `HEY_API_TOKEN`. 98 + */ 99 + api_key?: string; 100 + /** 101 + * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 102 + * 103 + * You can fetch the last build from branch by providing the branch 104 + * name. 105 + */ 106 + branch?: string; 107 + /** 108 + * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 109 + * 110 + * You can fetch an exact specification by providing a commit sha. 111 + * This will always return the same file. 112 + */ 113 + commit_sha?: string; 114 + /** 115 + * You can pass any valid Fetch API options to the request for fetching your 116 + * specification. This is useful if your file is behind auth for example. 117 + */ 118 + fetch?: RequestInit; 119 + /** 120 + * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 121 + * 122 + * Organization created in Hey API Platform. 123 + */ 124 + organization?: string; 125 + /** 126 + * Path to the OpenAPI specification. This can be: 127 + * - path 128 + * - URL 129 + * - API registry shorthand 130 + * 131 + * Both JSON and YAML file formats are supported. You can also pass the parsed 132 + * object directly if you're fetching the file yourself. 133 + */ 134 + path: ApiRegistryShorthands | (string & {}) | JsonSchema; 135 + /** 136 + * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 137 + * 138 + * Project created in Hey API Platform. 139 + */ 140 + project?: string; 141 + /** 142 + * If input path was resolved to a registry, this contains the registry 143 + * identifier so we don't need to parse it again. 144 + * 145 + * @default undefined 146 + */ 147 + registry?: 'hey-api' | 'readme' | 'scalar'; 148 + /** 149 + * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 150 + * 151 + * If you're tagging your specifications with custom tags, you can use 152 + * them to filter the results. When you provide multiple tags, only 153 + * the first match will be returned. 154 + */ 155 + tags?: ReadonlyArray<string>; 156 + /** 157 + * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 158 + * 159 + * Every OpenAPI document contains a required version field. You can 160 + * use this value to fetch the last uploaded specification matching 161 + * the value. 162 + */ 163 + version?: string; 164 + /** 165 + * Regenerate the client when the input file changes? You can alternatively 166 + * pass a numeric value for the interval in ms. 167 + */ 168 + watch: Watch; 86 169 }; 87 170 88 171 export type Watch = {
-12
packages/openapi-ts/src/types/types.d.ts
··· 12 12 */ 13 13 headers: Headers; 14 14 /** 15 - * Per-input watch state for multi-input mode. Keys should be a stable 16 - * identifier for the input (typically the URL or file path). 17 - */ 18 - inputs?: Record< 19 - string, 20 - { 21 - headers: Headers; 22 - isHeadMethodSupported?: boolean; 23 - lastValue?: string; 24 - } 25 - >; 26 - /** 27 15 * Can we send a HEAD request instead of fetching the whole specification? 28 16 * This value will be set after the first successful fetch. 29 17 */
+4
packages/openapi-ts/src/types/utils.d.ts
··· 10 10 }; 11 11 12 12 export type Files = Record<string, GeneratedFile>; 13 + 14 + export type LazyOrAsync<T> = T | (() => T) | (() => Promise<T>); 15 + 16 + export type MaybeArray<T> = T | ReadonlyArray<T>;
+19 -7
packages/openapi-ts/src/utils/input/__tests__/readme.test.ts
··· 91 91 describe('inputToReadmePath', () => { 92 92 it('should transform simple UUID format to API URL', () => { 93 93 const result = inputToReadmePath('readme:abc123'); 94 - expect(result).toBe('https://dash.readme.com/api/v1/api-registry/abc123'); 94 + expect(result).toEqual({ 95 + path: 'https://dash.readme.com/api/v1/api-registry/abc123', 96 + registry: 'readme', 97 + uuid: 'abc123', 98 + }); 95 99 }); 96 100 97 101 it('should transform full format to API URL', () => { 98 102 const result = inputToReadmePath('readme:@myorg/myproject#uuid123'); 99 - expect(result).toBe( 100 - 'https://dash.readme.com/api/v1/api-registry/uuid123', 101 - ); 103 + expect(result).toEqual({ 104 + organization: 'myorg', 105 + path: 'https://dash.readme.com/api/v1/api-registry/uuid123', 106 + project: 'myproject', 107 + registry: 'readme', 108 + uuid: 'uuid123', 109 + }); 102 110 }); 103 111 104 112 it('should throw error for invalid inputs', () => { ··· 137 145 'should handle $input correctly', 138 146 ({ expected, input }) => { 139 147 expect(parseShorthand(input)).toEqual(expected); 140 - expect(inputToReadmePath(`readme:${input}`)).toBe( 141 - `https://dash.readme.com/api/v1/api-registry/${expected.uuid}`, 142 - ); 148 + expect(inputToReadmePath(`readme:${input}`)).toEqual({ 149 + organization: expected.organization, 150 + path: `https://dash.readme.com/api/v1/api-registry/${expected.uuid}`, 151 + project: expected.project, 152 + registry: 'readme', 153 + uuid: expected.uuid, 154 + }); 143 155 }, 144 156 ); 145 157
+12 -6
packages/openapi-ts/src/utils/input/__tests__/scalar.test.ts
··· 76 76 describe('inputToScalarPath', () => { 77 77 it('should transform full format to API URL', () => { 78 78 const result = inputToScalarPath('scalar:@foo/bar'); 79 - expect(result).toBe( 80 - 'https://registry.scalar.com/@foo/apis/bar/latest?format=json', 81 - ); 79 + expect(result).toEqual({ 80 + organization: '@foo', 81 + path: 'https://registry.scalar.com/@foo/apis/bar/latest?format=json', 82 + project: 'bar', 83 + registry: 'scalar', 84 + }); 82 85 }); 83 86 84 87 it('should throw error for invalid inputs', () => { ··· 110 113 'should handle $input correctly', 111 114 ({ expected, input }) => { 112 115 expect(parseShorthand(input)).toEqual(expected); 113 - expect(inputToScalarPath(`scalar:${input}`)).toBe( 114 - `https://registry.scalar.com/${expected.organization}/apis/${expected.project}/latest?format=json`, 115 - ); 116 + expect(inputToScalarPath(`scalar:${input}`)).toEqual({ 117 + organization: expected.organization, 118 + path: `https://registry.scalar.com/${expected.organization}/apis/${expected.project}/latest?format=json`, 119 + project: expected.project, 120 + registry: 'scalar', 121 + }); 116 122 }, 117 123 ); 118 124
+12 -10
packages/openapi-ts/src/utils/input/heyApi.ts
··· 1 - // Regular expression to match Hey API Registry input formats: 2 - 3 1 import type { Input } from '../../types/input'; 4 2 3 + // Regular expression to match Hey API Registry input formats: 5 4 // - {organization}/{project}?{queryParams} 6 5 const registryRegExp = /^([\w-]+)\/([\w-]+)(?:\?([\w=&.-]*))?$/; 7 6 ··· 36 35 * @throws Error if the input format is invalid 37 36 */ 38 37 export const parseShorthand = ( 39 - input: Omit<Input, 'path'> & { 38 + input: Input & { 40 39 path: string; 41 40 }, 42 41 ): Parsed => { ··· 82 81 * @returns The Hey API Registry URL 83 82 */ 84 83 export const inputToHeyApiPath = ( 85 - input: Omit<Input, 'path'> & { 84 + input: Input & { 86 85 path: string; 87 86 }, 88 - ): string => { 87 + ): Partial<Input> => { 89 88 const parsed = parseShorthand(input); 90 - return getRegistryUrl( 91 - parsed.organization, 92 - parsed.project, 93 - parsed.queryParams, 94 - ); 89 + return { 90 + path: getRegistryUrl( 91 + parsed.organization, 92 + parsed.project, 93 + parsed.queryParams, 94 + ), 95 + registry: 'hey-api', 96 + }; 95 97 };
+6 -6
packages/openapi-ts/src/utils/input/index.ts
··· 9 9 }, 10 10 ) => { 11 11 if (input.path.startsWith('readme:')) { 12 - input.path = inputToReadmePath(input.path); 12 + Object.assign(input, inputToReadmePath(input.path)); 13 13 return; 14 14 } 15 15 16 16 if (input.path.startsWith('scalar:')) { 17 - input.path = inputToScalarPath(input.path); 17 + Object.assign(input, inputToScalarPath(input.path)); 18 18 return; 19 19 } 20 20 ··· 24 24 25 25 if (input.path.startsWith(heyApiRegistryBaseUrl)) { 26 26 input.path = input.path.slice(heyApiRegistryBaseUrl.length + 1); 27 - input.path = inputToHeyApiPath(input as Input & { path: string }); 27 + Object.assign(input, inputToHeyApiPath(input as Input & { path: string })); 28 28 return; 29 29 } 30 30 31 31 const parts = input.path.split('/'); 32 - const cleanParts = parts.filter(Boolean); 33 - if (parts.length === 2 && cleanParts.length === 2) { 34 - input.path = inputToHeyApiPath(input as Input & { path: string }); 32 + if (parts.length === 2 && parts.filter(Boolean).length === 2) { 33 + Object.assign(input, inputToHeyApiPath(input as Input & { path: string })); 34 + return; 35 35 } 36 36 };
+8 -2
packages/openapi-ts/src/utils/input/readme.ts
··· 1 + import type { Input } from '../../types/input'; 2 + 1 3 // Regular expression to match ReadMe API Registry input formats: 2 4 // - @{organization}/{project}#{uuid} 3 5 // - {uuid} ··· 57 59 * @param input - ReadMe format string 58 60 * @returns The ReadMe API Registry URL 59 61 */ 60 - export const inputToReadmePath = (input: string): string => { 62 + export const inputToReadmePath = (input: string): Partial<Input> => { 61 63 const shorthand = input.slice(`${namespace}:`.length); 62 64 const parsed = parseShorthand(shorthand); 63 - return getRegistryUrl(parsed.uuid); 65 + return { 66 + ...parsed, 67 + path: getRegistryUrl(parsed.uuid), 68 + registry: 'readme', 69 + }; 64 70 };
+8 -2
packages/openapi-ts/src/utils/input/scalar.ts
··· 1 + import type { Input } from '../../types/input'; 2 + 1 3 // Regular expression to match Scalar API Registry input formats: 2 4 // - @{organization}/{project} 3 5 const registryRegExp = /^(@[\w-]+)\/([\w.-]+)$/; ··· 59 61 * @param input - Scalar format string 60 62 * @returns The Scalar API Registry URL 61 63 */ 62 - export const inputToScalarPath = (input: string): string => { 64 + export const inputToScalarPath = (input: string): Partial<Input> => { 63 65 const shorthand = input.slice(`${namespace}:`.length); 64 66 const parsed = parseShorthand(shorthand); 65 - return getRegistryUrl(parsed.organization, parsed.project); 67 + return { 68 + ...parsed, 69 + path: getRegistryUrl(parsed.organization, parsed.project), 70 + registry: 'scalar', 71 + }; 66 72 };