1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377 |
x5
x5
x5
x5
x5
x14
x14
x14
x14
x9
x9
x8
x8
x9
x14
x14
x14
x13
x13
x13
x13
x13
x1
x1
x1
x14
x5
x5
x5
x16
x4
x4
x4
x4
x4
x12
x16
x34
x33
x33
x34
x5
x5
x34
x11
x11
x34
x5
x5
x34
x6
x6
x34
x2
x2
x34
x4
x34
x34
x12
x16
x5
x5
x17
x4
x4
x4
x13
x17
x5
x5
x5
x4
x5
x5
x5
x5 |
|
// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.
/**
* Problem Details for HTTP APIs per
* {@link https://www.rfc-editor.org/rfc/rfc9457.html | RFC 9457}.
*
* 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.
*
* @example Basic 404 response
* ```ts
* import { createProblemDetailsResponse } from "@std/http/unstable-problem-details";
*
* const response = createProblemDetailsResponse({
* status: 404,
* detail: "No user found with ID 42",
* });
* ```
*
* @example Parse from a Response
* ```ts ignore
* import {
* isProblemDetailsResponse,
* parseProblemDetails,
* } from "@std/http/unstable-problem-details";
*
* const response = await fetch("https://api.example.com/resource");
* if (isProblemDetailsResponse(response)) {
* const problem = await parseProblemDetails(response);
* console.error(problem.detail);
* }
* ```
*
* Only the JSON serialization (`application/problem+json`) is implemented.
* The XML serialization defined by the RFC is not supported.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @see {@link https://www.rfc-editor.org/rfc/rfc9457.html}
*
* @module
*/
import { STATUS_TEXT } from "./status.ts";
const PROBLEM_JSON_MEDIA_TYPE = "application/problem+json";
/**
* Keys of the five standard Problem Details members defined by RFC 9457.
* Used to prevent extension members from shadowing standard fields.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*/
export type StandardProblemDetailsMember =
| "type"
| "status"
| "title"
| "detail"
| "instance";
/**
* Constraint for Problem Details extension members. Permits any string-keyed
* properties except the five standard members defined by RFC 9457.
*
* 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.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*/
export type ProblemDetailsExtensions = Omit<
Record<string, unknown>,
StandardProblemDetailsMember
>;
/**
* A Problem Details object as defined by RFC 9457.
*
* Standard members are explicitly typed. Extension members are represented
* as a generic parameter intersected with the base type, so they appear as
* 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.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*/
export type ProblemDetails<
T extends ProblemDetailsExtensions = Record<never, never>,
> = {
/**
* A URI reference identifying the problem type. Defaults to `"about:blank"`
* when not provided, per RFC 9457 §4.2.1.
*/
type?: string;
/** HTTP status code generated by the origin server for this occurrence. */
status?: number;
/** Short, human-readable summary of the problem type. */
title?: string;
/** Human-readable explanation specific to this occurrence. */
detail?: string;
/** URI reference identifying this specific occurrence. */
instance?: string;
} & T;
/**
* Options for {@linkcode createProblemDetailsResponse}.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*/
export interface ProblemDetailsResponseOptions {
/** Additional headers to include in the response. */
headers?: HeadersInit;
}
/**
* Creates a `Response` with an `application/problem+json` body from a
* {@linkcode ProblemDetails} object.
*
* - Sets `Content-Type` to `application/problem+json`, overwriting any
* `Content-Type` provided in `options.headers`.
* - Defaults `type` to `"about:blank"` if not provided.
* - Defaults `title` to the standard HTTP status text (from `@std/http/status`)
* when `type` is `"about:blank"` and `title` is not provided.
* - Defaults `status` to `500` if not provided.
* - Serializes directly to JSON — the flat intersection type already matches
* the RFC wire format, so no flattening step is needed.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @typeParam T The type of extension members on the problem details object.
*
* @param problemDetails The problem details to serialize into the response body.
* @param options Additional response options such as extra headers.
*
* @returns A `Response` with status, `application/problem+json` content type,
* and the serialized problem details as the body.
*
* @example Basic 404 response
* ```ts
* import { createProblemDetailsResponse } from "@std/http/unstable-problem-details";
* import { assertEquals } from "@std/assert";
*
* const response = createProblemDetailsResponse({
* status: 404,
* detail: "No user found with ID 42",
* });
* assertEquals(response.status, 404);
* assertEquals(
* await response.json(),
* {
* type: "about:blank",
* status: 404,
* title: "Not Found",
* detail: "No user found with ID 42",
* },
* );
* ```
*
* @example Custom problem type with extensions
* ```ts
* import { createProblemDetailsResponse } from "@std/http/unstable-problem-details";
* import { assertEquals } from "@std/assert";
*
* const response = createProblemDetailsResponse({
* type: "https://example.com/problems/insufficient-credit",
* status: 403,
* title: "Insufficient credit",
* detail: "Your account balance is too low",
* instance: "/account/12345/transactions/abc",
* balance: 30,
* accounts: ["/account/12345", "/account/67890"],
* });
* assertEquals(response.status, 403);
* assertEquals(response.headers.get("Content-Type"), "application/problem+json");
* ```
*/
export function createProblemDetailsResponse<
T extends ProblemDetailsExtensions,
>(
problemDetails: ProblemDetails<T>,
options?: ProblemDetailsResponseOptions,
): Response {
const pd: Record<string, unknown> = { ...problemDetails };
if (pd.type === undefined) pd.type = "about:blank";
if (pd.status === undefined) pd.status = 500;
if (pd.type === "about:blank" && pd.title === undefined) {
const statusText = STATUS_TEXT[pd.status as keyof typeof STATUS_TEXT];
if (statusText !== undefined) {
pd.title = statusText;
}
}
const body = JSON.stringify(pd);
const status = pd.status as number;
if (options?.headers === undefined) {
return new Response(body, {
status,
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 });
}
/** Per RFC 9457 §3.1: ignore standard members whose value type does not match. */
function normalizeParsedProblemDetails(
raw: Record<string, unknown>,
): Record<string, unknown> {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new TypeError(
`Cannot parse Problem Details: expected a JSON object, but got ${
raw === null ? "null" : Array.isArray(raw) ? "an array" : typeof raw
}`,
);
}
const result: Record<string, unknown> = {};
for (const key in raw) {
if (!Object.hasOwn(raw, key)) continue;
const value = raw[key];
switch (key) {
case "type":
if (typeof value === "string") result.type = value;
break;
case "status":
if (Number.isInteger(value)) result.status = value;
break;
case "title":
if (typeof value === "string") result.title = value;
break;
case "detail":
if (typeof value === "string") result.detail = value;
break;
case "instance":
if (typeof value === "string") result.instance = value;
break;
default:
result[key] = value;
}
}
return result;
}
/**
* Parses a `Response` body 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.
*
* @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.
*
* @returns A promise that resolves to the parsed problem details.
*
* @example Parse from a Response
* ```ts ignore
* import { parseProblemDetails } from "@std/http/unstable-problem-details";
*
* const response = await fetch("https://api.example.com/resource");
* if (isProblemDetailsResponse(response)) {
* const problem = await parseProblemDetails(response);
* console.log(problem.status, problem.detail);
* }
* ```
*/
export function parseProblemDetails<
T extends ProblemDetailsExtensions = Record<never, never>,
>(input: Response): Promise<ProblemDetails<T>>;
/**
* Parses a plain JSON object 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.
*
* @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.
*
* @returns The parsed problem details.
*
* @example Parse from a plain object
* ```ts
* import { parseProblemDetails } 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");
* ```
*/
export function parseProblemDetails<
T extends ProblemDetailsExtensions = Record<never, never>,
>(input: Record<string, unknown>): ProblemDetails<T>;
export function parseProblemDetails<
T extends ProblemDetailsExtensions = Record<never, never>,
>(
input: Response | Record<string, unknown>,
): Promise<ProblemDetails<T>> | ProblemDetails<T> {
if (input instanceof Response) {
return input.json().then((raw: Record<string, unknown>) =>
normalizeParsedProblemDetails(raw) as ProblemDetails<T>
);
}
return normalizeParsedProblemDetails(input) as ProblemDetails<T>;
}
/**
* Type guard that checks whether a `Response` has an
* `application/problem+json` content type.
*
* The media type is compared without parameters (e.g. `charset=utf-8` is
* ignored).
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @param response The `Response` to check.
*
* @returns `true` if the response has an `application/problem+json` content
* type, `false` otherwise.
*
* @example Usage
* ```ts ignore
* import {
* isProblemDetailsResponse,
* parseProblemDetails,
* } from "@std/http/unstable-problem-details";
*
* const response = await fetch("https://api.example.com/resource");
* if (isProblemDetailsResponse(response)) {
* const problem = await parseProblemDetails(response);
* console.error(problem.detail);
* }
* ```
*/
export function isProblemDetailsResponse(response: Response): boolean {
const contentType = response.headers.get("Content-Type");
if (contentType === null) return false;
const semi = contentType.indexOf(";");
const base = semi === -1 ? contentType : contentType.substring(0, semi);
const mediaType = base.toLowerCase().trim();
return mediaType === PROBLEM_JSON_MEDIA_TYPE;
}
|