diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5946f6a..cd07744 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,67 +1,67 @@ -name: CI -on: [push, pull_request] -jobs: - build: - name: test deno ${{ matrix.deno }} ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 5 - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macOS-latest] - deno: [v1.x, canary] - fail-fast: true - steps: - - name: Clone repository - uses: actions/checkout@v4 - - name: Setup deno - uses: denoland/setup-deno@main - with: - deno-version: ${{ matrix.deno }} - - name: Check formatting - if: matrix.os == 'ubuntu-latest' - run: deno fmt --check - - name: Check linting - if: matrix.os == 'ubuntu-latest' - run: deno lint - - name: Run tests - run: deno test --coverage=cov - - name: Run tests unstable - run: deno test --unstable - - name: Generate lcov - if: | - matrix.os == 'ubuntu-latest' && - matrix.deno == 'v1.x' - run: deno coverage --lcov cov > cov.lcov - - name: Upload coverage - if: | - matrix.os == 'ubuntu-latest' && - matrix.deno == 'v1.x' - uses: codecov/codecov-action@v4 - with: - fail_ci_if_error: true - files: cov.lcov - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - name: Release info - if: | - github.repository == 'udibo/http-error' && - matrix.os == 'ubuntu-latest' && - matrix.deno == 'v1.x' && - startsWith(github.ref, 'refs/tags/') - shell: bash - run: | - echo "RELEASE_VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV - - name: Bundle - if: env.RELEASE_VERSION != '' - run: | - mkdir -p target/release - deno bundle mod.ts target/release/http-error.${RELEASE_VERSION}.js - - name: Release - uses: softprops/action-gh-release@v1 - if: env.RELEASE_VERSION != '' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - draft: true - files: | - target/release/http-error.${{ env.RELEASE_VERSION }}.js +name: CI +on: [push, pull_request] +jobs: + build: + name: test deno ${{ matrix.deno }} ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macOS-latest] + deno: [v2.x, canary] + fail-fast: true + steps: + - name: Clone repository + uses: actions/checkout@v4 + - name: Setup deno + uses: denoland/setup-deno@main + with: + deno-version: ${{ matrix.deno }} + - name: Check formatting + if: matrix.os == 'ubuntu-latest' + run: deno fmt --check + - name: Check linting + if: matrix.os == 'ubuntu-latest' + run: deno lint + - name: Run tests + run: deno test --coverage=cov + - name: Run tests unstable + run: deno test --unstable + - name: Generate lcov + if: | + matrix.os == 'ubuntu-latest' && + matrix.deno == 'v2.x' + run: deno coverage --lcov cov > cov.lcov + - name: Upload coverage + if: | + matrix.os == 'ubuntu-latest' && + matrix.deno == 'v2.x' + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + files: cov.lcov + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Release info + if: | + github.repository == 'udibo/http-error' && + matrix.os == 'ubuntu-latest' && + matrix.deno == 'v2.x' && + startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + echo "RELEASE_VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV + - name: Bundle + if: env.RELEASE_VERSION != '' + run: | + mkdir -p target/release + deno bundle mod.ts target/release/http-error.${RELEASE_VERSION}.js + - name: Release + uses: softprops/action-gh-release@v1 + if: env.RELEASE_VERSION != '' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + draft: true + files: | + target/release/http-error.${{ env.RELEASE_VERSION }}.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 6ec7152..3da0161 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,7 @@ { "deno.enable": true, "deno.lint": true, - "deno.unstable": false, - "deno.config": "./deno.jsonc", - "files.associations": { - "*.css": "tailwindcss" - }, + "deno.config": "./deno.json", "editor.formatOnSave": true, "editor.defaultFormatter": "denoland.vscode-deno", "editor.quickSuggestions": { diff --git a/README.md b/README.md index ccd4ec8..e6dd860 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,21 @@ ErrorResponse, it would be converted into an HttpError object and be thrown. ```ts import { ErrorResponse, HttpError, isErrorResponse } from "@udibo/http-error"; +async function getMovies() { + const response = await fetch("https://example.com/movies.json"); + if (!response.ok) throw new ErrorResponse.toError(movies); + return await response.json(); +} +``` + +The next example is similar, but uses the response JSON instead of the response. +The advantage of the first approach in the previous example is that it will +produce an HttpError based on the status code in the case that the response +doesn't have valid JSON. + +```ts +import { ErrorResponse, HttpError, isErrorResponse } from "@udibo/http-error"; + async function getMovies() { const response = await fetch("https://example.com/movies.json"); const movies = await response.json(); diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..d32606a --- /dev/null +++ b/deno.json @@ -0,0 +1,30 @@ +{ + "name": "@udibo/http-error", + "version": "0.9.0", + "exports": { + ".": "./mod.ts" + }, + "publish": { + "include": [ + "LICENSE", + "README.md", + "**/*.ts" + ], + "exclude": ["**/*.test.ts"] + }, + "imports": { + "@std/assert": "jsr:@std/assert@1", + "@std/http": "jsr:@std/http@1", + "@std/testing": "jsr:@std/testing@1" + }, + "tasks": { + "check": { + "description": "Checks the formatting and runs the linter.", + "command": "deno lint && deno fmt --check" + }, + "git-rebase": { + "description": "Gets your branch up to date with master after a squash merge.", + "command": "git fetch origin main && git rebase --onto origin/main HEAD" + } + } +} diff --git a/deno.jsonc b/deno.jsonc deleted file mode 100644 index a3f7838..0000000 --- a/deno.jsonc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@udibo/http-error", - "version": "0.8.2", - "exports": { - ".": "./mod.ts" - }, - "publish": { - "include": [ - "LICENSE", - "README.md", - "**/*.ts" - ], - "exclude": ["**/*.test.ts"] - }, - "imports": { - "@std/assert": "jsr:@std/assert@1", - "@std/http": "jsr:@std/http@0", - "@std/testing": "jsr:@std/testing@0" - }, - "tasks": { - // Checks the formatting and runs the linter. - "check": "deno lint && deno fmt --check", - // Gets your branch up to date with master after a squash merge. - "git-rebase": "git fetch origin main && git rebase --onto origin/main HEAD" - } -} diff --git a/deno.lock b/deno.lock index 1ab060f..e93d81d 100644 --- a/deno.lock +++ b/deno.lock @@ -1,43 +1,38 @@ { - "version": "3", - "packages": { - "specifiers": { - "jsr:@std/assert@1": "jsr:@std/assert@1.0.0", - "jsr:@std/http@0": "jsr:@std/http@0.224.5", - "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1", - "jsr:@std/testing@0": "jsr:@std/testing@0.225.3" + "version": "4", + "specifiers": { + "jsr:@std/assert@1": "1.0.12", + "jsr:@std/assert@^1.0.12": "1.0.12", + "jsr:@std/http@1": "1.0.13", + "jsr:@std/internal@^1.0.6": "1.0.6", + "jsr:@std/testing@1": "1.0.10" + }, + "jsr": { + "@std/assert@1.0.12": { + "integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/http@1.0.13": { + "integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e" }, - "jsr": { - "@std/assert@1.0.0": { - "integrity": "0e4f6d873f7f35e2a1e6194ceee39686c996b9e5d134948e644d35d4c4df2008", - "dependencies": [ - "jsr:@std/internal@^1.0.1" - ] - }, - "@std/http@0.224.5": { - "integrity": "b03b5d1529f6c423badfb82f6640f9f2557b4034cd7c30655ba5bb447ff750a4" - }, - "@std/internal@1.0.1": { - "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" - }, - "@std/testing@0.225.3": { - "integrity": "348c24d0479d44ab3dbb4f26170f242e19f24051b45935d4a9e7ca0ab7e37780" - } + "@std/internal@1.0.6": { + "integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4" + }, + "@std/testing@1.0.10": { + "integrity": "8997bd0b0df020b81bf5eae103c66622918adeff7e45e96291c92a29dbf82cc1", + "dependencies": [ + "jsr:@std/assert@^1.0.12", + "jsr:@std/internal" + ] } }, - "remote": { - "https://deno.land/std@0.192.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", - "https://deno.land/std@0.192.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", - "https://deno.land/std@0.192.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.192.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", - "https://deno.land/std@0.192.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f", - "https://deno.land/std@0.192.0/testing/bdd.ts": "59f7f7503066d66a12e50ace81bfffae5b735b6be1208f5684b630ae6b4de1d0" - }, "workspace": { "dependencies": [ "jsr:@std/assert@1", - "jsr:@std/http@0", - "jsr:@std/testing@0" + "jsr:@std/http@1", + "jsr:@std/testing@1" ] } } diff --git a/mod.test.ts b/mod.test.ts index fb25516..f4b08b3 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -584,6 +584,45 @@ it(ErrorResponseTests, "from external HttpError", () => { const toErrorTests = describe(ErrorResponseTests, "toError"); +it(toErrorTests, "with text response", async () => { + const response = new Response("oops", { status: 400 }); + const error = await ErrorResponse.toError(response); + assertEquals(error.toString(), "BadRequestError"); + assertEquals(error.name, "BadRequestError"); + assertEquals(error.message, ""); + assertEquals(error.status, 400); + assertEquals(error.expose, true); + assertEquals(error.cause, undefined); +}); + +it(toErrorTests, "with error response", async () => { + const response = new Response( + JSON.stringify({ error: { status: 400, message: "oops", custom: "data" } }), + { status: 400 }, + ); + const error = await ErrorResponse.toError(response); + assertEquals(error.toString(), "BadRequestError: oops"); + assertEquals(error.name, "BadRequestError"); + assertEquals(error.message, "oops"); + assertEquals(error.status, 400); + assertEquals(error.expose, true); + assertEquals(error.cause, undefined); + assertEquals(error.data, { custom: "data" }); +}); + +it(toErrorTests, "with json response", async () => { + const response = new Response(JSON.stringify({ message: "oops" }), { + status: 400, + }); + const error = await ErrorResponse.toError(response); + assertEquals(error.toString(), "BadRequestError: oops"); + assertEquals(error.name, "BadRequestError"); + assertEquals(error.message, "oops"); + assertEquals(error.status, 400); + assertEquals(error.expose, true); + assertEquals(error.cause, undefined); +}); + it(toErrorTests, "with internal ErrorResponse", () => { const errorResponse = new ErrorResponse(new HttpError("oops")); const error = ErrorResponse.toError(errorResponse); diff --git a/mod.ts b/mod.ts index fc52559..2b60a0f 100644 --- a/mod.ts +++ b/mod.ts @@ -12,6 +12,10 @@ export interface HttpErrorOptions extends ErrorOptions { * Defaults to true for client error statuses and false for server error statuses. */ expose?: boolean; + /** + * The cause of the error. + */ + cause?: unknown; /** * Other data associated with the error. */ @@ -448,9 +452,12 @@ export class ErrorResponse< } /** - * This function gives a client the ability to convert the error response JSON into + * This function gives a client the ability to convert the error response into * an HttpError. * + * The easiest way to convert an error response into an HttpError is to directly + * pass the response to the `ErrorResponse.toError` function. + * * In the following example, if getMovies is called and API endpoint returned an * ErrorResponse, it would be converted into an HttpError object and be thrown. * @@ -459,6 +466,21 @@ export class ErrorResponse< * * async function getMovies() { * const response = await fetch("https://example.com/movies.json"); + * if (!response.ok) throw new ErrorResponse.toError(response); + * return await response.json(); + * } + * ``` + * + * This function also supports converting error response JSON into an HttpError. + * However, it is recommended to use the first approach in the previous example as + * it will produce an HttpError based on the status code in the case that the + * response doesn't have valid JSON. + * + * ```ts + * import { ErrorResponse, HttpError, isErrorResponse } from "@udibo/http-error"; + * + * async function getMovies() { + * const response = await fetch("https://example.com/movies.json"); * const movies = await response.json(); * if (isErrorResponse(movies)) throw new ErrorResponse.toError(movies); * if (response.status >= 400) { @@ -487,10 +509,32 @@ export class ErrorResponse< * @param response - The error response to convert. * @returns An HttpError object. */ - static toError = Record>( + static toError< + T extends Record = Record, + >( response: ErrorResponse, - ): HttpError { - return new HttpError(response.error); + ): HttpError; + static toError< + T extends Record = Record, + >( + response: Response, + ): Promise>; + static toError< + T extends Record = Record, + >( + response: ErrorResponse | Response, + ): HttpError | Promise> { + if (isErrorResponse(response)) { + return new HttpError(response.error); + } else { + return response.json().then((json) => { + return isErrorResponse(json) + ? new HttpError(response.status, json.error) + : new HttpError(response.status, json.message); + }).catch(() => { + return new HttpError(response.status); + }); + } } }