+3
-1
packages/nuxt/src/module.ts
+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
+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
+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
+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
+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
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
*/
+19
-10
packages/openapi-ts/src/types/config.d.ts
+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
+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
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
+4
packages/openapi-ts/src/types/utils.d.ts
+19
-7
packages/openapi-ts/src/utils/input/__tests__/readme.test.ts
+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
+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
+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
+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
+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
+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
};