Skip to content

Commit e085217

Browse files
feat(http/unstable): split parseProblemDetails, validate status, add statusText option (#7117)
1 parent d1c28a0 commit e085217

2 files changed

Lines changed: 265 additions & 105 deletions

File tree

http/unstable_problem_details.ts

Lines changed: 121 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
*
88
* Provides {@linkcode createProblemDetailsResponse} to build a `Response` with
99
* an `application/problem+json` body, {@linkcode parseProblemDetails} to parse
10-
* from a `Response` or plain object, and {@linkcode isProblemDetailsResponse}
11-
* to detect problem-details responses by content type.
10+
* a plain object, {@linkcode parseProblemDetailsResponse} to parse a `Response`
11+
* body, and {@linkcode isProblemDetailsResponse} to detect problem-details
12+
* responses by content type.
1213
*
1314
* @example Basic 404 response
1415
* ```ts
@@ -20,16 +21,16 @@
2021
* });
2122
* ```
2223
*
23-
* @example Parse from a Response
24+
* @example Parse a Response
2425
* ```ts ignore
2526
* import {
2627
* isProblemDetailsResponse,
27-
* parseProblemDetails,
28+
* parseProblemDetailsResponse,
2829
* } from "@std/http/unstable-problem-details";
2930
*
3031
* const response = await fetch("https://api.example.com/resource");
3132
* if (isProblemDetailsResponse(response)) {
32-
* const problem = await parseProblemDetails(response);
33+
* const problem = await parseProblemDetailsResponse(response);
3334
* console.error(problem.detail);
3435
* }
3536
* ```
@@ -39,7 +40,7 @@
3940
*
4041
* @experimental **UNSTABLE**: New API, yet to be vetted.
4142
*
42-
* @see {@link https://www.rfc-editor.org/rfc/rfc9457.html}
43+
* @see {@link https://www.rfc-editor.org/rfc/rfc9457.html | RFC 9457}
4344
*
4445
* @module
4546
*/
@@ -62,14 +63,16 @@ export type StandardProblemDetailsMember =
6263
| "instance";
6364

6465
/**
65-
* Constraint for Problem Details extension members. Permits any string-keyed
66-
* properties except the five standard members defined by RFC 9457.
66+
* Constraint for Problem Details extension members.
6767
*
68-
* Uses `Omit` rather than a mapped `never` constraint so that TypeScript can
69-
* infer `T` from object literals at call sites without the standard keys
70-
* being captured into `T` and then failing a `never` check. If `T`
71-
* explicitly redeclares a standard key, the intersection with the base type
72-
* collapses that key to `never`, making it unusable.
68+
* The constraint itself is intentionally loose (effectively
69+
* `Record<string, unknown>`) so that TypeScript can infer `T` from object
70+
* literals at call sites without standard keys being captured into `T` and
71+
* then failing a stricter `never` check. Safety is enforced one level up: the
72+
* intersection in {@linkcode ProblemDetails} explicitly types the five
73+
* standard members, so if `T` redeclares any of them with an incompatible
74+
* type the resulting field collapses to `never` and the value becomes
75+
* unconstructible.
7376
*
7477
* @experimental **UNSTABLE**: New API, yet to be vetted.
7578
*/
@@ -86,13 +89,14 @@ export type ProblemDetailsExtensions = Omit<
8689
* top-level properties in both the TypeScript type and the serialized JSON
8790
* — matching the wire format exactly.
8891
*
89-
* The generic constraint on `T` prevents extension types from shadowing the
90-
* five standard members, which the RFC forbids.
92+
* If `T` declares a property whose name matches one of the five standard
93+
* members with an incompatible type, the intersection collapses that field
94+
* to `never`.
9195
*
9296
* @experimental **UNSTABLE**: New API, yet to be vetted.
9397
*/
9498
export type ProblemDetails<
95-
T extends ProblemDetailsExtensions = Record<never, never>,
99+
T extends ProblemDetailsExtensions = Record<string, never>,
96100
> = {
97101
/**
98102
* A URI reference identifying the problem type. Defaults to `"about:blank"`
@@ -117,6 +121,13 @@ export type ProblemDetails<
117121
export interface ProblemDetailsResponseOptions {
118122
/** Additional headers to include in the response. */
119123
headers?: HeadersInit;
124+
/**
125+
* Status text for the response. When omitted, the `Response` constructor's
126+
* default (the empty string, per the Fetch spec) is used; we deliberately
127+
* do not synthesize one from the standard HTTP status text — the JSON
128+
* `title` field already carries that information.
129+
*/
130+
statusText?: string;
120131
}
121132

122133
/**
@@ -131,6 +142,10 @@ export interface ProblemDetailsResponseOptions {
131142
* - Defaults `status` to `500` if not provided.
132143
* - Serializes directly to JSON — the flat intersection type already matches
133144
* the RFC wire format, so no flattening step is needed.
145+
* - Forwards `options.statusText` to the `Response` only when defined; when
146+
* omitted, the constructor's empty-string default is used and the standard
147+
* HTTP status text is intentionally not synthesized — the JSON `title`
148+
* field already carries that information.
134149
*
135150
* @experimental **UNSTABLE**: New API, yet to be vetted.
136151
*
@@ -142,6 +157,9 @@ export interface ProblemDetailsResponseOptions {
142157
* @returns A `Response` with status, `application/problem+json` content type,
143158
* and the serialized problem details as the body.
144159
*
160+
* @throws {RangeError} If `problemDetails.status` is not a finite integer, or
161+
* is any value the `Response` constructor rejects (i.e. outside 200–599).
162+
*
145163
* @example Basic 404 response
146164
* ```ts
147165
* import { createProblemDetailsResponse } from "@std/http/unstable-problem-details";
@@ -193,6 +211,14 @@ export function createProblemDetailsResponse<
193211

194212
if (pd.status === undefined) pd.status = 500;
195213

214+
if (!Number.isInteger(pd.status)) {
215+
throw new RangeError(
216+
`Cannot create Problem Details response: status must be a finite integer, received ${
217+
typeof pd.status === "string" ? `"${pd.status}"` : String(pd.status)
218+
}`,
219+
);
220+
}
221+
196222
if (pd.type === "about:blank" && pd.title === undefined) {
197223
const statusText = STATUS_TEXT[pd.status as keyof typeof STATUS_TEXT];
198224
if (statusText !== undefined) {
@@ -202,21 +228,26 @@ export function createProblemDetailsResponse<
202228

203229
const body = JSON.stringify(pd);
204230
const status = pd.status as number;
231+
const statusText = options?.statusText;
205232

206233
if (options?.headers === undefined) {
207234
return new Response(body, {
208235
status,
236+
...(statusText !== undefined ? { statusText } : {}),
209237
headers: { "Content-Type": PROBLEM_JSON_MEDIA_TYPE },
210238
});
211239
}
212240
const headers = new Headers(options.headers);
213241
headers.set("Content-Type", PROBLEM_JSON_MEDIA_TYPE);
214-
return new Response(body, { status, headers });
242+
return new Response(body, {
243+
status,
244+
...(statusText !== undefined ? { statusText } : {}),
245+
headers,
246+
});
215247
}
216248

217-
/** Per RFC 9457 §3.1: ignore standard members whose value type does not match. */
218249
function normalizeParsedProblemDetails(
219-
raw: Record<string, unknown>,
250+
raw: unknown,
220251
): Record<string, unknown> {
221252
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
222253
throw new TypeError(
@@ -227,10 +258,12 @@ function normalizeParsedProblemDetails(
227258
}
228259

229260
const result: Record<string, unknown> = {};
261+
const source = raw as Record<string, unknown>;
230262

231-
for (const key in raw) {
232-
if (!Object.hasOwn(raw, key)) continue;
233-
const value = raw[key];
263+
// RFC 9457 §3.1: ignore standard members whose value type does not match.
264+
for (const key in source) {
265+
if (!Object.hasOwn(source, key)) continue;
266+
const value = source[key];
234267
switch (key) {
235268
case "type":
236269
if (typeof value === "string") result.type = value;
@@ -256,57 +289,26 @@ function normalizeParsedProblemDetails(
256289
}
257290

258291
/**
259-
* Parses a `Response` body into a {@linkcode ProblemDetails}.
260-
*
261-
* Reads the response body as JSON and returns the standard members plus any
262-
* extension members as a flat object. Standard members with invalid types are
263-
* ignored per RFC 9457 §3.1. Does not throw on missing fields — the RFC makes
264-
* all members optional. Extension member types provided via `T` are asserted at
265-
* the type level only — values are not validated at runtime.
266-
*
267-
* Note: this consumes the response body. The `Response` cannot be re-read
268-
* after this call.
269-
*
270-
* @experimental **UNSTABLE**: New API, yet to be vetted.
271-
*
272-
* @typeParam T The type of extension members expected in the parsed result.
273-
*
274-
* @param input The `Response` whose JSON body will be parsed.
275-
*
276-
* @returns A promise that resolves to the parsed problem details.
277-
*
278-
* @example Parse from a Response
279-
* ```ts ignore
280-
* import { parseProblemDetails } from "@std/http/unstable-problem-details";
281-
*
282-
* const response = await fetch("https://api.example.com/resource");
283-
* if (isProblemDetailsResponse(response)) {
284-
* const problem = await parseProblemDetails(response);
285-
* console.log(problem.status, problem.detail);
286-
* }
287-
* ```
288-
*/
289-
export function parseProblemDetails<
290-
T extends ProblemDetailsExtensions = Record<never, never>,
291-
>(input: Response): Promise<ProblemDetails<T>>;
292-
293-
/**
294-
* Parses a plain JSON object into a {@linkcode ProblemDetails}.
292+
* Parses a plain JSON value into a {@linkcode ProblemDetails}.
295293
*
296294
* Returns the standard members plus any extension members as a flat object.
297-
* Standard members with invalid types are ignored per RFC 9457 §3.1. Does not
298-
* throw on missing fields — the RFC makes all members optional. Extension
299-
* member types provided via `T` are asserted at the type level only — values
300-
* are not validated at runtime.
295+
* Standard members with invalid types are silently dropped per RFC 9457 §3.1.
296+
* Does not throw on missing fields — the RFC makes all members optional.
297+
* Extension member types provided via `T` are asserted at the type level only;
298+
* values are not validated at runtime.
301299
*
302300
* @experimental **UNSTABLE**: New API, yet to be vetted.
303301
*
304302
* @typeParam T The type of extension members expected in the parsed result.
305303
*
306-
* @param input A plain JSON object to parse as problem details.
304+
* @param input A JSON value to parse as problem details.
307305
*
308306
* @returns The parsed problem details.
309307
*
308+
* @throws {TypeError} If `input` is not a non-null, non-array object, or if
309+
* `input` is a `Response` (use {@linkcode parseProblemDetailsResponse}
310+
* instead).
311+
*
310312
* @example Parse from a plain object
311313
* ```ts
312314
* import { parseProblemDetails } from "@std/http/unstable-problem-details";
@@ -323,28 +325,71 @@ export function parseProblemDetails<
323325
* ```
324326
*/
325327
export function parseProblemDetails<
326-
T extends ProblemDetailsExtensions = Record<never, never>,
327-
>(input: Record<string, unknown>): ProblemDetails<T>;
328-
329-
export function parseProblemDetails<
330-
T extends ProblemDetailsExtensions = Record<never, never>,
331-
>(
332-
input: Response | Record<string, unknown>,
333-
): Promise<ProblemDetails<T>> | ProblemDetails<T> {
328+
T extends ProblemDetailsExtensions = Record<string, never>,
329+
>(input: unknown): ProblemDetails<T> {
330+
// A `Response` is a non-null, non-array object with no enumerable own keys,
331+
// so it would silently parse to an empty result. Reject it explicitly so
332+
// callers who forgot to migrate to `parseProblemDetailsResponse` fail loudly.
334333
if (input instanceof Response) {
335-
return input.json().then((raw: Record<string, unknown>) =>
336-
normalizeParsedProblemDetails(raw) as ProblemDetails<T>
334+
throw new TypeError(
335+
"Cannot parse Problem Details: input is a Response, use parseProblemDetailsResponse instead",
337336
);
338337
}
339338
return normalizeParsedProblemDetails(input) as ProblemDetails<T>;
340339
}
341340

341+
/**
342+
* Parses a `Response` body into a {@linkcode ProblemDetails}.
343+
*
344+
* Reads the response body as JSON and delegates to
345+
* {@linkcode parseProblemDetails}. Standard members with invalid types are
346+
* silently dropped per RFC 9457 §3.1. Does not throw on missing fields — the
347+
* RFC makes all members optional. Extension member types provided via `T` are
348+
* asserted at the type level only; values are not validated at runtime.
349+
*
350+
* Note: this consumes the response body. The `Response` cannot be re-read
351+
* after this call.
352+
*
353+
* @experimental **UNSTABLE**: New API, yet to be vetted.
354+
*
355+
* @typeParam T The type of extension members expected in the parsed result.
356+
*
357+
* @param input The `Response` whose JSON body will be parsed.
358+
*
359+
* @returns A promise that resolves to the parsed problem details.
360+
*
361+
* @throws {TypeError} If the response body parses to a value that is not a
362+
* non-null, non-array object.
363+
* @throws {SyntaxError} If the response body is not valid JSON.
364+
*
365+
* @example Parse from a Response
366+
* ```ts
367+
* import { parseProblemDetailsResponse } from "@std/http/unstable-problem-details";
368+
* import { assertEquals } from "@std/assert";
369+
*
370+
* const response = new Response(
371+
* JSON.stringify({ type: "about:blank", status: 404, title: "Not Found" }),
372+
* { headers: { "Content-Type": "application/problem+json" } },
373+
* );
374+
* const problem = await parseProblemDetailsResponse(response);
375+
* assertEquals(problem.status, 404);
376+
* assertEquals(problem.title, "Not Found");
377+
* ```
378+
*/
379+
export async function parseProblemDetailsResponse<
380+
T extends ProblemDetailsExtensions = Record<string, never>,
381+
>(input: Response): Promise<ProblemDetails<T>> {
382+
const raw = await input.json();
383+
return normalizeParsedProblemDetails(raw) as ProblemDetails<T>;
384+
}
385+
342386
/**
343387
* Type guard that checks whether a `Response` has an
344388
* `application/problem+json` content type.
345389
*
346390
* The media type is compared without parameters (e.g. `charset=utf-8` is
347-
* ignored).
391+
* ignored). Only `application/problem+json` is recognized; any other media
392+
* type returns `false`.
348393
*
349394
* @experimental **UNSTABLE**: New API, yet to be vetted.
350395
*
@@ -357,12 +402,12 @@ export function parseProblemDetails<
357402
* ```ts ignore
358403
* import {
359404
* isProblemDetailsResponse,
360-
* parseProblemDetails,
405+
* parseProblemDetailsResponse,
361406
* } from "@std/http/unstable-problem-details";
362407
*
363408
* const response = await fetch("https://api.example.com/resource");
364409
* if (isProblemDetailsResponse(response)) {
365-
* const problem = await parseProblemDetails(response);
410+
* const problem = await parseProblemDetailsResponse(response);
366411
* console.error(problem.detail);
367412
* }
368413
* ```

0 commit comments

Comments
 (0)