diff --git a/packages/problem-details/README.md b/packages/problem-details/README.md new file mode 100644 index 0000000..1b908da --- /dev/null +++ b/packages/problem-details/README.md @@ -0,0 +1,24 @@ +# `@contentgrid/problem-details` + +[RFC 9457](https://datatracker.ietf.org/doc/html/rfc9567) Problem Details types and helpers + +## Usage + +```typescript +import { ProblemDetail, ProblemDetailError, checkResponse } from '@contentgrid/problem-details' + +// Fetch data from somewhere +fetch('/some-url') + .then(checkResponse) // Throws ProblemDetailError if an error response is returned + .then(response => { + // Handle succesfull response + }, error => { + // Handle error response + if(error instanceof ProblemDetailError) { + // ProblemDetail is available on the ProblemDetailError + console.error("Failed to fetch", error.problemDetail.title) + } else { + console.error("Failed to fetch", error); + } + }) +``` diff --git a/packages/problem-details/__tests__/index.ts b/packages/problem-details/__tests__/index.ts new file mode 100644 index 0000000..0e6dd22 --- /dev/null +++ b/packages/problem-details/__tests__/index.ts @@ -0,0 +1,91 @@ +import { test, expect, describe } from "@jest/globals" +import { fromResponse } from "../src/index"; + +describe('Create problem details', () => { + + test('from a problemdetails response', async () => { + const response = new Response(JSON.stringify({ + type: "http://example.com/rels/xyz", + title: "XYZ" + }), { + status: 404, + headers: { + "Content-Type": "application/problem+json" + } + }); + + const problemDetails = await fromResponse(response); + + expect(problemDetails).toEqual({ + type: "http://example.com/rels/xyz", + status: 404, + title: "XYZ" + }); + }) + + test('from a problemdetails response with an "about:blank" type', async () => { + const response = new Response(JSON.stringify({ + type: "about:blank", + }), { + status: 404, + headers: { + "Content-Type": "application/problem+json" + } + }); + + const problemDetails = await fromResponse(response); + + expect(problemDetails).toEqual({ + status: 404, + title: "" + }); + }) + + test('from a problemdetails response with a null type', async () => { + const response = new Response(JSON.stringify({ + type: null, + }), { + status: 404, + headers: { + "Content-Type": "application/problem+json" + } + }); + + const problemDetails = await fromResponse(response); + + expect(problemDetails).toEqual({ + status: 404, + title: "" + }); + }) + + test('from a non-problemdetails response', async () => { + const response = new Response("my-response", { + status: 403, + statusText: "Forbidden", + headers:{ + "Content-Type": "text/plain" + } + }); + + const problemDetails = await fromResponse(response); + + expect(problemDetails).toEqual({ + status: 403, + title: "Forbidden" + }); + + }) + + test('from a succesful response', async () => { + const response = new Response("my data", { + status: 200, + headers: { + "Content-Type": "text/plain" + } + }); + + expect(await fromResponse(response)).toBeNull(); + + }); +}) diff --git a/packages/problem-details/babel.config.mjs b/packages/problem-details/babel.config.mjs new file mode 100644 index 0000000..5becfe5 --- /dev/null +++ b/packages/problem-details/babel.config.mjs @@ -0,0 +1 @@ +export { default } from "../../config/babel.config.mjs"; diff --git a/packages/problem-details/jest.config.js b/packages/problem-details/jest.config.js new file mode 100644 index 0000000..77af86f --- /dev/null +++ b/packages/problem-details/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../config/jest.config.js'); diff --git a/packages/problem-details/package.json b/packages/problem-details/package.json new file mode 100644 index 0000000..9e055de --- /dev/null +++ b/packages/problem-details/package.json @@ -0,0 +1,36 @@ +{ + "name": "@contentgrid/problem-details", + "version": "0.0.1-alpha.0", + "description": "RFC9547 Problem Details types and helpers", + "keywords": ["RFC7807", "RFC9547", "problem", "problem-details"], + "main": "./build/index.js", + "module": "./build/index.mjs", + "types": "./build/index.d.ts", + "exports": { + ".": { + "types": "./build/index.d.ts", + "import": "./build/index.mjs", + "default": "./build/index.js" + } + }, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.6", + "tslib": "^2.6.1" + }, + "scripts": { + "prepare": "rollup -c", + "test": "jest" + }, + "repository": { + "type": "git", + "url": "https://github.com/xenit-eu/contentgrid-ts", + "directory": "packages/problem-details" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "build" + ] +} diff --git a/packages/problem-details/rollup.config.mjs b/packages/problem-details/rollup.config.mjs new file mode 100644 index 0000000..077e7f4 --- /dev/null +++ b/packages/problem-details/rollup.config.mjs @@ -0,0 +1 @@ +export { default } from "../../config/rollup.config.mjs"; diff --git a/packages/problem-details/src/index.ts b/packages/problem-details/src/index.ts new file mode 100644 index 0000000..89f6787 --- /dev/null +++ b/packages/problem-details/src/index.ts @@ -0,0 +1,47 @@ +export interface ProblemDetail { + readonly type?: string; + readonly status: number; + readonly title: string; + readonly detail?: string; + readonly instance?: string; +} + +export async function fromResponse(response: Response): Promise { + if(response.ok) { + return null; + } + if(response.headers.get("content-type")?.toLowerCase() !== "application/problem+json") { + return { + status: response.status, + title: response.statusText + } as T; + } + + const data = await response.json(); + + data.status ??= response.status; + data.title ??= response.statusText; + if(data.type === null || data.type === "about:blank") { + delete data.type; + } + + return data as T; +} + +export class ProblemDetailError extends Error { + constructor(public readonly problemDetail: T) { + super(problemDetail.title); + this.name = 'ProblemDetailError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export async function checkResponse(response: Response): Promise { + const problem = await fromResponse(response); + + if(problem !== null) { + throw new ProblemDetailError(problem); + } + + return response; +} diff --git a/packages/problem-details/tsconfig.json b/packages/problem-details/tsconfig.json new file mode 100644 index 0000000..a53e344 --- /dev/null +++ b/packages/problem-details/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../config/tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + } +}