diff --git a/.eslintrc.js b/.eslintrc.js index 76edd2a..13d8651 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,5 +23,22 @@ module.exports = { parserOptions: { project: "./tsconfig.eslint.json", }, - ignorePatterns: ["dist/"], + overrides: [ + { + rules: { + // Conflicts with TS imports + "import/no-unresolved": "off", + "no-shadow": [ + "error", + { + // status is a (deprecated) global variable, but it also is the + // conventional name for a Response attribute. + allow: ["status"], + }, + ], + }, + files: "*", + }, + ], + ignorePatterns: ["dist/", "docs/"], }; diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9913fae..b4ea8b4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -30,7 +30,8 @@ This PR bumps the version to . 1. Look at the [CHANGELOG.md](../CHANGELOG.md) to determine whether the release should be a major, minor, or patch release. Coordinate with the team to ensure the next version is agreed upon. 2. Run `npm version -- --no-push` with the decided on version (to prevent the tag from being pushed). 3. Update the `CHANGELOG.md` to release the latest the version, and set the release date. -4. Commit the changes on a `release/vX.Y.Z` branch -5. Push to GitHub, create a PR, and merge once CI passes. -6. Create a release on GitHub for the new version, using a combination of the release notes from the `CHANGELOG.md` and the automatically generated changes. +4. Add the `@since X.Y.Z` annotations to new APIs. +5. Commit the changes on a `release/vX.Y.Z` branch +6. Push to GitHub, create a PR, and merge once CI passes. +7. Create a release on GitHub for the new version, using a combination of the release notes from the `CHANGELOG.md` and the automatically generated changes. The release should have a new tag matching the new version number: `vx.y.z`, and point to the release commit. diff --git a/.gitignore b/.gitignore index c6bba59..cf03b90 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +docs/api/source/ diff --git a/README.md b/README.md index 87577f2..348b042 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ releases](https://nodejs.org/en/about/releases/), and support 18.x and 20.x. # Installation -For the latest stable version of solid-client-error: +For the latest stable version of solid-client-errors: ```bash npm install @inrupt/solid-client-errors diff --git a/jest.config.ts b/jest.config.ts index 0ced722..85f43bb 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -24,7 +24,10 @@ import type { Config } from "jest"; type ArrayElement = MyArray extends Array ? T : never; const baseConfig: ArrayElement> = { + coveragePathIgnorePatterns: [".*\\.mock\\.ts"], modulePathIgnorePatterns: ["dist/", "/examples/"], + // Setup required because of missing globals in JSDom + setupFilesAfterEnv: ["/jest.setup.ts"], testRegex: "/src/.*\\.test\\.ts$", clearMocks: true, injectGlobals: false, diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..cd642cd --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,22 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import "@inrupt/jest-jsdom-polyfills"; diff --git a/package-lock.json b/package-lock.json index 7a3ac79..51fb7c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,13 +5,15 @@ "requires": true, "packages": { "": { - "name": "@inrupt/solid-client-errors-js", + "name": "@inrupt/solid-client-error-js", "version": "0.0.1", "devDependencies": { "@inrupt/base-rollup-config": "^3.0.1", "@inrupt/eslint-config-lib": "^3.0.1", "@inrupt/internal-test-env": "^3.2.1", + "@inrupt/jest-jsdom-polyfills": "^3.2.1", "@typescript-eslint/eslint-plugin": "^7.14.1", + "@typhonjs-typedoc/ts-lib-docs": "^2023.7.12", "eslint": "^8.57.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^28.6.0", @@ -23,7 +25,7 @@ "ts-jest": "^29.1.5", "ts-node": "^10.9.2", "typedoc": "^0.26.3", - "typedoc-plugin-markdown": "^4.1.1", + "typedoc-plugin-markdown": "^3.17.1", "typescript": "^5.5.2" }, "engines": { @@ -743,6 +745,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1078,6 +1089,18 @@ "dotenv": "^16.4.4" } }, + "node_modules/@inrupt/jest-jsdom-polyfills": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@inrupt/jest-jsdom-polyfills/-/jest-jsdom-polyfills-3.2.1.tgz", + "integrity": "sha512-f4dupgNF+HzGmcdrRtWxk0BeLb499s1pCOVSzmecUhlXRQEZnYhpgVfEnpLvzZPIi5JBqWl1JJLTUlbOGsnYfw==", + "dev": true, + "dependencies": { + "@peculiar/webcrypto": "^1.4.5", + "@web-std/blob": "^3.0.5", + "@web-std/file": "^3.0.3", + "undici": "^5.28.3" + } + }, "node_modules/@inrupt/solid-client": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@inrupt/solid-client/-/solid-client-2.0.1.tgz", @@ -1623,6 +1646,45 @@ "node": ">= 8" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", + "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", + "dev": true, + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@pkgr/core": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", @@ -2343,12 +2405,56 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typhonjs-typedoc/ts-lib-docs": { + "version": "2023.7.12", + "resolved": "https://registry.npmjs.org/@typhonjs-typedoc/ts-lib-docs/-/ts-lib-docs-2023.7.12.tgz", + "integrity": "sha512-vaiDbYGjnx7+gMM7KWSgNpGFxqr2Gw5MYpi9hvLWt8/nZ98EaTo1M4WjT6XtgbCw0w0x7greMlYWLgk7Mak/TA==", + "dev": true, + "peerDependencies": { + "typedoc": ">=0.24.8" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@web-std/blob": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@web-std/blob/-/blob-3.0.5.tgz", + "integrity": "sha512-Lm03qr0eT3PoLBuhkvFBLf0EFkAsNz/G/AYCzpOdi483aFaVX86b4iQs0OHhzHJfN5C15q17UtDbyABjlzM96A==", + "dev": true, + "dependencies": { + "@web-std/stream": "1.0.0", + "web-encoding": "1.1.5" + } + }, + "node_modules/@web-std/file": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@web-std/file/-/file-3.0.3.tgz", + "integrity": "sha512-X7YYyvEERBbaDfJeC9lBKC5Q5lIEWYCP1SNftJNwNH/VbFhdHm+3neKOQP+kWEYJmosbDFq+NEUG7+XIvet/Jw==", + "dev": true, + "dependencies": { + "@web-std/blob": "^3.0.3" + } + }, + "node_modules/@web-std/stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@web-std/stream/-/stream-1.0.0.tgz", + "integrity": "sha512-jyIbdVl+0ZJyKGTV0Ohb9E6UnxP+t7ZzX4Do3AHjZKxUXKMs9EmqnBDQgHF7bEw0EzbQygOjtt/7gvtmi//iCQ==", + "dev": true, + "dependencies": { + "web-streams-polyfill": "^3.1.1" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "dev": true, + "optional": true + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -2635,6 +2741,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dev": true, + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4515,6 +4635,27 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -4774,6 +4915,22 @@ "node": ">= 0.4" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -4905,6 +5062,21 @@ "node": ">=6" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6149,6 +6321,12 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6777,6 +6955,24 @@ } ] }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dev": true, + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -7831,15 +8027,15 @@ } }, "node_modules/typedoc-plugin-markdown": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.1.1.tgz", - "integrity": "sha512-ZQv8FXn1TBZAvhWMgOL8hE472rwv1dzSr/KIIUGPmdNXybeS6jmK7d1OwKhorLuGbPDQGl6U97BwfkFTcydAkw==", + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-3.17.1.tgz", + "integrity": "sha512-QzdU3fj0Kzw2XSdoL15ExLASt2WPqD7FbLeaqwT70+XjKyTshBnUlQA5nNREO1C2P8Uen0CDjsBLMsCQ+zd0lw==", "dev": true, - "engines": { - "node": ">= 18" + "dependencies": { + "handlebars": "^4.7.7" }, "peerDependencies": { - "typedoc": "0.26.x" + "typedoc": ">=0.24.0" } }, "node_modules/typescript": { @@ -7861,6 +8057,19 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, + "node_modules/uglify-js": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.18.0.tgz", + "integrity": "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -7876,6 +8085,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -7940,6 +8161,19 @@ "requires-port": "^1.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -7994,6 +8228,40 @@ "makeerror": "1.0.12" } }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "dev": true, + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/webcrypto-core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.0.tgz", + "integrity": "sha512-kR1UQNH8MD42CYuLzvibfakG5Ew5seG85dMMoAM/1LqvckxaF6pUiidLuraIu4V+YCIFabYecUZAW0TuxAoaqw==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.1", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -8087,6 +8355,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index b054769..5c3f6b8 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "test:e2e:browser": "playwright test", "test:e2e:browser:build": "cd e2e/browser/test-app && npm ci" }, - "homepage": "https://docs.inrupt.com/client-libraries/solid-client-error-js/", - "bugs": "https://github.com/inrupt/solid-client-error-js/issues", + "homepage": "https://docs.inrupt.com/client-libraries/solid-client-errors-js/", + "bugs": "https://github.com/inrupt/solid-client-errors-js/issues", "main": "dist/index.js", "module": "dist/index.es.js", "types": "dist/index.d.ts", @@ -42,13 +42,15 @@ ], "repository": { "type": "git", - "url": "https://github.com/inrupt/solid-client-error-js.git" + "url": "https://github.com/inrupt/solid-client-errors-js.git" }, "devDependencies": { "@inrupt/base-rollup-config": "^3.0.1", "@inrupt/eslint-config-lib": "^3.0.1", "@inrupt/internal-test-env": "^3.2.1", + "@inrupt/jest-jsdom-polyfills": "^3.2.1", "@typescript-eslint/eslint-plugin": "^7.14.1", + "@typhonjs-typedoc/ts-lib-docs": "^2023.7.12", "eslint": "^8.57.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^28.6.0", @@ -60,7 +62,7 @@ "ts-jest": "^29.1.5", "ts-node": "^10.9.2", "typedoc": "^0.26.3", - "typedoc-plugin-markdown": "^4.1.1", + "typedoc-plugin-markdown": "^3.17.1", "typescript": "^5.5.2" }, "engines": { diff --git a/src/clientError.ts b/src/clientError.ts new file mode 100644 index 0000000..e08ba7a --- /dev/null +++ b/src/clientError.ts @@ -0,0 +1,29 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * Superclass of all errors thrown by Inrupt's client libraries. + * + * @since unreleased + */ +export class InruptClientError extends Error {} + +export default InruptClientError; diff --git a/src/http/errorResponse.mock.ts b/src/http/errorResponse.mock.ts new file mode 100644 index 0000000..553097d --- /dev/null +++ b/src/http/errorResponse.mock.ts @@ -0,0 +1,53 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import type { WithErrorResponse } from "./errorResponse"; + +export function mockErrorResponse({ + ok, + body, + status, + statusText, + url, + headers, +}: { + ok?: boolean | null; + body?: string | null; + status?: number | null; + statusText?: string | null; + url?: string | null; + headers?: Headers | null; +}): WithErrorResponse { + return { + response: { + ok: ok === null ? undefined : ok ?? false, + body: body === null ? undefined : body ?? "Some response body", + status: status === null ? undefined : status ?? 400, + statusText: statusText === null ? undefined : statusText ?? "Bad Request", + url: url === null ? undefined : url ?? "https://example.org/resource", + headers: headers === null ? undefined : headers ?? new Headers(), + }, + // The type assertion allows us to create invalid error + // responses for unit tests purpose. + } as WithErrorResponse; +} + +export default mockErrorResponse; diff --git a/src/http/errorResponse.test.ts b/src/http/errorResponse.test.ts new file mode 100644 index 0000000..86c2de6 --- /dev/null +++ b/src/http/errorResponse.test.ts @@ -0,0 +1,43 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import { describe, it, expect } from "@jest/globals"; +import { hasErrorResponse } from "./errorResponse"; +import { mockErrorResponse } from "./errorResponse.mock"; + +describe("hasErrorResponse", () => { + it("returns true for correct error responses", () => { + expect(hasErrorResponse(mockErrorResponse({}))).toBe(true); + }); + + it("returns false for error response with missing fields", () => { + expect(hasErrorResponse(mockErrorResponse({ ok: true }))).toBe(false); + expect(hasErrorResponse(mockErrorResponse({ status: null }))).toBe(false); + expect(hasErrorResponse(mockErrorResponse({ statusText: null }))).toBe( + false, + ); + expect(hasErrorResponse(mockErrorResponse({ body: null }))).toBe(false); + expect(hasErrorResponse(mockErrorResponse({ url: null }))).toBe(false); + }); + + it("returns false for an object not having an errorResponse entry", () => { + expect(hasErrorResponse(new Error())).toBe(false); + }); +}); diff --git a/src/http/errorResponse.ts b/src/http/errorResponse.ts new file mode 100644 index 0000000..313d832 --- /dev/null +++ b/src/http/errorResponse.ts @@ -0,0 +1,94 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * A subset of the {@link Response} type metadata. + * + * @since unreleased + */ +export type ResponseMetadata = Pick< + Response, + "headers" | "status" | "statusText" | "url" | "ok" +>; + +/** + * Relevant details of an HTTP error response. + * + * @since unreleased + */ +export type ErrorResponse = Readonly< + ResponseMetadata & { + ok: false; + body: string; + } +>; + +/** + * Extension to an Error thrown on an unsuccessful HTTP response + * to link to a {@link ErrorResponse} instance. + * + * @since unreleased + */ +export interface WithErrorResponse { + response: ErrorResponse; +} + +function isErrorResponse( + response: Response | ErrorResponse, +): response is ErrorResponse { + return ( + !response.ok && + typeof response.body === "string" && + typeof response.status === "number" && + typeof response.statusText === "string" && + typeof response.url === "string" && + response.headers instanceof Headers + ); +} + +/** + * Type guard which, given an Error, checks whether it has a `response` + * field conform to the {@link ErrorResponse} type. + * + * @example + * ``` + * try { + * // ... + * } catch (e) { + * if (hasErrorResponse(e)) { + * // e.response can safely be accessed. + * } + * } + * ``` + * + * @alpha + * @since unreleased + * @param error the error being checked. + * @returns whether the error has HTTP error details attached. + */ +export function hasErrorResponse( + error: Error | WithErrorResponse, +): error is WithErrorResponse { + if (typeof (error as WithErrorResponse).response !== "object") { + return false; + } + return isErrorResponse((error as WithErrorResponse).response); +} diff --git a/src/http/httpError.test.ts b/src/http/httpError.test.ts new file mode 100644 index 0000000..de8db94 --- /dev/null +++ b/src/http/httpError.test.ts @@ -0,0 +1,211 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import { describe, it, expect, jest } from "@jest/globals"; +import { ClientHttpError } from "./httpError"; +import { mockProblemDetails } from "./problemDetails.mock"; +import { + DEFAULT_TYPE, + PROBLEM_DETAILS_MIME, + hasProblemDetails, +} from "./problemDetails"; +import InruptClientError from "../clientError"; +import { hasErrorResponse } from "./errorResponse"; + +const mockResponse = ({ + body, + status, + statusText, + headers, + responseUrl, +}: { + body?: string; + status?: number; + statusText?: string; + headers?: Headers; + responseUrl?: string; +} = {}): Response => { + const response = new Response(body ?? undefined, { + status: status ?? 400, + statusText: statusText ?? "Bad Request", + headers: + headers ?? + new Headers({ + "Content-Type": PROBLEM_DETAILS_MIME, + }), + }); + jest + .spyOn(response, "url", "get") + .mockReturnValue(responseUrl ?? "https://example.org/resource"); + return response; +}; + +describe("ClientHttpError", () => { + it("creates an object with the appropriate response getter", () => { + const response = mockResponse(); + const { problemDetails } = mockProblemDetails({}); + const error = new ClientHttpError( + response, + JSON.stringify(problemDetails), + "Some error message", + ); + expect(error instanceof InruptClientError).toBe(true); + expect(hasErrorResponse(error)).toBe(true); + expect(error.response.status).toStrictEqual(response.status); + expect(error.response.statusText).toStrictEqual(response.statusText); + expect(error.response.headers).toStrictEqual(response.headers); + expect(error.response.url).toStrictEqual(response.url); + expect(error.response.body).toStrictEqual(JSON.stringify(problemDetails)); + }); + + it("creates an object with the appropriate problemDetails getter", () => { + const response = mockResponse(); + const { problemDetails: mockedProblemDetails } = mockProblemDetails({ + detail: "Some details.", + instance: new URL("https://example.org/instance"), + }); + const error = new ClientHttpError( + response, + JSON.stringify(mockedProblemDetails), + "Some error message", + ); + expect(error instanceof InruptClientError).toBe(true); + expect(hasProblemDetails(error)).toBe(true); + expect(error.problemDetails.status).toStrictEqual( + mockedProblemDetails.status, + ); + expect(error.problemDetails.title).toStrictEqual( + mockedProblemDetails.title, + ); + expect(error.problemDetails.type).toStrictEqual(mockedProblemDetails.type); + expect(error.problemDetails.detail).toStrictEqual( + mockedProblemDetails.detail, + ); + expect(error.problemDetails.instance).toStrictEqual( + mockedProblemDetails.instance, + ); + expect(error.response.body).toStrictEqual( + JSON.stringify(mockedProblemDetails), + ); + }); + + it("supports optional problem details entries being absent", () => { + const response = mockResponse(); + // Note that `instance` and `detail` entries are not mocked. + const { problemDetails: mockedProblemDetails } = mockProblemDetails({}); + const error = new ClientHttpError( + response, + JSON.stringify(mockedProblemDetails), + "Some error message", + ); + expect(hasProblemDetails(error)).toBe(true); + expect(error.problemDetails.detail).toBeUndefined(); + expect(error.problemDetails.instance).toBeUndefined(); + }); + + it("makes the problem details and error response immutable", () => { + const response = mockResponse(); + const error = new ClientHttpError( + response, + JSON.stringify(mockProblemDetails({}).problemDetails), + "Some error message", + ); + const { problemDetails } = error; + expect(() => { + // @ts-expect-error the read-only value is overwritten for test purpose. + problemDetails.status = 200; + }).toThrow(); + const errorResponse = error.response; + expect(() => { + // @ts-expect-error the read-only value is overwritten for test purpose. + errorResponse.statusText = "Some other status"; + }).toThrow(); + }); + + it("creates an object with problemDetails defaults when the response does not conform to RFC9457", () => { + const response = mockResponse({ + headers: new Headers({ + "Content-Type": "text/plain", + }), + }); + const error = new ClientHttpError( + response, + "Some response body", + "Some error message", + ); + expect(error.problemDetails.status).toStrictEqual(response.status); + expect(error.problemDetails.title).toStrictEqual(response.statusText); + expect(error.problemDetails.type).toStrictEqual(DEFAULT_TYPE); + expect(error.problemDetails.detail).toBeUndefined(); + expect(error.problemDetails.instance).toBeUndefined(); + expect(error.response.body).toBe("Some response body"); + }); + + it("creates an Error object with problemDetails defaults when the response is malformed", () => { + const response = mockResponse(); + const error = new ClientHttpError( + response, + // The response body should be JSON, but it actually is a plain string. + "Not JSON", + "Some error message", + ); + expect(error.problemDetails.status).toStrictEqual(response.status); + expect(error.problemDetails.title).toStrictEqual(response.statusText); + expect(error.problemDetails.type).toStrictEqual(DEFAULT_TYPE); + expect(error.problemDetails.detail).toBeUndefined(); + expect(error.problemDetails.instance).toBeUndefined(); + expect(error.response.body).toBe("Not JSON"); + }); + + it("throws if the provided response is successful", () => { + const response = mockResponse({ + status: 200, + statusText: "OK", + }); + expect(() => { + // Constructing the object to throw. + // eslint-disable-next-line no-new + new ClientHttpError(response, "Not important", "Some error message"); + }).toThrow(InruptClientError); + }); + + it("supports relative URLs for type and instance", () => { + const responseUrl = "https://example.org/resource"; + const relativeTypeUrl = "/type"; + const relativeInstanceUrl = "/instance"; + const response = mockResponse(); + const { problemDetails: mockedProblemDetails } = mockProblemDetails({}); + const error = new ClientHttpError( + response, + JSON.stringify({ + ...mockedProblemDetails, + type: relativeTypeUrl, + instance: relativeInstanceUrl, + }), + "Some error message", + ); + expect(error.problemDetails.type.href).toStrictEqual( + new URL(relativeTypeUrl, responseUrl).href, + ); + expect(error.problemDetails.instance?.href).toStrictEqual( + new URL(relativeInstanceUrl, responseUrl).href, + ); + }); +}); diff --git a/src/http/httpError.ts b/src/http/httpError.ts new file mode 100644 index 0000000..5d6f18c --- /dev/null +++ b/src/http/httpError.ts @@ -0,0 +1,88 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import InruptClientError from "../clientError"; +import type { WithErrorResponse, ErrorResponse } from "./errorResponse"; +import type { WithProblemDetails, ProblemDetails } from "./problemDetails"; +import { buildProblemDetails } from "./problemDetails"; + +/** + * Error thrown on unsuccessful HTTP response. + * + * @example + * ```ts + * try { + * // ... + * } catch (e) { + * if (e instanceof ClientHttpError) { + * // e.response and e.problemDetails can be accessed safely. + * } + * } + * ``` + * + * @since unreleased + */ +export class ClientHttpError + extends InruptClientError + implements WithErrorResponse, WithProblemDetails +{ + private errorResponse: ErrorResponse; + + private details: ProblemDetails; + + constructor( + responseMetadata: { + status: number; + statusText: string; + headers: Headers; + url: string; + }, + responseBody: string, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + if (responseMetadata.status >= 200 && responseMetadata.status < 400) { + throw new InruptClientError( + `A ClientHttpError cannot be built from a success response, got ${responseMetadata.status} ${responseMetadata.statusText}`, + ); + } + this.errorResponse = Object.freeze({ + status: responseMetadata.status, + statusText: responseMetadata.statusText, + headers: responseMetadata.headers, + url: responseMetadata.url, + body: responseBody, + ok: false, + }); + this.details = buildProblemDetails(this.errorResponse); + } + + get response() { + return this.errorResponse; + } + + get problemDetails() { + return this.details; + } +} + +export default ClientHttpError; diff --git a/src/http/problemDetails.mock.ts b/src/http/problemDetails.mock.ts new file mode 100644 index 0000000..8b72338 --- /dev/null +++ b/src/http/problemDetails.mock.ts @@ -0,0 +1,49 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import type { WithProblemDetails } from "./problemDetails"; +import { DEFAULT_TYPE } from "./problemDetails"; + +export function mockProblemDetails({ + type, + status, + title, + detail, + instance, +}: { + type?: URL | null; + status?: number | null; + title?: string | null; + detail?: string; + instance?: URL; +}): WithProblemDetails { + return { + problemDetails: { + type: type === null ? undefined : type ?? DEFAULT_TYPE, + status: status === null ? undefined : status ?? 400, + title: title === null ? undefined : title ?? "Bad Request", + detail, + instance, + }, + // The type assertion allows us to null fields for unit tests. + } as WithProblemDetails; +} + +export default mockProblemDetails; diff --git a/src/http/problemDetails.test.ts b/src/http/problemDetails.test.ts new file mode 100644 index 0000000..9cc2a1c --- /dev/null +++ b/src/http/problemDetails.test.ts @@ -0,0 +1,34 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import { describe, it, expect } from "@jest/globals"; +import { hasProblemDetails } from "./problemDetails"; +import { mockProblemDetails } from "./problemDetails.mock"; + +describe("hasProblemDetails", () => { + it("validates a correct problem details", () => { + expect(hasProblemDetails(mockProblemDetails({}))).toBe(true); + }); + it("does not validate a problem details missing required fields", () => { + expect(hasProblemDetails(mockProblemDetails({ type: null }))).toBe(false); + expect(hasProblemDetails(mockProblemDetails({ title: null }))).toBe(false); + expect(hasProblemDetails(mockProblemDetails({ status: null }))).toBe(false); + }); +}); diff --git a/src/http/problemDetails.ts b/src/http/problemDetails.ts new file mode 100644 index 0000000..085f33b --- /dev/null +++ b/src/http/problemDetails.ts @@ -0,0 +1,188 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import type { ErrorResponse } from "./errorResponse"; + +/** + * The Problem Details MIME type documented in {@link https://www.rfc-editor.org/rfc/rfc9457}. + * + * @since unreleased + */ +export const PROBLEM_DETAILS_MIME = "application/problem+json"; +/** + * The default type problem documented in {@link https://www.rfc-editor.org/rfc/rfc9457#name-aboutblank}. + * + * @since unreleased + */ +export const DEFAULT_TYPE = new URL("about:blank"); + +/** + * Structured representation of the issue underlying an error response + * from an HTTP API. + * + * @since unreleased + */ +export type ProblemDetails = Readonly<{ + /** + * The problem type + * @defaultValue {@link DEFAULT_TYPE} + */ + type: URL; + /** + * A short description of the problem. + */ + title: string; + /** + * The error response status code. + */ + status: number; + /** + * A longer description of the problem. + */ + detail?: string; + /** + * A unique URL identifying the problem occurrence. + */ + instance?: URL; +}>; + +/** + * Extension to an Error thrown on an unsuccessful HTTP response + * to link to a {@link ProblemDetails} instance. + * + * @since unreleased + */ +export interface WithProblemDetails { + /** + * The {@link ProblemDetails} instance. + */ + problemDetails: ProblemDetails; +} + +function isUrl(url: unknown | URL): url is URL { + return typeof url === "object" && typeof (url as URL).href === "string"; +} + +function isProblemDetails( + problem: unknown | ProblemDetails, +): problem is ProblemDetails { + const hasDetail = + typeof (problem as ProblemDetails).detail === "undefined" || + typeof (problem as ProblemDetails).detail === "string"; + const hasInstance = + typeof (problem as ProblemDetails).instance === "undefined" || + isUrl((problem as ProblemDetails).instance); + return ( + isUrl((problem as ProblemDetails).type) && + typeof (problem as ProblemDetails).title === "string" && + typeof (problem as ProblemDetails).status === "number" && + hasDetail && + hasInstance + ); +} + +/** + * Type guard which, given an Error, checks whether it has a `problemDetails` + * field conform to the {@link ProblemDetails} type. + * + * @example + * ``` + * try { + * // ... + * } catch (e) { + * if (hasProblemDetails(e)) { + * // e.problemDetails can safely be accessed. + * } + * } + * ``` + * + * @alpha + * @since unreleased + * @param error the error being checked. + * @returns whether the error has problem details attached. + */ +export function hasProblemDetails( + error: Error | WithProblemDetails, +): error is WithProblemDetails { + return isProblemDetails((error as WithProblemDetails).problemDetails); +} + +function asUrl(url: string | undefined, base: string): URL | undefined { + if (url !== undefined) { + try { + return new URL(url, base); + } catch { + /* no op */ + } + } + return undefined; +} + +/** + * Builds a {@link ProblemDetails} object from an {@link ErrorResponse}. If the response + * is a valid {@link https://www.rfc-editor.org/rfc/rfc9457} response, values for the + * {@link ProblemDetails} object are parsed from it. Otherwise, some values are taken + * from the response metadata (status, status text), and defaults are applied. + * + * @internal + * @param response the error response. + * @returns a {@link ProblemDetails} object derived from the response. + */ +export function buildProblemDetails(response: ErrorResponse): ProblemDetails { + let type: URL | undefined; + let title: string | undefined; + let status: number | undefined; + let detail: string | undefined; + let instance: URL | undefined; + + if (response.headers.get("Content-Type") === PROBLEM_DETAILS_MIME) { + try { + const responseBody = JSON.parse(response.body); + const responseType = asUrl(responseBody.type, response.url); + if (responseType !== undefined) { + type = responseType; + } + if (typeof responseBody.title === "string") { + title = responseBody.title; + } + if (typeof responseBody.status === "number") { + status = responseBody.status; + } + if (typeof responseBody.detail === "string") { + detail = responseBody.detail; + } + const responseInstance = asUrl(responseBody.instance, response.url); + if (responseInstance !== undefined) { + instance = responseInstance; + } + } catch { + // In case of error, default values are applied. + } + } + + return Object.freeze({ + type: type ?? DEFAULT_TYPE, + title: title ?? response.statusText, + status: status ?? response.status, + detail, + instance, + }); +} diff --git a/src/index.ts b/src/index.ts index b8d5213..5fb82e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,4 +19,18 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -// Export public API from here +export { InruptClientError } from "./clientError"; +export { ClientHttpError } from "./http/httpError"; +export { + DEFAULT_TYPE, + PROBLEM_DETAILS_MIME, + type ProblemDetails, + type WithProblemDetails, + hasProblemDetails, +} from "./http/problemDetails"; +export { + type ResponseMetadata, + type ErrorResponse, + type WithErrorResponse, + hasErrorResponse, +} from "./http/errorResponse"; diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 75a19e2..8205f87 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,5 +1,8 @@ { "extends": "./tsconfig.json", "include": ["**/*.js", "**/*.ts", "**/*.md", "**/*.yml", "**/*.json", ".eslintrc.js"], - "exclude": ["node_modules"], + // Although we do not want our browser-based end-to-end tests from /e2e/browser + // to be compiled as part of solid-client, we do want to run ESLint over them. + // Thus, we override the `exclude` property of the tsconfig.json that we extend. + "exclude": ["**/node_modules", "**/dist", "**/docs"] } diff --git a/tsconfig.json b/tsconfig.json index 87912c0..b1a835e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,13 +2,13 @@ "compilerOptions": { "target": "es2020", "module": "commonjs", - "lib": ["es6", "dom"], + "lib": ["es2022", "dom"], "declaration": true, "outDir": "dist", "rootDir": "src", "strict": true, // https://github.com/microsoft/TypeScript/wiki/Performance#controlling-types-inclusion: - "types": ["jest"], + "types": [], "esModuleInterop": true, // Prevent developers on different OSes from running into problems: "forceConsistentCasingInFileNames": true, @@ -26,7 +26,11 @@ "theme": "markdown", "readme": "none", "entryDocument": "index.rst", - "plugin": ["typedoc-plugin-markdown"] + "plugin": [ + "typedoc-plugin-markdown", + "@typhonjs-typedoc/ts-lib-docs/typedoc/ts-links/dom/2023", + "@typhonjs-typedoc/ts-lib-docs/typedoc/ts-links/esm/2023" + ] }, "include": ["src/**/*.ts"], "exclude": [ diff --git a/tsdoc.json b/tsdoc.json new file mode 100644 index 0000000..a922531 --- /dev/null +++ b/tsdoc.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "tagDefinitions": [ + { + "tagName": "@since", + "syntaxKind": "block" + } + ] +}