feat(api): standardize errors as problem details

This commit is contained in:
dmg
2026-08-01 14:42:19 -04:00
parent 40abab7abc
commit 2657399628
16 changed files with 441 additions and 17 deletions
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { isUniqueConstraintViolation } from "./database-errors";
describe("database error classification", () => {
it("recognizes a nested PostgreSQL unique-constraint violation", () => {
expect(isUniqueConstraintViolation({
cause: { code: "23505", constraint_name: "plugin_requests_pkey" },
}, "plugin_requests_pkey")).toBe(true);
});
it("does not mistake another unique constraint for a replay", () => {
expect(isUniqueConstraintViolation({
code: "23505",
constraint_name: "minecraft_accounts_active_uuid_uidx",
}, "plugin_requests_pkey")).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
export function isUniqueConstraintViolation(
error: unknown,
expectedConstraint: string,
): boolean {
if (!error || typeof error !== "object") return false;
const databaseError = error as {
code?: unknown;
constraint_name?: unknown;
constraint?: unknown;
message?: unknown;
cause?: unknown;
};
const constraint = databaseError.constraint_name ?? databaseError.constraint;
if (
databaseError.code === "23505" &&
(constraint === expectedConstraint || String(databaseError.message).includes(expectedConstraint))
) {
return true;
}
return databaseError.cause
? isUniqueConstraintViolation(databaseError.cause, expectedConstraint)
: false;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { problemInstance, problemResponse } from "./problem-response";
describe("problemResponse", () => {
it("uses the request path and query as the problem instance", () => {
expect(problemInstance(new Request("https://example.com/api/items?cursor=next")))
.toBe("/api/items?cursor=next");
});
it("returns RFC 9457 JSON with the required media type", async () => {
const response = problemResponse({
type: "urn:error:unauthorized",
title: "Unauthorized",
status: 401,
detail: "A valid Velocity server credential is required.",
instance: "/api/velocity/access",
});
expect(response.status).toBe(401);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(await response.json()).toEqual({
type: "urn:error:unauthorized",
title: "Unauthorized",
status: 401,
detail: "A valid Velocity server credential is required.",
instance: "/api/velocity/access",
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { ProblemDetails } from "@minecraft-account-manager/contracts";
export function problemInstance(request: Request) {
const url = new URL(request.url);
return `${url.pathname}${url.search}`;
}
export function problemResponse(problem: ProblemDetails) {
return new Response(JSON.stringify(problem), {
status: problem.status,
headers: {
"cache-control": "no-store",
"content-type": "application/problem+json",
},
});
}