Skip to content

Commit b643d7d

Browse files
committed
fix: improve invalid input error
1 parent 5e1eaea commit b643d7d

10 files changed

Lines changed: 183 additions & 45 deletions

File tree

.changeset/mean-tips-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/openapi-ts": patch
3+
---
4+
5+
**cli**: improve error message on invalid input

.changeset/rude-glasses-prove.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/shared": patch
3+
---
4+
5+
**error**: handle InputError

.changeset/seven-doors-learn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/json-schema-ref-parser": patch
3+
---
4+
5+
**internal**: export errors

packages/json-schema-ref-parser/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,3 +585,17 @@ export class $RefParser {
585585

586586
export { sendRequest } from './resolvers/url';
587587
export type { JSONSchema } from './types';
588+
export type { JSONParserErrorType } from './util/errors';
589+
export {
590+
InvalidPointerError,
591+
isHandledError,
592+
JSONParserError,
593+
JSONParserErrorGroup,
594+
MissingPointerError,
595+
normalizeError,
596+
ParserError,
597+
ResolverError,
598+
TimeoutError,
599+
UnmatchedParserError,
600+
UnmatchedResolverError,
601+
} from './util/errors';

packages/openapi-python/src/createClient.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
import path from 'node:path';
22

33
import { type Logger, Project } from '@hey-api/codegen-core';
4-
import { $RefParser } from '@hey-api/json-schema-ref-parser';
4+
import { $RefParser, ResolverError } from '@hey-api/json-schema-ref-parser';
5+
import type { Input, OpenApi, WatchValues } from '@hey-api/shared';
56
import {
67
applyNaming,
78
buildGraph,
89
compileInputPath,
910
Context,
1011
getSpec,
11-
type Input,
12+
InputError,
1213
logInputPaths,
13-
type OpenApi,
1414
parseOpenApiSpec,
1515
patchOpenApiSpec,
1616
postprocessOutput,
17-
type WatchValues,
1817
} from '@hey-api/shared';
1918
import colors from 'ansi-colors';
2019

@@ -66,10 +65,20 @@ export async function createClient({
6665
// if in watch mode, subsequent errors won't throw to gracefully handle
6766
// cases where server might be reloading
6867
if (error && !_watches) {
69-
const text = await response.text().catch(() => '');
70-
throw new Error(
71-
`Request failed with status ${response.status}: ${text || response.statusText}`,
72-
);
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);
7382
}
7483

7584
return { arrayBuffer, resolvedInput };
@@ -82,18 +91,26 @@ export async function createClient({
8291

8392
if (specData.length) {
8493
const refParser = new $RefParser();
85-
const data =
86-
specData.length > 1
87-
? await refParser.bundleMany({
88-
arrayBuffer: specData.map((data) => data.arrayBuffer!),
89-
pathOrUrlOrSchemas: [],
90-
resolvedInputs: specData.map((data) => data.resolvedInput!),
91-
})
92-
: await refParser.bundle({
93-
arrayBuffer: specData[0]!.arrayBuffer,
94-
pathOrUrlOrSchema: undefined,
95-
resolvedInput: specData[0]!.resolvedInput!,
96-
});
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+
}
97114

98115
// on subsequent runs in watch mode, print the message only if we know we're
99116
// generating the output

packages/openapi-python/src/generate.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Context } from '@hey-api/shared';
66
import {
77
checkNodeVersion,
88
ConfigValidationError,
9+
getInputError,
910
getLogs,
1011
JobError,
1112
logCrashReport,
@@ -100,18 +101,25 @@ export async function createClient(
100101
rawLogs;
101102
const dryRun =
102103
jobs.some((job) => job.config.dryRun) ?? userConfigs.some((config) => config.dryRun) ?? false;
103-
const logPath = logs?.file && !dryRun ? logCrashReport(error, logs.path ?? '') : undefined;
104+
105+
const inputError = getInputError(error);
106+
const normalizedError = inputError ?? error;
107+
108+
const logPath =
109+
logs?.file && !dryRun ? logCrashReport(normalizedError, logs.path ?? '') : undefined;
104110
if (!logs || logs.level !== 'silent') {
105111
printCrashReport({ error, logPath });
106112
const isInteractive =
107113
jobs.some((job) => job.config.interactive) ??
108114
userConfigs.some((config) => config.interactive) ??
109115
false;
110-
if (await shouldReportCrash({ error, isInteractive })) {
116+
if (await shouldReportCrash({ error: normalizedError, isInteractive })) {
111117
await openGitHubIssueWithCrashReport(error, __dirname);
112118
}
113119
}
114120

121+
if (inputError) return [];
122+
115123
throw error;
116124
}
117125
}

packages/openapi-ts/src/createClient.ts

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
import path from 'node:path';
22

33
import { type Logger, Project } from '@hey-api/codegen-core';
4-
import { $RefParser } from '@hey-api/json-schema-ref-parser';
4+
import { $RefParser, ResolverError } from '@hey-api/json-schema-ref-parser';
5+
import type { Input, OpenApi, WatchValues } from '@hey-api/shared';
56
import {
67
applyNaming,
78
buildGraph,
89
compileInputPath,
910
Context,
1011
getSpec,
11-
type Input,
12+
InputError,
1213
logInputPaths,
13-
type OpenApi,
1414
parseOpenApiSpec,
1515
patchOpenApiSpec,
1616
postprocessOutput,
17-
type WatchValues,
1817
} from '@hey-api/shared';
1918
import colors from 'ansi-colors';
2019

@@ -67,9 +66,19 @@ export async function createClient({
6766
// cases where server might be reloading
6867
if (error && !_watches) {
6968
const text = await response.text().catch(() => '');
70-
throw new Error(
71-
`Request failed with status ${response.status}: ${text || response.statusText}`,
72-
);
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);
7382
}
7483

7584
return { arrayBuffer, resolvedInput };
@@ -82,18 +91,26 @@ export async function createClient({
8291

8392
if (specData.length) {
8493
const refParser = new $RefParser();
85-
const data =
86-
specData.length > 1
87-
? await refParser.bundleMany({
88-
arrayBuffer: specData.map((data) => data.arrayBuffer!),
89-
pathOrUrlOrSchemas: [],
90-
resolvedInputs: specData.map((data) => data.resolvedInput!),
91-
})
92-
: await refParser.bundle({
93-
arrayBuffer: specData[0]!.arrayBuffer,
94-
pathOrUrlOrSchema: undefined,
95-
resolvedInput: specData[0]!.resolvedInput!,
96-
});
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+
}
97114

98115
// on subsequent runs in watch mode, print the message only if we know we're
99116
// generating the output

packages/openapi-ts/src/generate.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Context } from '@hey-api/shared';
66
import {
77
checkNodeVersion,
88
ConfigValidationError,
9+
getInputError,
910
getLogs,
1011
JobError,
1112
logCrashReport,
@@ -100,18 +101,25 @@ export async function createClient(
100101
rawLogs;
101102
const dryRun =
102103
jobs.some((job) => job.config.dryRun) ?? userConfigs.some((config) => config.dryRun) ?? false;
103-
const logPath = logs?.file && !dryRun ? logCrashReport(error, logs.path ?? '') : undefined;
104+
105+
const inputError = getInputError(error);
106+
const normalizedError = inputError ?? error;
107+
108+
const logPath =
109+
logs?.file && !dryRun ? logCrashReport(normalizedError, logs.path ?? '') : undefined;
104110
if (!logs || logs.level !== 'silent') {
105111
printCrashReport({ error, logPath });
106112
const isInteractive =
107113
jobs.some((job) => job.config.interactive) ??
108114
userConfigs.some((config) => config.interactive) ??
109115
false;
110-
if (await shouldReportCrash({ error, isInteractive })) {
116+
if (await shouldReportCrash({ error: normalizedError, isInteractive })) {
111117
await openGitHubIssueWithCrashReport(error, __dirname);
112118
}
113119
}
114120

121+
if (inputError) return [];
122+
115123
throw error;
116124
}
117125
}

packages/shared/src/error.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ export class ConfigValidationError extends Error {
3737
}
3838
}
3939

40+
/**
41+
* Represents an error caused by invalid or inaccessible input.
42+
*
43+
* Used for errors like file not found, URL not reachable, etc.
44+
*/
45+
export class InputError extends Error {
46+
readonly originalError: Error & { source?: string };
47+
48+
constructor(message: string, originalError: Error & { source?: string }) {
49+
super(message);
50+
this.name = 'InputError';
51+
this.originalError = originalError;
52+
}
53+
}
54+
4055
/**
4156
* Represents a runtime error originating from a specific job.
4257
*
@@ -83,7 +98,11 @@ export class HeyApiError extends Error {
8398
}
8499

85100
export function logCrashReport(error: unknown, logsDir: string): string | undefined {
86-
if (error instanceof ConfigError || error instanceof ConfigValidationError) {
101+
if (
102+
error instanceof ConfigError ||
103+
error instanceof ConfigValidationError ||
104+
error instanceof InputError
105+
) {
87106
return;
88107
}
89108

@@ -157,6 +176,15 @@ export async function openGitHubIssueWithCrashReport(
157176
await open(url);
158177
}
159178

179+
export function getInputError(error: unknown): InputError | undefined {
180+
if (error instanceof InputError) {
181+
return error;
182+
}
183+
if (error instanceof JobError && error.originalError.error instanceof InputError) {
184+
return error.originalError.error;
185+
}
186+
}
187+
160188
export function printCrashReport({
161189
error,
162190
logPath,
@@ -193,6 +221,30 @@ export function printCrashReport({
193221
error = error.originalError.error;
194222
}
195223

224+
if (error instanceof InputError) {
225+
const source = (error.originalError as { source?: string }).source;
226+
const itemPrefixStr = ` `;
227+
228+
const isNetworkError = error.message.startsWith('Input request failed');
229+
if (isNetworkError) {
230+
console.error(`${jobPrefix}${colors.red(`❌ ${error.message}`)}`);
231+
if (source) console.error(colors.gray(source));
232+
console.error(colors.gray('\nPlease verify that:'));
233+
console.error(colors.gray(`${itemPrefixStr}• The URL is correct`));
234+
console.error(colors.gray(`${itemPrefixStr}• Your API key is valid`));
235+
console.error(colors.gray(`${itemPrefixStr}• You have network access`));
236+
return;
237+
}
238+
239+
console.error(`${jobPrefix}${colors.red('❌ Input file not found:')}`);
240+
if (source) console.error(colors.gray(source));
241+
console.error(colors.gray('\nPlease verify that:'));
242+
console.error(colors.gray(`${itemPrefixStr}• The file exists`));
243+
console.error(colors.gray(`${itemPrefixStr}• The path is correct`));
244+
console.error(colors.gray(`${itemPrefixStr}• You have read permissions`));
245+
return;
246+
}
247+
196248
const baseString = colors.red('Failed with the message:');
197249
console.error(`${jobPrefix}${baseString}`);
198250
const itemPrefixStr = ` `;
@@ -215,7 +267,12 @@ export async function shouldReportCrash({
215267
error: unknown;
216268
isInteractive: boolean | undefined;
217269
}): Promise<boolean> {
218-
if (!isInteractive || error instanceof ConfigError || error instanceof ConfigValidationError) {
270+
if (
271+
!isInteractive ||
272+
error instanceof ConfigError ||
273+
error instanceof ConfigValidationError ||
274+
error instanceof InputError
275+
) {
219276
return false;
220277
}
221278

packages/shared/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ export { debugTools } from './debug';
3535
export {
3636
ConfigError,
3737
ConfigValidationError,
38+
getInputError,
3839
HeyApiError,
40+
InputError,
3941
JobError,
4042
logCrashReport,
4143
openGitHubIssueWithCrashReport,

0 commit comments

Comments
 (0)