@sdxc/problem 0.0.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # @sdxc/problem
2
+
3
+ Build, detect and parse [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details
4
+ (`application/problem+json`), and declare an API's problem types in one catalog.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ npm add @sdxc/problem
10
+ ```
11
+
12
+ Extension members are validated with [`remix/data-schema`](https://www.npmjs.com/package/remix)
13
+ or any other [Standard Schema](https://standardschema.dev/), and results come back as
14
+ [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) values; both install alongside
15
+ this package.
16
+
17
+ ## Usage
18
+
19
+ ### Answer With A Problem
20
+
21
+ ```typescript
22
+ import { problem } from "@sdxc/problem";
23
+
24
+ return problem({ status: 404 });
25
+ // 404, Content-Type: application/problem+json
26
+ // {"type":"about:blank","title":"Not Found","status":404}
27
+ ```
28
+
29
+ A status alone is a complete document: `type` defaults to `about:blank` and `title` to the
30
+ status phrase. Add `type`, `title`, `detail`, `instance` and `extensions` as needed:
31
+
32
+ ```typescript
33
+ return problem(
34
+ {
35
+ status: 403,
36
+ type: "https://example.com/probs/out-of-credit",
37
+ title: "You do not have enough credit.",
38
+ detail: "Your current balance is 30, but that costs 50.",
39
+ extensions: { balance: 30 },
40
+ },
41
+ { headers: { "Cache-Control": "no-store" } },
42
+ );
43
+ ```
44
+
45
+ Extensions are written at the document's top level, and one named like a standard member
46
+ never replaces it.
47
+
48
+ ### Declare A Catalog
49
+
50
+ ```typescript
51
+ import { defineProblems } from "@sdxc/problem";
52
+ import * as s from "remix/data-schema";
53
+
54
+ export const problems = defineProblems("https://docs.example.com/errors/", {
55
+ notFound: { slug: "not-found", status: 404, title: "The resource does not exist" },
56
+ outOfCredit: {
57
+ slug: "out-of-credit",
58
+ status: 403,
59
+ title: "You do not have enough credit",
60
+ extensions: s.object({ balance: s.number() }),
61
+ },
62
+ });
63
+
64
+ return problems.notFound({ detail: "No article has that slug." });
65
+ return problems.outOfCredit({ extensions: { balance: 30 } });
66
+ ```
67
+
68
+ Each entry becomes a builder that writes its `type` (the base URL followed by the slug),
69
+ `status` and `title`, so a call site supplies only `detail`, `instance` and the extensions
70
+ its schema types. The base URL must end in `/`.
71
+
72
+ ### Read A Problem
73
+
74
+ ```typescript
75
+ import { isProblem, parseProblem } from "@sdxc/problem";
76
+ import { isSuccess } from "@sdxc/result";
77
+
78
+ if (isProblem(response)) {
79
+ let result = await parseProblem(response);
80
+ if (isSuccess(result)) result.data.type; // "https://example.com/probs/out-of-credit"
81
+ }
82
+ ```
83
+
84
+ With a catalog, `parse` names the entry a response belongs to and validates its extensions:
85
+
86
+ ```typescript
87
+ let result = await problems.parse(response);
88
+
89
+ if (isSuccess(result) && problems.is(result.data, "outOfCredit")) {
90
+ result.data.extensions.balance; // number
91
+ }
92
+ ```
93
+
94
+ A `type` outside the catalog still parses, with `name: null`, as RFC 9457 requires clients
95
+ to accept problem types they do not know.
96
+
97
+ ### Report Validation Failures
98
+
99
+ ```typescript
100
+ import { issuesFrom, validationProblem } from "@sdxc/problem";
101
+ import * as s from "remix/data-schema";
102
+
103
+ let result = s.parseSafe(schema, body);
104
+ if (!result.success) return validationProblem(issuesFrom(result.issues));
105
+ // 422 with {"errors":[{"pointer":"/user/email","code":"invalid","message":"..."}], ...}
106
+ ```
107
+
108
+ ## API
109
+
110
+ ### `problem(options, init?)`
111
+
112
+ Returns a `Response` whose status line and body `status` both come from `options.status`,
113
+ with `Content-Type: application/problem+json`. `init` adds headers.
114
+
115
+ ### `stringify(options)`
116
+
117
+ The document's JSON text, for writing a problem somewhere other than a `Response`.
118
+
119
+ ### `defineProblems(base, entries)`
120
+
121
+ A catalog: one builder per entry, plus `parse(response)`, `is(problem, name)` and
122
+ `entries()`, which lists every entry with its resolved `type` for rendering an error
123
+ reference. Entries cannot be named `parse`, `is` or `entries`.
124
+
125
+ ### `isProblem(message)`
126
+
127
+ Whether a `Request` or `Response` declares `application/problem+json`, ignoring parameters
128
+ and case. The body is left unread.
129
+
130
+ ### `parseProblem(response, options?)`
131
+
132
+ Reads a problem `Response` into a `Result<Problem, ProblemParseError>`. Absent members take
133
+ their RFC defaults, and the status line wins over the body's `status`. Pass
134
+ `options.extensions`, a synchronous Standard Schema, to validate and type the extensions.
135
+
136
+ ### `parse(text, options?)`
137
+
138
+ The same, from JSON text; `options.status` stands in for the status line.
139
+
140
+ ### `validationProblem(issues, options?)`
141
+
142
+ A `422` problem whose `errors` extension lists each invalid field. `options` sets any
143
+ standard member, including a different `status`.
144
+
145
+ ### `issuesFrom(source, code?)`
146
+
147
+ Converts Standard Schema issues, or an error carrying them in `issues`, into `errors`
148
+ entries, turning each path into a JSON Pointer. Every entry gets `code`, `"invalid"` by
149
+ default.
150
+
151
+ ### `toPointer(path)`
152
+
153
+ Formats an issue path as an RFC 6901 JSON Pointer, escaping `~` and `/`.
154
+
155
+ ### `ISSUES_SCHEMA`
156
+
157
+ The schema for the `errors` extension, for `s.object({ errors: ISSUES_SCHEMA })`.
158
+
159
+ ### `ProblemParseError`
160
+
161
+ Why a document is not a problem. `issues` holds the extension schema's issues when that is
162
+ the reason.
163
+
164
+ ### `PROBLEM_MEDIA_TYPE`, `ABOUT_BLANK`
165
+
166
+ `"application/problem+json"` and `"about:blank"`.
167
+
168
+ ### Types
169
+
170
+ `Problem<Extensions>` is a parsed document, with `detail` and `instance` as `string | null`
171
+ and extension members under `extensions`. `ProblemOptions<Extensions>` is what a writer
172
+ passes, and `ProblemIssue` is one `errors` entry.
173
+
174
+ ## Pattern: Share A Catalog Between A Server And Its Client
175
+
176
+ ```typescript
177
+ // api-problems.ts, imported by both
178
+ import { ISSUES_SCHEMA, defineProblems } from "@sdxc/problem";
179
+ import * as s from "remix/data-schema";
180
+
181
+ export const apiProblems = defineProblems("https://docs.example.com/errors/", {
182
+ notFound: { slug: "not-found", status: 404, title: "The resource does not exist" },
183
+ validationFailed: {
184
+ slug: "validation-failed",
185
+ status: 422,
186
+ title: "The request body is invalid",
187
+ extensions: s.object({ errors: ISSUES_SCHEMA }),
188
+ },
189
+ });
190
+ ```
191
+
192
+ ```typescript
193
+ // server
194
+ import { issuesFrom } from "@sdxc/problem";
195
+ import * as s from "remix/data-schema";
196
+
197
+ let result = s.parseSafe(schema, body);
198
+ if (!result.success) {
199
+ return apiProblems.validationFailed({ extensions: { errors: issuesFrom(result.issues) } });
200
+ }
201
+ ```
202
+
203
+ ```typescript
204
+ // client
205
+ import { isSuccess } from "@sdxc/result";
206
+
207
+ let response = await fetch(url, { method: "POST", body });
208
+ if (!response.ok) {
209
+ let result = await apiProblems.parse(response);
210
+ if (isSuccess(result) && apiProblems.is(result.data, "validationFailed")) {
211
+ for (let issue of result.data.extensions.errors) showError(issue.pointer, issue.message);
212
+ }
213
+ }
214
+ ```
215
+
216
+ ## Versioning
217
+
218
+ Releases are dated rather than semantic. A version is the UTC date it was published,
219
+ written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
220
+ release goes out per day.
221
+
222
+ Those numbers say when, not what: a later date means a later release and carries no
223
+ compatibility promise. Any release may change or remove an export.
224
+
225
+ Depend on one exact date, and move it when you are ready to take the change:
226
+
227
+ ```json
228
+ {
229
+ "dependencies": {
230
+ "@sdxc/problem": "2026.9.4"
231
+ }
232
+ }
233
+ ```
234
+
235
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every
236
+ later release in the same year. An exact version keeps the upgrade yours to schedule.
237
+
238
+ ## License
239
+
240
+ MIT
241
+
242
+ ## Author
243
+
244
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Problem catalogs: an API declares its problem types once, and gets a builder per
3
+ * type for its handlers and a parser that recognizes those types for its clients,
4
+ * so the `type`, `status` and `title` of each are written in one place.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
11
+ import type { Problem } from "./types.js";
12
+ import { ProblemParseError } from "./parse.js";
13
+ /** One problem type in a catalog. */
14
+ export interface ProblemEntry<Extensions extends object = object> {
15
+ /** Appended to the catalog's base URL to form `type`; it is the wire contract, so keep it stable. */
16
+ slug: string;
17
+ status: number;
18
+ title: string;
19
+ /** Types the builder's `extensions` argument and validates them when the catalog parses. */
20
+ extensions?: StandardSchemaV1<unknown, Extensions>;
21
+ }
22
+ /** The entries of a catalog, keyed by builder name; `parse`, `is` and `entries` name its own methods. */
23
+ export type ProblemEntries = Record<string, ProblemEntry<any>> & {
24
+ parse?: never;
25
+ is?: never;
26
+ entries?: never;
27
+ };
28
+ /** The extension members an entry's schema produces, `undefined` for an entry without one. */
29
+ type ExtensionsOf<Entry> = Entry extends ProblemEntry<infer Extensions> ? Entry extends {
30
+ extensions: StandardSchemaV1;
31
+ } ? Extensions : undefined : undefined;
32
+ /** What a call site supplies to a builder; `extensions` is required exactly when the entry has a schema. */
33
+ export type BuilderInput<Extensions> = {
34
+ detail?: string;
35
+ instance?: string;
36
+ } & ([Extensions] extends [undefined] ? {
37
+ extensions?: undefined;
38
+ } : {
39
+ extensions: Extensions;
40
+ });
41
+ /** A builder for one catalog entry, returning its problem `Response`. */
42
+ export type ProblemBuilder<Extensions> = [Extensions] extends [undefined] ? (input?: BuilderInput<Extensions>, init?: ResponseInit) => Response : (input: BuilderInput<Extensions>, init?: ResponseInit) => Response;
43
+ /**
44
+ * A problem the catalog read: `name` is the entry whose `type` it carries, with that entry's
45
+ * extensions validated, or `null` for a type outside the catalog, which RFC 9457 requires a
46
+ * client to accept.
47
+ */
48
+ export type CatalogProblem<Entries extends ProblemEntries> = {
49
+ [Name in keyof Entries & string]: Problem<ExtensionsOf<Entries[Name]> extends object ? ExtensionsOf<Entries[Name]> : Record<string, unknown>> & {
50
+ name: Name;
51
+ };
52
+ }[keyof Entries & string] | (Problem & {
53
+ name: null;
54
+ });
55
+ /** One entry as `entries()` lists it, with its resolved `type`. */
56
+ export interface CatalogEntry {
57
+ name: string;
58
+ type: string;
59
+ status: number;
60
+ title: string;
61
+ }
62
+ /** The methods every catalog carries beside its builders. */
63
+ export interface CatalogMethods<Entries extends ProblemEntries> {
64
+ /**
65
+ * Reads a problem response and names the entry it belongs to. A known type whose
66
+ * extensions fail the entry's schema is a failure.
67
+ */
68
+ parse(response: Response): Promise<Result<CatalogProblem<Entries>, ProblemParseError>>;
69
+ /** Narrows a parsed problem to one entry. */
70
+ is<Name extends keyof Entries & string>(problem: CatalogProblem<Entries>, name: Name): problem is Extract<CatalogProblem<Entries>, {
71
+ name: Name;
72
+ }>;
73
+ /** Every entry, in declaration order, for rendering an error reference. */
74
+ entries(): CatalogEntry[];
75
+ }
76
+ /** A defined catalog: one builder per entry, plus its methods. */
77
+ export type ProblemCatalog<Entries extends ProblemEntries> = {
78
+ [Name in keyof Entries]: ProblemBuilder<ExtensionsOf<Entries[Name]>>;
79
+ } & CatalogMethods<Entries>;
80
+ /**
81
+ * Declares an API's problem types. Each `type` is `base` followed by the entry's slug, so
82
+ * `base` ends in `/`: that is what keeps its last path segment when a slug is resolved.
83
+ *
84
+ * @param base - The URL the types live under, usually the error reference's docs page.
85
+ * @param entries - The problem types, keyed by the builder name each becomes.
86
+ * @returns The catalog.
87
+ * @example let problems = defineProblems("https://docs.example.com/errors/", { notFound: { slug: "not-found", status: 404, title: "Not found" } });
88
+ * @example return problems.notFound({ detail: "No article has that slug." });
89
+ */
90
+ export declare function defineProblems<const Entries extends ProblemEntries>(base: `${string}/`, entries: Entries): ProblemCatalog<Entries>;
91
+ export {};
@@ -0,0 +1,39 @@
1
+ import { failure, success } from "@sdxc/result";
2
+ import { ProblemParseError, parseProblem, readExtensions } from "./parse.js";
3
+ import { problem } from "./problem.js";
4
+ /**
5
+ * Declares an API's problem types. Each `type` is `base` followed by the entry's slug, so
6
+ * `base` ends in `/`: that is what keeps its last path segment when a slug is resolved.
7
+ *
8
+ * @param base - The URL the types live under, usually the error reference's docs page.
9
+ * @param entries - The problem types, keyed by the builder name each becomes.
10
+ * @returns The catalog.
11
+ * @example let problems = defineProblems("https://docs.example.com/errors/", { notFound: { slug: "not-found", status: 404, title: "Not found" } });
12
+ * @example return problems.notFound({ detail: "No article has that slug." });
13
+ */
14
+ export function defineProblems(base, entries) {
15
+ let names = new Map();
16
+ let listing = [];
17
+ let catalog = {};
18
+ for (let [name, entry] of Object.entries(entries)) {
19
+ let type = `${base}${entry.slug}`;
20
+ names.set(type, name);
21
+ listing.push({ name, type, status: entry.status, title: entry.title });
22
+ catalog[name] = (input = {}, init) => problem({ ...input, type, status: entry.status, title: entry.title }, init);
23
+ }
24
+ catalog.parse = async (response) => {
25
+ let parsed = await parseProblem(response);
26
+ if (parsed.status === "failure")
27
+ return parsed;
28
+ let name = names.get(parsed.data.type) ?? null;
29
+ if (name === null)
30
+ return success({ ...parsed.data, name });
31
+ let extensions = readExtensions(parsed.data.extensions, entries[name]?.extensions);
32
+ if (extensions.status === "failure")
33
+ return failure(extensions.error);
34
+ return success({ ...parsed.data, extensions: extensions.data, name });
35
+ };
36
+ catalog.is = (parsed, name) => parsed.name === name;
37
+ catalog.entries = () => listing.map((entry) => ({ ...entry }));
38
+ return catalog;
39
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Public surface of the problem details package: writing and reading RFC 9457
3
+ * `application/problem+json` documents, validation failures as problems, and
4
+ * catalogs that declare an API's problem types once.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export type { BuilderInput, CatalogEntry, CatalogMethods, CatalogProblem, ProblemBuilder, ProblemCatalog, ProblemEntries, ProblemEntry, } from "./catalog.js";
10
+ export type { ParseOptions } from "./parse.js";
11
+ export type { Problem, ProblemIssue, ProblemOptions } from "./types.js";
12
+ export { defineProblems } from "./catalog.js";
13
+ export { ISSUES_SCHEMA, issuesFrom, toPointer, validationProblem } from "./issues.js";
14
+ export { isProblem, parse, parseProblem, ProblemParseError } from "./parse.js";
15
+ export { ABOUT_BLANK, PROBLEM_MEDIA_TYPE, problem, stringify } from "./problem.js";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public surface of the problem details package: writing and reading RFC 9457
3
+ * `application/problem+json` documents, validation failures as problems, and
4
+ * catalogs that declare an API's problem types once.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export { defineProblems } from "./catalog.js";
10
+ export { ISSUES_SCHEMA, issuesFrom, toPointer, validationProblem } from "./issues.js";
11
+ export { isProblem, parse, parseProblem, ProblemParseError } from "./parse.js";
12
+ export { ABOUT_BLANK, PROBLEM_MEDIA_TYPE, problem, stringify } from "./problem.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Validation failures as problems: converting Standard Schema issues into the
3
+ * `errors` extension's JSON Pointer entries, the schema that reads them back,
4
+ * and the 422 response that carries them.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
10
+ import * as s from "remix/data-schema";
11
+ import type { ProblemIssue, ProblemOptions } from "./types.js";
12
+ /**
13
+ * Formats a Standard Schema issue path as an RFC 6901 JSON Pointer, escaping `~` as `~0`
14
+ * and `/` as `~1`. An empty or absent path points at the whole document, `""`.
15
+ *
16
+ * @param path - The issue's path segments.
17
+ * @example toPointer(["users", 0, "a/b"]); // "/users/0/a~1b"
18
+ */
19
+ export declare function toPointer(path: StandardSchemaV1.Issue["path"]): string;
20
+ /**
21
+ * Converts Standard Schema issues into `errors` entries. Standard Schema issues carry no
22
+ * code of their own, so every entry gets `code`.
23
+ *
24
+ * @param source - The issues, or anything carrying them, such as a validation error.
25
+ * @param code - The code each entry reports.
26
+ * @default code "invalid"
27
+ * @example issuesFrom(parseSafe(schema, input).issues);
28
+ */
29
+ export declare function issuesFrom(source: readonly StandardSchemaV1.Issue[] | {
30
+ issues: readonly StandardSchemaV1.Issue[];
31
+ }, code?: string): ProblemIssue[];
32
+ /**
33
+ * Builds the response for a request that failed validation, `422` unless `options` says
34
+ * otherwise, listing each invalid field in the `errors` extension.
35
+ *
36
+ * @param issues - One entry per invalid field.
37
+ * @param options - Any standard member to set; `status` defaults to `422`.
38
+ * @example return validationProblem(issuesFrom(result.issues));
39
+ */
40
+ export declare function validationProblem(issues: ProblemIssue[], options?: Partial<Omit<ProblemOptions, "extensions">>): Response;
41
+ /**
42
+ * The schema for the `errors` extension, for composing into an extension schema such as
43
+ * `s.object({ errors: ISSUES_SCHEMA })`.
44
+ */
45
+ export declare const ISSUES_SCHEMA: s.Schema<unknown, ProblemIssue[]>;
package/dist/issues.js ADDED
@@ -0,0 +1,46 @@
1
+ import * as s from "remix/data-schema";
2
+ import { problem } from "./problem.js";
3
+ /**
4
+ * Formats a Standard Schema issue path as an RFC 6901 JSON Pointer, escaping `~` as `~0`
5
+ * and `/` as `~1`. An empty or absent path points at the whole document, `""`.
6
+ *
7
+ * @param path - The issue's path segments.
8
+ * @example toPointer(["users", 0, "a/b"]); // "/users/0/a~1b"
9
+ */
10
+ export function toPointer(path) {
11
+ let pointer = "";
12
+ for (let segment of path ?? []) {
13
+ let key = typeof segment === "object" ? segment.key : segment;
14
+ pointer += `/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`;
15
+ }
16
+ return pointer;
17
+ }
18
+ /**
19
+ * Converts Standard Schema issues into `errors` entries. Standard Schema issues carry no
20
+ * code of their own, so every entry gets `code`.
21
+ *
22
+ * @param source - The issues, or anything carrying them, such as a validation error.
23
+ * @param code - The code each entry reports.
24
+ * @default code "invalid"
25
+ * @example issuesFrom(parseSafe(schema, input).issues);
26
+ */
27
+ export function issuesFrom(source, code = "invalid") {
28
+ let issues = "issues" in source ? source.issues : source;
29
+ return issues.map((issue) => ({ pointer: toPointer(issue.path), code, message: issue.message }));
30
+ }
31
+ /**
32
+ * Builds the response for a request that failed validation, `422` unless `options` says
33
+ * otherwise, listing each invalid field in the `errors` extension.
34
+ *
35
+ * @param issues - One entry per invalid field.
36
+ * @param options - Any standard member to set; `status` defaults to `422`.
37
+ * @example return validationProblem(issuesFrom(result.issues));
38
+ */
39
+ export function validationProblem(issues, options = {}) {
40
+ return problem({ status: 422, ...options, extensions: { errors: issues } });
41
+ }
42
+ /**
43
+ * The schema for the `errors` extension, for composing into an extension schema such as
44
+ * `s.object({ errors: ISSUES_SCHEMA })`.
45
+ */
46
+ export const ISSUES_SCHEMA = s.array(s.object({ pointer: s.string(), code: s.string(), message: s.string() }));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Reads problem documents: detecting one by its media type, and decoding its JSON
3
+ * into a `Problem` with RFC 9457's defaults resolved and the extension members
4
+ * optionally validated by a Standard Schema.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
11
+ import type { Problem } from "./types.js";
12
+ /** Why a document could not be read as a problem, with the schema's issues when extensions failed validation. */
13
+ export declare class ProblemParseError extends Error {
14
+ name: string;
15
+ readonly issues: readonly StandardSchemaV1.Issue[];
16
+ /**
17
+ * @param message - What made the document unreadable.
18
+ * @param issues - The extension schema's issues, empty for any other failure.
19
+ */
20
+ constructor(message: string, issues?: readonly StandardSchemaV1.Issue[]);
21
+ }
22
+ /** How to read a problem document. */
23
+ export interface ParseOptions<Extensions extends object> {
24
+ /** Validates the extension members; without one they are kept as an unchecked record. */
25
+ extensions?: StandardSchemaV1<unknown, Extensions>;
26
+ /** The status line's code, which takes precedence over the body's advisory `status`. */
27
+ status?: number;
28
+ }
29
+ /**
30
+ * Whether a request or response declares the problem media type. Only the media type
31
+ * essence is compared, case-insensitively, so parameters like `charset` are accepted;
32
+ * the body is left unread.
33
+ *
34
+ * @param message - A `Request` or `Response`.
35
+ */
36
+ export declare function isProblem(message: {
37
+ headers: Headers;
38
+ }): boolean;
39
+ /**
40
+ * Decodes a problem document's JSON text. Absent members take their RFC defaults; a body
41
+ * that is not a JSON object, a standard member of the wrong type, a missing status, or
42
+ * extensions the schema rejects is a failure. A schema must validate synchronously.
43
+ *
44
+ * @param text - The document's JSON text.
45
+ * @param options - The extension schema and the status line's code.
46
+ * @returns The problem, or why the text is not one.
47
+ * @template Extensions - The extension members the schema produces.
48
+ */
49
+ export declare function parse<Extensions extends object = Record<string, unknown>>(text: string, options?: ParseOptions<Extensions>): Result<Problem<Extensions>, ProblemParseError>;
50
+ /**
51
+ * Reads a problem `Response`, whose status line wins over the body's `status`. A response
52
+ * that does not declare the problem media type, or whose body cannot be read, is a failure.
53
+ *
54
+ * @param response - The response, with its body unread.
55
+ * @param options - The extension schema.
56
+ * @returns The problem, or why the response does not carry one.
57
+ * @template Extensions - The extension members the schema produces.
58
+ */
59
+ export declare function parseProblem<Extensions extends object = Record<string, unknown>>(response: Response, options?: Omit<ParseOptions<Extensions>, "status">): Promise<Result<Problem<Extensions>, ProblemParseError>>;
60
+ /**
61
+ * Collects the non-standard members and, when a schema is given, validates them.
62
+ *
63
+ * @param body - The decoded document.
64
+ * @param schema - The extension schema, if any.
65
+ */
66
+ export declare function readExtensions<Extensions extends object>(body: Record<string, unknown>, schema: StandardSchemaV1<unknown, Extensions> | undefined): Result<Extensions, ProblemParseError>;
package/dist/parse.js ADDED
@@ -0,0 +1,119 @@
1
+ import { failure, success } from "@sdxc/result";
2
+ import { ABOUT_BLANK, PROBLEM_MEDIA_TYPE } from "./problem.js";
3
+ import { statusPhrase } from "./status-phrase.js";
4
+ /** Why a document could not be read as a problem, with the schema's issues when extensions failed validation. */
5
+ export class ProblemParseError extends Error {
6
+ name = "ProblemParseError";
7
+ issues;
8
+ /**
9
+ * @param message - What made the document unreadable.
10
+ * @param issues - The extension schema's issues, empty for any other failure.
11
+ */
12
+ constructor(message, issues = []) {
13
+ super(message);
14
+ this.issues = issues;
15
+ }
16
+ }
17
+ /** The members RFC 9457 defines, which never appear among a parsed problem's extensions. */
18
+ const STANDARD_MEMBERS = new Set(["type", "title", "status", "detail", "instance"]);
19
+ /**
20
+ * Whether a request or response declares the problem media type. Only the media type
21
+ * essence is compared, case-insensitively, so parameters like `charset` are accepted;
22
+ * the body is left unread.
23
+ *
24
+ * @param message - A `Request` or `Response`.
25
+ */
26
+ export function isProblem(message) {
27
+ let declared = message.headers.get("Content-Type");
28
+ if (declared === null)
29
+ return false;
30
+ return declared.split(";")[0]?.trim().toLowerCase() === PROBLEM_MEDIA_TYPE;
31
+ }
32
+ /**
33
+ * Decodes a problem document's JSON text. Absent members take their RFC defaults; a body
34
+ * that is not a JSON object, a standard member of the wrong type, a missing status, or
35
+ * extensions the schema rejects is a failure. A schema must validate synchronously.
36
+ *
37
+ * @param text - The document's JSON text.
38
+ * @param options - The extension schema and the status line's code.
39
+ * @returns The problem, or why the text is not one.
40
+ * @template Extensions - The extension members the schema produces.
41
+ */
42
+ export function parse(text, options = {}) {
43
+ let json;
44
+ try {
45
+ json = JSON.parse(text);
46
+ }
47
+ catch {
48
+ return failure(new ProblemParseError("The problem document is not valid JSON."));
49
+ }
50
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
51
+ return failure(new ProblemParseError("The problem document is not a JSON object."));
52
+ }
53
+ let body = json;
54
+ let status = options.status ?? body.status;
55
+ if (!Number.isInteger(status)) {
56
+ return failure(new ProblemParseError("The problem document has no integer status."));
57
+ }
58
+ for (let member of ["type", "title", "detail", "instance"]) {
59
+ if (body[member] !== undefined && typeof body[member] !== "string") {
60
+ return failure(new ProblemParseError(`The problem member "${member}" is not a string.`));
61
+ }
62
+ }
63
+ let extensions = readExtensions(body, options.extensions);
64
+ if (extensions.status === "failure")
65
+ return extensions;
66
+ return success({
67
+ type: body.type ?? ABOUT_BLANK,
68
+ title: body.title ?? statusPhrase(status),
69
+ status: status,
70
+ detail: body.detail ?? null,
71
+ instance: body.instance ?? null,
72
+ extensions: extensions.data,
73
+ });
74
+ }
75
+ /**
76
+ * Reads a problem `Response`, whose status line wins over the body's `status`. A response
77
+ * that does not declare the problem media type, or whose body cannot be read, is a failure.
78
+ *
79
+ * @param response - The response, with its body unread.
80
+ * @param options - The extension schema.
81
+ * @returns The problem, or why the response does not carry one.
82
+ * @template Extensions - The extension members the schema produces.
83
+ */
84
+ export async function parseProblem(response, options = {}) {
85
+ if (!isProblem(response)) {
86
+ return failure(new ProblemParseError(`The response is not ${PROBLEM_MEDIA_TYPE}.`));
87
+ }
88
+ let text;
89
+ try {
90
+ text = await response.text();
91
+ }
92
+ catch {
93
+ return failure(new ProblemParseError("The response body could not be read."));
94
+ }
95
+ return parse(text, { ...options, status: response.status });
96
+ }
97
+ /**
98
+ * Collects the non-standard members and, when a schema is given, validates them.
99
+ *
100
+ * @param body - The decoded document.
101
+ * @param schema - The extension schema, if any.
102
+ */
103
+ export function readExtensions(body, schema) {
104
+ let members = {};
105
+ for (let [key, value] of Object.entries(body)) {
106
+ if (!STANDARD_MEMBERS.has(key))
107
+ members[key] = value;
108
+ }
109
+ if (schema === undefined)
110
+ return success(members);
111
+ let result = schema["~standard"].validate(members);
112
+ if (result instanceof Promise) {
113
+ return failure(new ProblemParseError("The extension schema must validate synchronously."));
114
+ }
115
+ if (result.issues !== undefined) {
116
+ return failure(new ProblemParseError("The problem's extensions failed validation.", result.issues));
117
+ }
118
+ return success(result.value);
119
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Writes problem documents: the JSON text and the `Response` that carries it,
3
+ * with RFC 9457's defaults applied so a status alone produces a valid document.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import type { ProblemOptions } from "./types.js";
9
+ /** The registered media type of an RFC 9457 JSON problem document. */
10
+ export declare const PROBLEM_MEDIA_TYPE = "application/problem+json";
11
+ /** The `type` of a problem whose status code alone describes it. */
12
+ export declare const ABOUT_BLANK = "about:blank";
13
+ /**
14
+ * Serializes a problem to its JSON text. Extension members are written first and the
15
+ * standard members after them, so an extension named like a standard member never
16
+ * replaces it; `null` or omitted `detail` and `instance` are left out.
17
+ *
18
+ * @param options - The problem to write; a parsed `Problem` is accepted as-is.
19
+ * @returns The document's JSON text.
20
+ * @example stringify({ status: 404 }); // '{"type":"about:blank","title":"Not Found","status":404}'
21
+ */
22
+ export declare function stringify(options: ProblemOptions<object>): string;
23
+ /**
24
+ * Builds the `Response` for a problem, with the status line and the body's `status`
25
+ * set from the same value and `Content-Type` declaring the problem media type.
26
+ *
27
+ * @param options - The problem to answer with.
28
+ * @param init - Extra headers, such as `Retry-After`, merged under the content type.
29
+ * @returns A response ready to return from a handler.
30
+ * @example return problem({ status: 404 });
31
+ * @example return problem({ status: 429, detail: "Try again in a minute." }, { headers: { "Retry-After": "60" } });
32
+ */
33
+ export declare function problem(options: ProblemOptions<object>, init?: ResponseInit): Response;
@@ -0,0 +1,46 @@
1
+ import { statusPhrase } from "./status-phrase.js";
2
+ /** The registered media type of an RFC 9457 JSON problem document. */
3
+ export const PROBLEM_MEDIA_TYPE = "application/problem+json";
4
+ /** The `type` of a problem whose status code alone describes it. */
5
+ export const ABOUT_BLANK = "about:blank";
6
+ /**
7
+ * Serializes a problem to its JSON text. Extension members are written first and the
8
+ * standard members after them, so an extension named like a standard member never
9
+ * replaces it; `null` or omitted `detail` and `instance` are left out.
10
+ *
11
+ * @param options - The problem to write; a parsed `Problem` is accepted as-is.
12
+ * @returns The document's JSON text.
13
+ * @example stringify({ status: 404 }); // '{"type":"about:blank","title":"Not Found","status":404}'
14
+ */
15
+ export function stringify(options) {
16
+ let body = {
17
+ ...options.extensions,
18
+ type: options.type ?? ABOUT_BLANK,
19
+ title: options.title ?? statusPhrase(options.status),
20
+ status: options.status,
21
+ };
22
+ if (options.detail != null)
23
+ body.detail = options.detail;
24
+ else
25
+ delete body.detail;
26
+ if (options.instance != null)
27
+ body.instance = options.instance;
28
+ else
29
+ delete body.instance;
30
+ return JSON.stringify(body);
31
+ }
32
+ /**
33
+ * Builds the `Response` for a problem, with the status line and the body's `status`
34
+ * set from the same value and `Content-Type` declaring the problem media type.
35
+ *
36
+ * @param options - The problem to answer with.
37
+ * @param init - Extra headers, such as `Retry-After`, merged under the content type.
38
+ * @returns A response ready to return from a handler.
39
+ * @example return problem({ status: 404 });
40
+ * @example return problem({ status: 429, detail: "Try again in a minute." }, { headers: { "Retry-After": "60" } });
41
+ */
42
+ export function problem(options, init) {
43
+ let headers = new Headers(init?.headers);
44
+ headers.set("Content-Type", PROBLEM_MEDIA_TYPE);
45
+ return new Response(stringify(options), { ...init, status: options.status, headers });
46
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The reason phrase for each HTTP error status, which RFC 9457 makes the `title`
3
+ * of an `about:blank` problem, so a status alone produces a complete document.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * The reason phrase for `status`, or `"Unknown Status"` for a code the registry
10
+ * leaves unnamed, so every problem carries a title.
11
+ *
12
+ * @param status - The HTTP status code.
13
+ * @returns The phrase to use as the problem's `title`.
14
+ */
15
+ export declare function statusPhrase(status: number): string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The reason phrase for each HTTP error status, which RFC 9457 makes the `title`
3
+ * of an `about:blank` problem, so a status alone produces a complete document.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /** The registered phrase for every 4xx and 5xx status in the IANA HTTP status registry. */
9
+ const STATUS_PHRASES = {
10
+ 400: "Bad Request",
11
+ 401: "Unauthorized",
12
+ 402: "Payment Required",
13
+ 403: "Forbidden",
14
+ 404: "Not Found",
15
+ 405: "Method Not Allowed",
16
+ 406: "Not Acceptable",
17
+ 407: "Proxy Authentication Required",
18
+ 408: "Request Timeout",
19
+ 409: "Conflict",
20
+ 410: "Gone",
21
+ 411: "Length Required",
22
+ 412: "Precondition Failed",
23
+ 413: "Content Too Large",
24
+ 414: "URI Too Long",
25
+ 415: "Unsupported Media Type",
26
+ 416: "Range Not Satisfiable",
27
+ 417: "Expectation Failed",
28
+ 421: "Misdirected Request",
29
+ 422: "Unprocessable Content",
30
+ 423: "Locked",
31
+ 424: "Failed Dependency",
32
+ 425: "Too Early",
33
+ 426: "Upgrade Required",
34
+ 428: "Precondition Required",
35
+ 429: "Too Many Requests",
36
+ 431: "Request Header Fields Too Large",
37
+ 451: "Unavailable For Legal Reasons",
38
+ 500: "Internal Server Error",
39
+ 501: "Not Implemented",
40
+ 502: "Bad Gateway",
41
+ 503: "Service Unavailable",
42
+ 504: "Gateway Timeout",
43
+ 505: "HTTP Version Not Supported",
44
+ 506: "Variant Also Negotiates",
45
+ 507: "Insufficient Storage",
46
+ 508: "Loop Detected",
47
+ 511: "Network Authentication Required",
48
+ };
49
+ /**
50
+ * The reason phrase for `status`, or `"Unknown Status"` for a code the registry
51
+ * leaves unnamed, so every problem carries a title.
52
+ *
53
+ * @param status - The HTTP status code.
54
+ * @returns The phrase to use as the problem's `title`.
55
+ */
56
+ export function statusPhrase(status) {
57
+ return STATUS_PHRASES[status] ?? "Unknown Status";
58
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The shapes of an RFC 9457 problem document: the parsed form a reader receives,
3
+ * the options a writer passes, and the field-level issue a validation failure lists.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * A problem document as read, with every standard member resolved: `type` and `title`
10
+ * carry their RFC defaults when the document omits them, and the extension members sit
11
+ * under `extensions` so none of them can shadow a standard one.
12
+ *
13
+ * @template Extensions - The extension members, as a schema validated them.
14
+ */
15
+ export interface Problem<Extensions extends object = Record<string, unknown>> {
16
+ /** The URI a client branches on; `"about:blank"` means the status alone describes it. */
17
+ type: string;
18
+ title: string;
19
+ status: number;
20
+ detail: string | null;
21
+ /** Identifies this occurrence, for a caller to quote back in a support request. */
22
+ instance: string | null;
23
+ extensions: Extensions;
24
+ }
25
+ /**
26
+ * What a writer passes to describe a problem. Only `status` is required: an omitted
27
+ * `type` writes `"about:blank"`, and an omitted `title` writes the status phrase.
28
+ *
29
+ * @template Extensions - The extension members written at the document's top level.
30
+ */
31
+ export interface ProblemOptions<Extensions extends object = Record<string, unknown>> {
32
+ status: number;
33
+ type?: string;
34
+ title?: string;
35
+ detail?: string | null;
36
+ instance?: string | null;
37
+ extensions?: Extensions;
38
+ }
39
+ /** One invalid field, the entry shape of a validation failure's `errors` extension. */
40
+ export interface ProblemIssue {
41
+ /** An RFC 6901 JSON Pointer into the request body; `""` names the body itself. */
42
+ pointer: string;
43
+ /** A stable code for a caller branching without reading `message`. */
44
+ code: string;
45
+ message: string;
46
+ }
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The shapes of an RFC 9457 problem document: the parsed form a reader receives,
3
+ * the options a writer passes, and the field-level issue a validation failure lists.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@sdxc/problem",
3
+ "version": "0.0.0-pre.1",
4
+ "description": "Build, detect and parse RFC 9457 problem details, and declare an API's problem types in one catalog",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@sdxc/result": "2026.9.15",
12
+ "@standard-schema/spec": "^1.1.0",
13
+ "remix": "3.0.0-rc.2"
14
+ },
15
+ "gitHead": "a076dfaede8ecdd39ba3c2bb08b9239031540b67",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sergiodxa/monorepo.git",
22
+ "directory": "packages/problem"
23
+ }
24
+ }