fork of hey-api/openapi-ts because I need some additional things
1import path from 'node:path';
2
3import { type Logger, Project } from '@hey-api/codegen-core';
4import { $RefParser, ResolverError } from '@hey-api/json-schema-ref-parser';
5import type { Input, OpenApi, WatchValues } from '@hey-api/shared';
6import {
7 applyNaming,
8 buildGraph,
9 compileInputPath,
10 Context,
11 getSpec,
12 InputError,
13 logInputPaths,
14 parseOpenApiSpec,
15 patchOpenApiSpec,
16 postprocessOutput,
17} from '@hey-api/shared';
18import colors from 'ansi-colors';
19
20import { postProcessors } from './config/output/postprocess';
21import type { Config } from './config/types';
22import { generateOutput } from './generate/output';
23import { PythonRenderer } from './py-dsl';
24
25export async function createClient({
26 config,
27 dependencies,
28 jobIndex,
29 logger,
30 watches: _watches,
31}: {
32 config: Config;
33 dependencies: Record<string, string>;
34 jobIndex: number;
35 logger: Logger;
36 /**
37 * Always undefined on the first run, defined on subsequent runs.
38 */
39 watches?: ReadonlyArray<WatchValues>;
40}): Promise<Context | undefined> {
41 const watches: ReadonlyArray<WatchValues> =
42 _watches ||
43 Array.from({ length: config.input.length }, () => ({
44 headers: new Headers(),
45 }));
46
47 const inputPaths = config.input.map((input) => compileInputPath(input));
48
49 // on first run, print the message as soon as possible
50 if (config.logs.level !== 'silent' && !_watches) {
51 logInputPaths(inputPaths, jobIndex);
52 }
53
54 const getSpecData = async (input: Input, index: number) => {
55 const eventSpec = logger.timeEvent('spec');
56 const { arrayBuffer, error, resolvedInput, response } = await getSpec({
57 fetchOptions: input.fetch,
58 inputPath: inputPaths[index]!.path,
59 timeout: input.watch.timeout,
60 watch: watches[index]!,
61 });
62 eventSpec.timeEnd();
63
64 // throw on first run if there's an error to preserve user experience
65 // if in watch mode, subsequent errors won't throw to gracefully handle
66 // cases where server might be reloading
67 if (error && !_watches) {
68 const text = await response.text().catch((): string => '');
69 const message = `Request failed with status ${response.status}: ${text || response.statusText}`;
70 // Handle 4xx client errors as input errors (bad URL, bad API key, etc.)
71 if (response.status >= 400 && response.status < 500) {
72 const statusText = response.statusText || 'Unknown';
73 const originalError = new Error(message) as Error & { source?: string };
74 originalError.source = String(inputPaths[index]!.path);
75 throw new InputError(
76 `Input request failed: ${response.status} ${statusText}`,
77 originalError,
78 );
79 }
80 // For 5xx server errors, keep the generic error (could be a bug)
81 throw new Error(message);
82 }
83
84 return { arrayBuffer, resolvedInput };
85 };
86 const specData = (
87 await Promise.all(config.input.map((input, index) => getSpecData(input, index)))
88 ).filter((data) => data.arrayBuffer || data.resolvedInput);
89
90 let context: Context | undefined;
91
92 if (specData.length) {
93 const refParser = new $RefParser();
94 let data: unknown;
95 try {
96 data =
97 specData.length > 1
98 ? await refParser.bundleMany({
99 arrayBuffer: specData.map((data) => data.arrayBuffer!),
100 pathOrUrlOrSchemas: [],
101 resolvedInputs: specData.map((data) => data.resolvedInput!),
102 })
103 : await refParser.bundle({
104 arrayBuffer: specData[0]!.arrayBuffer,
105 pathOrUrlOrSchema: undefined,
106 resolvedInput: specData[0]!.resolvedInput!,
107 });
108 } catch (err) {
109 if (err instanceof ResolverError && err.ioErrorCode === 'ENOENT') {
110 throw new InputError('Input file not found', err);
111 }
112 throw err;
113 }
114
115 // on subsequent runs in watch mode, print the message only if we know we're
116 // generating the output
117 if (config.logs.level !== 'silent' && _watches) {
118 console.clear();
119 logInputPaths(inputPaths, jobIndex);
120 }
121
122 const eventInputPatch = logger.timeEvent('input.patch');
123 await patchOpenApiSpec({ patchOptions: config.parser.patch, spec: data });
124 eventInputPatch.timeEnd();
125
126 const eventParser = logger.timeEvent('parser');
127 const header = config.output.header;
128 // TODO: allow overriding via config
129 const project = new Project({
130 defaultFileName: '__init__',
131 fileName: (base) => {
132 const name = applyNaming(base, config.output.fileName);
133 const { suffix } = config.output.fileName;
134 if (!suffix) {
135 return name;
136 }
137 return name === '__init__' || name.endsWith(suffix) ? name : `${name}${suffix}`;
138 },
139 nameConflictResolvers: config.output.nameConflictResolver
140 ? {
141 python: config.output.nameConflictResolver,
142 }
143 : undefined,
144 renderers: [
145 new PythonRenderer({
146 header: (ctx) => {
147 const defaultValue = ['# This file is auto-generated by @hey-api/openapi-python'];
148 const result = typeof header === 'function' ? header({ ...ctx, defaultValue }) : header;
149 return result === undefined ? defaultValue : result;
150 },
151 module: config.output.module,
152 preferExportAll: config.output.preferExportAll,
153 }),
154 ],
155 root: config.output.path,
156 });
157 context = new Context<OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X, Config>({
158 config,
159 dependencies,
160 logger,
161 project,
162 spec: data as OpenApi.V2_0_X | OpenApi.V3_0_X | OpenApi.V3_1_X,
163 });
164 parseOpenApiSpec(context);
165 context.graph = buildGraph(context.ir, logger).graph;
166 eventParser.timeEnd();
167
168 const eventGenerator = logger.timeEvent('generator');
169 await generateOutput(context);
170 eventGenerator.timeEnd();
171
172 const eventPostprocess = logger.timeEvent('postprocess');
173 if (!config.dryRun) {
174 const jobPrefix = colors.gray(`[Job ${jobIndex + 1}] `);
175 postprocessOutput(config.output, postProcessors, jobPrefix);
176
177 if (config.logs.level !== 'silent') {
178 const outputPath = process.env.INIT_CWD
179 ? `./${path.relative(process.env.INIT_CWD, config.output.path)}`
180 : config.output.path;
181 console.log(
182 `${jobPrefix}${colors.green('✅ Done!')} Your output is in ${colors.cyanBright(outputPath)}`,
183 );
184 }
185 }
186 eventPostprocess.timeEnd();
187 }
188
189 const watchedInput = config.input.find(
190 (input, index) => input.watch.enabled && typeof inputPaths[index]!.path === 'string',
191 );
192
193 if (watchedInput) {
194 setTimeout(() => {
195 createClient({
196 config,
197 dependencies,
198 jobIndex,
199 logger,
200 watches,
201 });
202 }, watchedInput.watch.interval);
203 }
204
205 return context;
206}