From 7f48d40485ba70bb0c3e040b4133f08fe6276a42 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 27 Apr 2026 10:04:29 +0200 Subject: [PATCH 1/2] BREAKING(http/unstable): split parseProblemDetails, validate status, add statusText option --- http/unstable_problem_details.ts | 185 +++++++++++++++----------- http/unstable_problem_details_test.ts | 159 ++++++++++++++++++---- 2 files changed, 236 insertions(+), 108 deletions(-) diff --git a/http/unstable_problem_details.ts b/http/unstable_problem_details.ts index c1ab118d04a6..1cb8be6fc385 100644 --- a/http/unstable_problem_details.ts +++ b/http/unstable_problem_details.ts @@ -7,8 +7,9 @@ * * Provides {@linkcode createProblemDetailsResponse} to build a `Response` with * an `application/problem+json` body, {@linkcode parseProblemDetails} to parse - * from a `Response` or plain object, and {@linkcode isProblemDetailsResponse} - * to detect problem-details responses by content type. + * a plain object, {@linkcode parseProblemDetailsResponse} to parse a `Response` + * body, and {@linkcode isProblemDetailsResponse} to detect problem-details + * responses by content type. * * @example Basic 404 response * ```ts @@ -20,16 +21,16 @@ * }); * ``` * - * @example Parse from a Response + * @example Parse a Response * ```ts ignore * import { * isProblemDetailsResponse, - * parseProblemDetails, + * parseProblemDetailsResponse, * } from "@std/http/unstable-problem-details"; * * const response = await fetch("https://api.example.com/resource"); * if (isProblemDetailsResponse(response)) { - * const problem = await parseProblemDetails(response); + * const problem = await parseProblemDetailsResponse(response); * console.error(problem.detail); * } * ``` @@ -39,8 +40,6 @@ * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @see {@link https://www.rfc-editor.org/rfc/rfc9457.html} - * * @module */ @@ -62,14 +61,16 @@ export type StandardProblemDetailsMember = | "instance"; /** - * Constraint for Problem Details extension members. Permits any string-keyed - * properties except the five standard members defined by RFC 9457. + * Constraint for Problem Details extension members. * - * Uses `Omit` rather than a mapped `never` constraint so that TypeScript can - * infer `T` from object literals at call sites without the standard keys - * being captured into `T` and then failing a `never` check. If `T` - * explicitly redeclares a standard key, the intersection with the base type - * collapses that key to `never`, making it unusable. + * The constraint itself is intentionally loose (effectively + * `Record`) so that TypeScript can infer `T` from object + * literals at call sites without standard keys being captured into `T` and + * then failing a stricter `never` check. Safety is enforced one level up: the + * intersection in {@linkcode ProblemDetails} explicitly types the five + * standard members, so if `T` redeclares any of them with an incompatible + * type the resulting field collapses to `never` and the value becomes + * unconstructible. * * @experimental **UNSTABLE**: New API, yet to be vetted. */ @@ -86,13 +87,14 @@ export type ProblemDetailsExtensions = Omit< * top-level properties in both the TypeScript type and the serialized JSON * — matching the wire format exactly. * - * The generic constraint on `T` prevents extension types from shadowing the - * five standard members, which the RFC forbids. + * If `T` declares a property whose name matches one of the five standard + * members with an incompatible type, the intersection collapses that field + * to `never`. * * @experimental **UNSTABLE**: New API, yet to be vetted. */ export type ProblemDetails< - T extends ProblemDetailsExtensions = Record, + T extends ProblemDetailsExtensions = Record, > = { /** * A URI reference identifying the problem type. Defaults to `"about:blank"` @@ -117,6 +119,11 @@ export type ProblemDetails< export interface ProblemDetailsResponseOptions { /** Additional headers to include in the response. */ headers?: HeadersInit; + /** + * Status text for the response. When omitted, the platform's default for + * `status` is used. + */ + statusText?: string; } /** @@ -142,6 +149,8 @@ export interface ProblemDetailsResponseOptions { * @returns A `Response` with status, `application/problem+json` content type, * and the serialized problem details as the body. * + * @throws {RangeError} If `problemDetails.status` is not a finite integer. + * * @example Basic 404 response * ```ts * import { createProblemDetailsResponse } from "@std/http/unstable-problem-details"; @@ -193,6 +202,14 @@ export function createProblemDetailsResponse< if (pd.status === undefined) pd.status = 500; + if (!Number.isInteger(pd.status)) { + throw new RangeError( + `Cannot create Problem Details response: status must be a finite integer: received ${ + typeof pd.status === "string" ? `"${pd.status}"` : String(pd.status) + }`, + ); + } + if (pd.type === "about:blank" && pd.title === undefined) { const statusText = STATUS_TEXT[pd.status as keyof typeof STATUS_TEXT]; if (statusText !== undefined) { @@ -202,21 +219,26 @@ export function createProblemDetailsResponse< const body = JSON.stringify(pd); const status = pd.status as number; + const statusText = options?.statusText; if (options?.headers === undefined) { return new Response(body, { status, + ...(statusText !== undefined ? { statusText } : {}), headers: { "Content-Type": PROBLEM_JSON_MEDIA_TYPE }, }); } const headers = new Headers(options.headers); headers.set("Content-Type", PROBLEM_JSON_MEDIA_TYPE); - return new Response(body, { status, headers }); + return new Response(body, { + status, + ...(statusText !== undefined ? { statusText } : {}), + headers, + }); } -/** Per RFC 9457 §3.1: ignore standard members whose value type does not match. */ function normalizeParsedProblemDetails( - raw: Record, + raw: unknown, ): Record { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { throw new TypeError( @@ -227,10 +249,12 @@ function normalizeParsedProblemDetails( } const result: Record = {}; + const source = raw as Record; - for (const key in raw) { - if (!Object.hasOwn(raw, key)) continue; - const value = raw[key]; + // RFC 9457 §3.1: ignore standard members whose value type does not match. + for (const key in source) { + if (!Object.hasOwn(source, key)) continue; + const value = source[key]; switch (key) { case "type": if (typeof value === "string") result.type = value; @@ -256,87 +280,88 @@ function normalizeParsedProblemDetails( } /** - * Parses a `Response` body into a {@linkcode ProblemDetails}. + * Parses a plain JSON value into a {@linkcode ProblemDetails}. * - * Reads the response body as JSON and returns the standard members plus any - * extension members as a flat object. Standard members with invalid types are - * ignored per RFC 9457 §3.1. Does not throw on missing fields — the RFC makes - * all members optional. Extension member types provided via `T` are asserted at - * the type level only — values are not validated at runtime. - * - * Note: this consumes the response body. The `Response` cannot be re-read - * after this call. + * Returns the standard members plus any extension members as a flat object. + * Standard members with invalid types are silently dropped per RFC 9457 §3.1. + * Does not throw on missing fields — the RFC makes all members optional. + * Extension member types provided via `T` are asserted at the type level only; + * values are not validated at runtime. * * @experimental **UNSTABLE**: New API, yet to be vetted. * * @typeParam T The type of extension members expected in the parsed result. * - * @param input The `Response` whose JSON body will be parsed. + * @param input A JSON value to parse as problem details. * - * @returns A promise that resolves to the parsed problem details. + * @returns The parsed problem details. * - * @example Parse from a Response - * ```ts ignore + * @throws {TypeError} If `input` is not a non-null, non-array object. + * + * @example Parse from a plain object + * ```ts * import { parseProblemDetails } from "@std/http/unstable-problem-details"; + * import { assertEquals } from "@std/assert"; * - * const response = await fetch("https://api.example.com/resource"); - * if (isProblemDetailsResponse(response)) { - * const problem = await parseProblemDetails(response); - * console.log(problem.status, problem.detail); - * } + * const problem = parseProblemDetails({ + * type: "about:blank", + * status: 400, + * title: "Bad Request", + * balance: 30, + * }); + * assertEquals(problem.status, 400); + * assertEquals(problem.title, "Bad Request"); * ``` */ export function parseProblemDetails< - T extends ProblemDetailsExtensions = Record, ->(input: Response): Promise>; + T extends ProblemDetailsExtensions = Record, +>(input: unknown): ProblemDetails { + return normalizeParsedProblemDetails(input) as ProblemDetails; +} /** - * Parses a plain JSON object into a {@linkcode ProblemDetails}. + * Parses a `Response` body into a {@linkcode ProblemDetails}. * - * Returns the standard members plus any extension members as a flat object. - * Standard members with invalid types are ignored per RFC 9457 §3.1. Does not - * throw on missing fields — the RFC makes all members optional. Extension - * member types provided via `T` are asserted at the type level only — values - * are not validated at runtime. + * Reads the response body as JSON and delegates to + * {@linkcode parseProblemDetails}. Standard members with invalid types are + * silently dropped per RFC 9457 §3.1. Does not throw on missing fields — the + * RFC makes all members optional. Extension member types provided via `T` are + * asserted at the type level only; values are not validated at runtime. + * + * Note: this consumes the response body. The `Response` cannot be re-read + * after this call. * * @experimental **UNSTABLE**: New API, yet to be vetted. * * @typeParam T The type of extension members expected in the parsed result. * - * @param input A plain JSON object to parse as problem details. + * @param input The `Response` whose JSON body will be parsed. * - * @returns The parsed problem details. + * @returns A promise that resolves to the parsed problem details. * - * @example Parse from a plain object + * @throws {TypeError} If the response body parses to a value that is not a + * non-null, non-array object. + * @throws {SyntaxError} If the response body is not valid JSON. + * + * @example Parse from a Response * ```ts - * import { parseProblemDetails } from "@std/http/unstable-problem-details"; + * import { parseProblemDetailsResponse } from "@std/http/unstable-problem-details"; * import { assertEquals } from "@std/assert"; * - * const problem = parseProblemDetails({ - * type: "about:blank", - * status: 400, - * title: "Bad Request", - * balance: 30, - * }); - * assertEquals(problem.status, 400); - * assertEquals(problem.title, "Bad Request"); + * const response = new Response( + * JSON.stringify({ type: "about:blank", status: 404, title: "Not Found" }), + * { headers: { "Content-Type": "application/problem+json" } }, + * ); + * const problem = await parseProblemDetailsResponse(response); + * assertEquals(problem.status, 404); + * assertEquals(problem.title, "Not Found"); * ``` */ -export function parseProblemDetails< - T extends ProblemDetailsExtensions = Record, ->(input: Record): ProblemDetails; - -export function parseProblemDetails< - T extends ProblemDetailsExtensions = Record, ->( - input: Response | Record, -): Promise> | ProblemDetails { - if (input instanceof Response) { - return input.json().then((raw: Record) => - normalizeParsedProblemDetails(raw) as ProblemDetails - ); - } - return normalizeParsedProblemDetails(input) as ProblemDetails; +export async function parseProblemDetailsResponse< + T extends ProblemDetailsExtensions = Record, +>(input: Response): Promise> { + const raw = await input.json(); + return normalizeParsedProblemDetails(raw) as ProblemDetails; } /** @@ -344,7 +369,9 @@ export function parseProblemDetails< * `application/problem+json` content type. * * The media type is compared without parameters (e.g. `charset=utf-8` is - * ignored). + * ignored). Only the JSON serialization is recognized; problem-details + * responses using the XML serialization (`application/problem+xml`) or any + * other media type return `false`. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -357,12 +384,12 @@ export function parseProblemDetails< * ```ts ignore * import { * isProblemDetailsResponse, - * parseProblemDetails, + * parseProblemDetailsResponse, * } from "@std/http/unstable-problem-details"; * * const response = await fetch("https://api.example.com/resource"); * if (isProblemDetailsResponse(response)) { - * const problem = await parseProblemDetails(response); + * const problem = await parseProblemDetailsResponse(response); * console.error(problem.detail); * } * ``` diff --git a/http/unstable_problem_details_test.ts b/http/unstable_problem_details_test.ts index de131e865f14..046a53042909 100644 --- a/http/unstable_problem_details_test.ts +++ b/http/unstable_problem_details_test.ts @@ -6,6 +6,7 @@ import { createProblemDetailsResponse, isProblemDetailsResponse, parseProblemDetails, + parseProblemDetailsResponse, type ProblemDetails, type StandardProblemDetailsMember, } from "./unstable_problem_details.ts"; @@ -68,12 +69,14 @@ Deno.test("createProblemDetailsResponse() does not override explicit title for a assertEquals(body.title, "Custom title"); }); -Deno.test("createProblemDetailsResponse() custom type does not auto-populate title", async () => { +Deno.test("createProblemDetailsResponse() custom type does not auto-populate title from STATUS_TEXT", async () => { const response = createProblemDetailsResponse({ type: "https://example.com/problems/out-of-stock", - status: 409, + status: 404, }); const body = await response.json(); + assertEquals(body.type, "https://example.com/problems/out-of-stock"); + assertEquals(body.status, 404); assertEquals(body.title, undefined); }); @@ -130,27 +133,57 @@ Deno.test("createProblemDetailsResponse() all five standard members round-trip", assertEquals(body.instance, "/orders/123"); }); -// --- parseProblemDetails (Response) --- +Deno.test("createProblemDetailsResponse() throws RangeError for non-integer status", () => { + for ( + const value of [ + NaN, + Infinity, + -Infinity, + 3.14, + "500", + ] as unknown[] + ) { + assertThrows( + () => + createProblemDetailsResponse( + { status: value } as unknown as ProblemDetails, + ), + RangeError, + "Cannot create Problem Details response: status must be a finite integer", + ); + } +}); -Deno.test("parseProblemDetails() from Response returns correct fields", async () => { - const response = new Response( - JSON.stringify({ - type: "about:blank", - status: 400, - title: "Bad Request", - detail: "Invalid payload", - }), +Deno.test("createProblemDetailsResponse() forwards statusText to the response", () => { + const response = createProblemDetailsResponse( + { status: 418 }, + { statusText: "I'm a teapot, actually" }, + ); + assertEquals(response.status, 418); + assertEquals(response.statusText, "I'm a teapot, actually"); +}); + +Deno.test("createProblemDetailsResponse() forwards statusText alongside custom headers", () => { + const response = createProblemDetailsResponse( + { status: 400 }, { - headers: { "Content-Type": "application/problem+json" }, + headers: { "X-Trace-Id": "trace-1" }, + statusText: "Bad Input", }, ); - const problem = await parseProblemDetails(response); - assertEquals(problem.type, "about:blank"); - assertEquals(problem.status, 400); - assertEquals(problem.title, "Bad Request"); - assertEquals(problem.detail, "Invalid payload"); + assertEquals(response.statusText, "Bad Input"); + assertEquals(response.headers.get("X-Trace-Id"), "trace-1"); }); +Deno.test("createProblemDetailsResponse() leaves statusText empty when not provided", () => { + // Per the Fetch spec, Response.statusText defaults to the empty byte + // sequence; we do not synthesize one from STATUS_TEXT. + const response = createProblemDetailsResponse({ status: 404 }); + assertEquals(response.statusText, ""); +}); + +// --- parseProblemDetails (plain value) --- + Deno.test("parseProblemDetails() from plain object returns correct fields with extensions", () => { const problem = parseProblemDetails<{ balance: number; accounts: string[] }>({ type: "about:blank", @@ -170,6 +203,13 @@ Deno.test("parseProblemDetails() from plain object returns correct fields with e assertEquals(problem.accounts, ["/a", "/b"]); }); +Deno.test("parseProblemDetails() accepts unknown input and validates at runtime", () => { + const json: unknown = JSON.parse('{"status":400,"detail":"bad"}'); + const problem = parseProblemDetails(json); + assertEquals(problem.status, 400); + assertEquals(problem.detail, "bad"); +}); + Deno.test("parseProblemDetails() ignores status when not a finite integer", () => { for ( const value of [ @@ -179,9 +219,7 @@ Deno.test("parseProblemDetails() ignores status when not a finite integer", () = 404.5, ] as unknown[] ) { - const problem = parseProblemDetails( - { status: value } as Record, - ); + const problem = parseProblemDetails({ status: value }); assertEquals( problem.status, undefined, @@ -228,9 +266,20 @@ Deno.test("parseProblemDetails() excludes inherited prototype properties", () => ); }); +Deno.test("parseProblemDetails() preserves extension keys that look like standard members but with wrong type", () => { + // Standard members with invalid types are dropped per RFC 9457 §3.1; they + // are not promoted into the extensions bag. + const problem = parseProblemDetails({ + type: 123, + extra: "kept", + }); + assertEquals(problem.type, undefined); + assertEquals((problem as Record).extra, "kept"); +}); + Deno.test("parseProblemDetails() throws TypeError for null input", () => { assertThrows( - () => parseProblemDetails(null as unknown as Record), + () => parseProblemDetails(null), TypeError, "Cannot parse Problem Details: expected a JSON object", ); @@ -238,7 +287,7 @@ Deno.test("parseProblemDetails() throws TypeError for null input", () => { Deno.test("parseProblemDetails() throws TypeError for array input", () => { assertThrows( - () => parseProblemDetails([] as unknown as Record), + () => parseProblemDetails([]), TypeError, "Cannot parse Problem Details: expected a JSON object", ); @@ -246,31 +295,76 @@ Deno.test("parseProblemDetails() throws TypeError for array input", () => { Deno.test("parseProblemDetails() throws TypeError for primitive input", () => { assertThrows( - () => parseProblemDetails("string" as unknown as Record), + () => parseProblemDetails("string"), + TypeError, + "Cannot parse Problem Details: expected a JSON object", + ); + assertThrows( + () => parseProblemDetails(42), + TypeError, + "Cannot parse Problem Details: expected a JSON object", + ); + assertThrows( + () => parseProblemDetails(true), TypeError, "Cannot parse Problem Details: expected a JSON object", ); }); -Deno.test("parseProblemDetails() from Response rejects for non-JSON body", async () => { +// --- parseProblemDetailsResponse --- + +Deno.test("parseProblemDetailsResponse() returns correct fields", async () => { + const response = new Response( + JSON.stringify({ + type: "about:blank", + status: 400, + title: "Bad Request", + detail: "Invalid payload", + }), + { + headers: { "Content-Type": "application/problem+json" }, + }, + ); + const problem = await parseProblemDetailsResponse(response); + assertEquals(problem.type, "about:blank"); + assertEquals(problem.status, 400); + assertEquals(problem.title, "Bad Request"); + assertEquals(problem.detail, "Invalid payload"); +}); + +Deno.test("parseProblemDetailsResponse() rejects with SyntaxError for non-JSON body", async () => { const response = new Response("not json", { headers: { "Content-Type": "application/problem+json" }, }); await assertRejects( - () => parseProblemDetails(response), + () => parseProblemDetailsResponse(response), SyntaxError, ); }); -Deno.test("parseProblemDetails() from Response rejects for JSON array body", async () => { +Deno.test("parseProblemDetailsResponse() rejects with TypeError for JSON array body", async () => { const response = new Response(JSON.stringify([1, 2, 3])); await assertRejects( - () => parseProblemDetails(response), + () => parseProblemDetailsResponse(response), TypeError, "Cannot parse Problem Details: expected a JSON object", ); }); +Deno.test("parseProblemDetailsResponse() rejects with TypeError for JSON primitive body", async () => { + for (const body of ["true", "42", '"hello"', "null"]) { + const response = new Response(body, { + headers: { "Content-Type": "application/problem+json" }, + }); + await assertRejects( + () => parseProblemDetailsResponse(response), + TypeError, + "Cannot parse Problem Details: expected a JSON object", + `body=${body} should be rejected`, + ); + } +}); + // --- isProblemDetailsResponse --- Deno.test("isProblemDetailsResponse() returns true for application/problem+json", () => { @@ -296,6 +390,13 @@ Deno.test("isProblemDetailsResponse() returns false for application/json", () => assertEquals(isProblemDetailsResponse(response), false); }); +Deno.test("isProblemDetailsResponse() returns false for application/problem+xml", () => { + const response = new Response(null, { + headers: { "Content-Type": "application/problem+xml" }, + }); + assertEquals(isProblemDetailsResponse(response), false); +}); + Deno.test("isProblemDetailsResponse() returns false for missing Content-Type", () => { const response = new Response(null); assertEquals(isProblemDetailsResponse(response), false); @@ -310,13 +411,13 @@ Deno.test("isProblemDetailsResponse() is case insensitive", () => { // --- Round-trip tests --- -Deno.test("createProblemDetailsResponse() then parseProblemDetails() round-trips with extensions", async () => { +Deno.test("createProblemDetailsResponse() then parseProblemDetailsResponse() round-trips with extensions", async () => { const response = createProblemDetailsResponse({ status: 422, detail: "Validation failed", errors: [{ field: "email", message: "required" }], }); - const parsed = await parseProblemDetails< + const parsed = await parseProblemDetailsResponse< { errors: { field: string; message: string }[] } >(response); assertEquals(parsed.type, "about:blank"); From a1f4c969f709bcdbb5096e33b7bf104898a638d1 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 27 Apr 2026 10:09:46 +0200 Subject: [PATCH 2/2] fix --- http/unstable_problem_details.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/http/unstable_problem_details.ts b/http/unstable_problem_details.ts index 1cb8be6fc385..56a065873e9f 100644 --- a/http/unstable_problem_details.ts +++ b/http/unstable_problem_details.ts @@ -369,9 +369,8 @@ export async function parseProblemDetailsResponse< * `application/problem+json` content type. * * The media type is compared without parameters (e.g. `charset=utf-8` is - * ignored). Only the JSON serialization is recognized; problem-details - * responses using the XML serialization (`application/problem+xml`) or any - * other media type return `false`. + * ignored). Only `application/problem+json` is recognized; any other media + * type returns `false`. * * @experimental **UNSTABLE**: New API, yet to be vetted. *