@sdxc/problem
Build, detect and parse RFC 9457 problem details, and declare an API's problem types in one catalog
- Installs with
- @sdxc/result
- Depends on
@standard-schema/specremix- Source
- packages/problem
Build, detect and parse RFC 9457 problem details
(application/problem+json), and declare an API's problem types in one catalog.
Installation
npm add @sdxc/problem
Extension members are validated with remix/data-schema
or any other Standard Schema, and results come back as
@sdxc/result values; both install alongside
this package.
Usage
Answer With A Problem
import { problem } from "@sdxc/problem";
return problem({ status: 404 });
// 404, Content-Type: application/problem+json
// {"type":"about:blank","title":"Not Found","status":404}
A status alone is a complete document: type defaults to about:blank and title to the
status phrase. Add type, title, detail, instance and extensions as needed:
return problem(
{
status: 403,
type: "https://example.com/probs/out-of-credit",
title: "You do not have enough credit.",
detail: "Your current balance is 30, but that costs 50.",
extensions: { balance: 30 },
},
{ headers: { "Cache-Control": "no-store" } },
);
Extensions are written at the document's top level, and one named like a standard member never replaces it.
Declare A Catalog
import { defineProblems } from "@sdxc/problem";
import * as s from "remix/data-schema";
export const problems = defineProblems("https://docs.example.com/errors/", {
notFound: { slug: "not-found", status: 404, title: "The resource does not exist" },
outOfCredit: {
slug: "out-of-credit",
status: 403,
title: "You do not have enough credit",
extensions: s.object({ balance: s.number() }),
},
});
return problems.notFound({ detail: "No article has that slug." });
return problems.outOfCredit({ extensions: { balance: 30 } });
Each entry becomes a builder that writes its type (the base URL followed by the slug),
status and title, so a call site supplies only detail, instance and the extensions
its schema types. The base URL must end in /.
Read A Problem
import { isProblem, parseProblem } from "@sdxc/problem";
import { isSuccess } from "@sdxc/result";
if (isProblem(response)) {
let result = await parseProblem(response);
if (isSuccess(result)) result.data.type; // "https://example.com/probs/out-of-credit"
}
With a catalog, parse names the entry a response belongs to and validates its extensions:
let result = await problems.parse(response);
if (isSuccess(result) && problems.is(result.data, "outOfCredit")) {
result.data.extensions.balance; // number
}
A type outside the catalog still parses, with name: null, as RFC 9457 requires clients
to accept problem types they do not know.
Report Validation Failures
import { issuesFrom, validationProblem } from "@sdxc/problem";
import * as s from "remix/data-schema";
let result = s.parseSafe(schema, body);
if (!result.success) return validationProblem(issuesFrom(result.issues));
// 422 with {"errors":[{"pointer":"/user/email","code":"invalid","message":"..."}], ...}
API
problem(options, init?)
Returns a Response whose status line and body status both come from options.status,
with Content-Type: application/problem+json. init adds headers.
stringify(options)
The document's JSON text, for writing a problem somewhere other than a Response.
defineProblems(base, entries)
A catalog: one builder per entry, plus parse(response), is(problem, name) and
entries(), which lists every entry with its resolved type for rendering an error
reference. Entries cannot be named parse, is or entries.
isProblem(message)
Whether a Request or Response declares application/problem+json, ignoring parameters
and case. The body is left unread.
parseProblem(response, options?)
Reads a problem Response into a Result<Problem, ProblemParseError>. Absent members take
their RFC defaults, and the status line wins over the body's status. Pass
options.extensions, a synchronous Standard Schema, to validate and type the extensions.
parse(text, options?)
The same, from JSON text; options.status stands in for the status line.
validationProblem(issues, options?)
A 422 problem whose errors extension lists each invalid field. options sets any
standard member, including a different status.
issuesFrom(source, code?)
Converts Standard Schema issues, or an error carrying them in issues, into errors
entries, turning each path into a JSON Pointer. Every entry gets code, "invalid" by
default.
toPointer(path)
Formats an issue path as an RFC 6901 JSON Pointer, escaping ~ and /.
ISSUES_SCHEMA
The schema for the errors extension, for s.object({ errors: ISSUES_SCHEMA }).
ProblemParseError
Why a document is not a problem. issues holds the extension schema's issues when that is
the reason.
PROBLEM_MEDIA_TYPE, ABOUT_BLANK
"application/problem+json" and "about:blank".
Types
Problem<Extensions> is a parsed document, with detail and instance as string | null
and extension members under extensions. ProblemOptions<Extensions> is what a writer
passes, and ProblemIssue is one errors entry.
Pattern: Share A Catalog Between A Server And Its Client
// api-problems.ts, imported by both
import { ISSUES_SCHEMA, defineProblems } from "@sdxc/problem";
import * as s from "remix/data-schema";
export const apiProblems = defineProblems("https://docs.example.com/errors/", {
notFound: { slug: "not-found", status: 404, title: "The resource does not exist" },
validationFailed: {
slug: "validation-failed",
status: 422,
title: "The request body is invalid",
extensions: s.object({ errors: ISSUES_SCHEMA }),
},
});
// server
import { issuesFrom } from "@sdxc/problem";
import * as s from "remix/data-schema";
let result = s.parseSafe(schema, body);
if (!result.success) {
return apiProblems.validationFailed({ extensions: { errors: issuesFrom(result.issues) } });
}
// client
import { isSuccess } from "@sdxc/result";
let response = await fetch(url, { method: "POST", body });
if (!response.ok) {
let result = await apiProblems.parse(response);
if (isSuccess(result) && apiProblems.is(result.data, "validationFailed")) {
for (let issue of result.data.extensions.errors) showError(issue.pointer, issue.message);
}
}