Skip to content

Commit e3049e8

Browse files
committed
(thunderstore-api) Implement user-facing error handling improvements and update tests for authentication errors
1 parent e20d99e commit e3049e8

File tree

4 files changed

+168
-37
lines changed

4 files changed

+168
-37
lines changed

packages/thunderstore-api/src/apiFetch.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -44,36 +44,41 @@ function sleep(delay: number) {
4444
return new Promise((resolve) => setTimeout(resolve, delay));
4545
}
4646

47-
export type apiFetchArgs<B, QP> = {
47+
type SchemaOrUndefined<Schema extends z.ZodSchema | undefined> =
48+
Schema extends z.ZodSchema ? z.infer<Schema> : undefined;
49+
50+
type apiFetchArgs<
51+
RequestSchema extends z.ZodSchema | undefined,
52+
QueryParamsSchema extends z.ZodSchema | undefined,
53+
ResponseSchema extends z.ZodSchema | undefined,
54+
> = {
4855
config: () => RequestConfig;
4956
path: string;
50-
queryParams?: QP;
57+
queryParams?: SchemaOrUndefined<QueryParamsSchema>;
5158
request?: Omit<RequestInit, "headers" | "body"> & { body?: string };
5259
useSession?: boolean;
53-
bodyRaw?: B;
60+
bodyRaw?: SchemaOrUndefined<RequestSchema>;
61+
requestSchema: RequestSchema;
62+
queryParamsSchema: QueryParamsSchema;
63+
responseSchema: ResponseSchema;
5464
};
5565

56-
type schemaOrUndefined<A> = A extends z.ZodSchema
57-
? z.infer<A>
58-
: never | undefined;
59-
60-
export async function apiFetch(props: {
61-
args: apiFetchArgs<
62-
schemaOrUndefined<typeof props.requestSchema>,
63-
schemaOrUndefined<typeof props.queryParamsSchema>
64-
>;
65-
requestSchema: z.ZodSchema | undefined;
66-
queryParamsSchema: z.ZodSchema | undefined;
67-
responseSchema: z.ZodSchema | undefined;
68-
}): Promise<schemaOrUndefined<typeof props.responseSchema>> {
69-
const { args, requestSchema, queryParamsSchema, responseSchema } = props;
66+
export async function apiFetch<
67+
RequestSchema extends z.ZodSchema | undefined,
68+
QueryParamsSchema extends z.ZodSchema | undefined,
69+
ResponseSchema extends z.ZodSchema | undefined,
70+
>(
71+
args: apiFetchArgs<RequestSchema, QueryParamsSchema, ResponseSchema>
72+
): Promise<SchemaOrUndefined<ResponseSchema>> {
73+
const { requestSchema, queryParamsSchema, responseSchema } = args;
7074

7175
if (requestSchema && args.bodyRaw) {
7276
const parsedRequestBody = requestSchema.safeParse(args.bodyRaw);
7377
if (!parsedRequestBody.success) {
7478
throw new RequestBodyParseError(parsedRequestBody.error);
7579
}
7680
}
81+
7782
if (queryParamsSchema && args.queryParams) {
7883
const parsedQueryParams = queryParamsSchema.safeParse(args.queryParams);
7984
if (!parsedQueryParams.success) {
@@ -88,8 +93,7 @@ export async function apiFetch(props: {
8893
apiHost: config().apiHost,
8994
sessionId: undefined,
9095
};
91-
// TODO: Query params have stronger types, but they are not just shown here.
92-
// Look into furthering the ensuring of passing proper query params.
96+
const sessionWasUsed = Boolean(usedConfig.sessionId);
9397
const url = getUrl(usedConfig, path, queryParams);
9498

9599
const response = await fetchRetry(url, {
@@ -101,17 +105,21 @@ export async function apiFetch(props: {
101105
});
102106

103107
if (!response.ok) {
104-
throw await ApiError.createFromResponse(response);
108+
throw await ApiError.createFromResponse(response, {
109+
sessionWasUsed,
110+
});
105111
}
106112

107-
if (responseSchema === undefined) return undefined;
113+
if (responseSchema === undefined) {
114+
return undefined as SchemaOrUndefined<ResponseSchema>;
115+
}
108116

109117
const parsed = responseSchema.safeParse(await response.json());
110118
if (!parsed.success) {
111119
throw new ParseError(parsed.error);
112-
} else {
113-
return parsed.data;
114120
}
121+
122+
return parsed.data;
115123
}
116124

117125
function getAuthHeaders(config: RequestConfig): RequestInit["headers"] {
Lines changed: 135 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
import { z } from "zod";
22

3+
import { sanitizeServerDetail } from "./errors/sanitizeServerDetail";
4+
import { formatErrorMessage } from "./utils";
5+
36
type JSONValue =
47
| string
58
| number
69
| boolean
710
| { [x: string]: JSONValue }
8-
| JSONValue[];
11+
| JSONValue[]
12+
| null;
13+
14+
type ApiErrorContext = {
15+
sessionWasUsed?: boolean;
16+
};
17+
18+
const JSON_MESSAGE_PRIORITY_KEYS = [
19+
"detail",
20+
"message",
21+
"error",
22+
"errors",
23+
"non_field_errors",
24+
"root",
25+
"__all__",
26+
"title",
27+
];
928

1029
export function isApiError(e: Error | ApiError | unknown): e is ApiError {
1130
return e instanceof ApiError;
@@ -14,69 +33,173 @@ export function isApiError(e: Error | ApiError | unknown): e is ApiError {
1433
export class ApiError extends Error {
1534
response: Response;
1635
responseJson?: JSONValue;
36+
responseText?: string;
37+
context?: ApiErrorContext;
38+
responseSummary?: string;
1739

1840
constructor(args: {
1941
message: string;
2042
response: Response;
2143
responseJson?: JSONValue;
44+
responseText?: string;
45+
context?: ApiErrorContext;
46+
responseSummary?: string;
2247
}) {
2348
super(args.message);
2449
this.responseJson = args.responseJson;
2550
this.response = args.response;
51+
this.responseText = args.responseText;
52+
this.context = args.context;
53+
this.responseSummary = args.responseSummary;
2654
}
2755

28-
static async createFromResponse(response: Response): Promise<ApiError> {
56+
static async createFromResponse(
57+
response: Response,
58+
context: ApiErrorContext = {}
59+
): Promise<ApiError> {
60+
let responseText: string | undefined;
2961
let responseJson: JSONValue | undefined;
3062
try {
31-
responseJson = await response.json();
63+
responseText = await response.clone().text();
64+
} catch {
65+
responseText = undefined;
66+
}
67+
try {
68+
responseJson = responseText
69+
? JSON.parse(responseText)
70+
: await response.json();
3271
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3372
} catch (e) {
3473
responseJson = undefined;
3574
}
3675

76+
const responseSummary = extractMessage(responseJson ?? responseText);
77+
3778
return new ApiError({
38-
message: `${response.status}: ${response.statusText}`,
79+
message: buildApiErrorMessage({ response, responseJson }),
3980
response: response,
4081
responseJson: responseJson,
82+
responseText,
83+
context,
84+
responseSummary,
4185
});
4286
}
4387

4488
getFieldErrors(): { [key: string | "root"]: string[] } {
45-
if (typeof this.responseJson !== "object")
89+
if (!this.responseJson || typeof this.responseJson !== "object")
4690
return { root: ["Unknown error occurred"] };
4791
if (Array.isArray(this.responseJson)) {
4892
return {
49-
root: this.responseJson.map((x) => x.toString()),
93+
root: this.responseJson.map((x) => String(x)),
5094
};
5195
} else {
5296
return Object.fromEntries(
5397
Object.entries(this.responseJson).map(([key, val]) => {
5498
return [
5599
key,
56-
Array.isArray(val)
57-
? val.map((x) => x.toString())
58-
: [val.toString()],
100+
Array.isArray(val) ? val.map((x) => String(x)) : [String(val)],
59101
];
60102
})
61103
);
62104
}
63105
}
106+
107+
get statusCode(): number {
108+
return this.response.status;
109+
}
110+
111+
get statusText(): string {
112+
return this.response.statusText;
113+
}
64114
}
65115

116+
/**
117+
* Raised when a request body fails validation against its Zod schema.
118+
*/
66119
export class RequestBodyParseError extends Error {
67120
constructor(public error: z.ZodError) {
68-
super(error.message);
121+
super(formatErrorMessage(error));
69122
}
70123
}
71124

125+
/**
126+
* Raised when query parameters fail validation against their Zod schema.
127+
*/
72128
export class RequestQueryParamsParseError extends Error {
73129
constructor(public error: z.ZodError) {
74-
super(error.message);
130+
super(formatErrorMessage(error));
75131
}
76132
}
77133

134+
/**
135+
* Raised when response payloads fail validation against their expected Zod schema.
136+
*/
78137
export class ParseError extends Error {
79138
constructor(public error: z.ZodError) {
80-
super(error.message);
139+
super(formatErrorMessage(error));
140+
}
141+
}
142+
143+
function buildApiErrorMessage(args: {
144+
response: Response;
145+
responseJson?: JSONValue;
146+
}): string {
147+
const { response, responseJson } = args;
148+
const statusLabel = getStatusLabel(response);
149+
const detailMessage = extractMessage(responseJson);
150+
151+
if (detailMessage) {
152+
return `${detailMessage} (${statusLabel})`;
153+
}
154+
155+
return statusLabel;
156+
}
157+
158+
function extractMessage(value: JSONValue | undefined): string | undefined {
159+
if (value === undefined || value === null) return undefined;
160+
161+
if (typeof value === "string") {
162+
return sanitizeServerDetail(value);
163+
}
164+
165+
if (typeof value === "number" || typeof value === "boolean") {
166+
return sanitizeServerDetail(String(value));
167+
}
168+
169+
if (Array.isArray(value)) {
170+
const parts = value
171+
.map((item) => extractMessage(item))
172+
.filter((item): item is string => Boolean(item));
173+
174+
if (parts.length > 0) {
175+
return sanitizeServerDetail(parts.join(" "));
176+
}
177+
178+
return undefined;
81179
}
180+
181+
const objectValue = value as { [x: string]: JSONValue };
182+
183+
for (const key of JSON_MESSAGE_PRIORITY_KEYS) {
184+
if (key in objectValue) {
185+
const message = extractMessage(objectValue[key]);
186+
if (message) {
187+
return sanitizeServerDetail(message);
188+
}
189+
}
190+
}
191+
192+
for (const entry of Object.values(objectValue)) {
193+
const message = extractMessage(entry);
194+
if (message) {
195+
return message;
196+
}
197+
}
198+
199+
return undefined;
200+
}
201+
202+
function getStatusLabel(response: Response): string {
203+
const statusText = response.statusText?.trim() || "Error";
204+
return `${response.status} ${statusText}`.trim();
82205
}

packages/thunderstore-api/src/get/__tests__/teamMembers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ it("ensures accessing team members requires authentication", async () => {
1111
data: {},
1212
queryParams: {},
1313
})
14-
).rejects.toThrowError("401: Unauthorized");
14+
).rejects.toThrowError("Authentication required. Please sign in.");
1515
});

packages/thunderstore-api/src/get/__tests__/teamServiceAccounts.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ it("ensures accessing team members requires authentication", async () => {
1111
data: {},
1212
queryParams: {},
1313
})
14-
).rejects.toThrowError("401: Unauthorized");
14+
).rejects.toThrowError("Authentication required. Please sign in.");
1515
});

0 commit comments

Comments
 (0)