Skip to content

Commit

Permalink
SDK-3311: Integrate problem details library (#1103)
Browse files Browse the repository at this point in the history
* Integrate solid-client-errors and ensure this is tested

* Add problem details handling feature to CHANGELOG.md

Co-authored-by: Zwifi <[email protected]>
  • Loading branch information
edwardsph and NSeydoux authored Aug 13, 2024
1 parent 3477255 commit 755d75c
Show file tree
Hide file tree
Showing 15 changed files with 218 additions and 107 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The following changes have been implemented but not released yet:

## Unreleased

### New feature

- Integrate @inrupt/solid-client-errors for handling HTTP errors.

## [1.0.3](https://github.com/inrupt/solid-client-vc-js/releases/tag/v1.0.3) - 2024-05-15

### Patch changes
Expand Down
4 changes: 3 additions & 1 deletion e2e/node/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ describe("End-to-end verifiable credentials tests for environment", () => {
fetch: session.fetch,
},
);
await expect(vcPromise).rejects.toThrow(/400/);
await expect(vcPromise).rejects.toThrow(
`The VC issuing endpoint [${issuerService}] could not successfully issue a VC`,
);
});
});

Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
},
"dependencies": {
"@inrupt/solid-client": "^2.0.0",
"@inrupt/solid-client-errors": "^0.0.1",
"event-emitter-promisify": "^1.1.0",
"jsonld-context-parser": "^3.0.0",
"jsonld-streaming-parser": "^3.3.0",
Expand Down
35 changes: 20 additions & 15 deletions src/common/common.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import type { IJsonLdContext } from "jsonld-context-parser";
import { DataFactory, Store } from "n3";
import { isomorphic } from "rdf-isomorphic";
import { UnauthorizedError } from "@inrupt/solid-client-errors";
import { jsonLdStringToStore } from "../parser/jsonld";
import type { VerifiableCredential } from "./common";
import {
Expand All @@ -44,6 +45,7 @@ import {
import { cred, rdf } from "./constants";
import isRdfjsVerifiableCredential from "./isRdfjsVerifiableCredential";
import isRdfjsVerifiablePresentation from "./isRdfjsVerifiablePresentation";
import { mockedFetchWithResponse } from "../tests.internal";

const { namedNode, quad, blankNode } = DataFactory;

Expand Down Expand Up @@ -529,27 +531,30 @@ describe("getVerifiableCredential", () => {
let mockedFetch: jest.MockedFunction<typeof fetch>;

beforeEach(() => {
mockedFetch = jest.fn<typeof fetch>().mockResolvedValueOnce(
new Response(undefined, {
status: 401,
statusText: "Unauthenticated",
}),
mockedFetch = mockedFetchWithResponse(
401,
`{"status": 401, "title": "Unauthorized", "detail": "Example detail"}`,
{ "Content-Type": "application/problem+json" },
);
});

it.each([[true], [false]])(
"returnLegacyJsonld: %s",
async (returnLegacyJsonld) => {
await expect(
getVerifiableCredential(
"https://example.org/ns/someCredentialInstance",
{
fetch: mockedFetch,
returnLegacyJsonld,
},
),
).rejects.toThrow(
"Fetching the Verifiable Credential [https://example.org/ns/someCredentialInstance] failed: 401 Unauthenticated",
const err: UnauthorizedError = await getVerifiableCredential(
"https://example.org/ns/someCredentialInstance",
{
fetch: mockedFetch,
returnLegacyJsonld,
},
).catch((e) => e);

expect(err).toBeInstanceOf(UnauthorizedError);
expect(err.problemDetails.status).toBe(401);
expect(err.problemDetails.title).toBe("Unauthorized");
expect(err.problemDetails.detail).toBe("Example detail");
expect(err.message).toBe(
"Fetching the Verifiable Credential [https://example.org/ns/someCredentialInstance] failed",
);
},
);
Expand Down
8 changes: 6 additions & 2 deletions src/common/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "@inrupt/solid-client";
import type { DatasetCore, Quad } from "@rdfjs/types";
import { DataFactory } from "n3";
import { handleErrorResponse } from "@inrupt/solid-client-errors";
import type { ParseOptions } from "../parser/jsonld";
import { jsonLdToStore } from "../parser/jsonld";
import isRdfjsVerifiableCredential from "./isRdfjsVerifiableCredential";
Expand Down Expand Up @@ -743,8 +744,11 @@ export async function getVerifiableCredential(
const response = await authFetch(vcUrl);

if (!response.ok) {
throw new Error(
`Fetching the Verifiable Credential [${vcUrl}] failed: ${response.status} ${response.statusText}`,
const responseBody = await response.text();
throw handleErrorResponse(
response,
responseBody,
`Fetching the Verifiable Credential [${vcUrl}] failed`,
);
}

Expand Down
33 changes: 19 additions & 14 deletions src/issue/issue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
//

import { jest, describe, it, expect } from "@jest/globals";
import { BadRequestError } from "@inrupt/solid-client-errors";
import { defaultContext, defaultCredentialTypes } from "../common/common";
import { mockDefaultCredential } from "../common/common.mock";
import { mockedFetchWithResponse } from "../tests.internal";
import { issueVerifiableCredential } from "./issue";

describe("issueVerifiableCredential", () => {
Expand Down Expand Up @@ -57,21 +59,24 @@ describe("issueVerifiableCredential", () => {
});

it("throws if the issuer returns an error", async () => {
const mockedFetch = jest.fn<typeof fetch>().mockResolvedValueOnce(
new Response(undefined, {
status: 400,
statusText: "Bad request",
}),
const mockedFetch = mockedFetchWithResponse(
400,
`{"status": 400, "title": "Bad request", "detail": "Example detail"}`,
{ "Content-Type": "application/problem+json" },
);
await expect(
issueVerifiableCredential(
"https://some.endpoint",
{ "@context": ["https://some.context"] },
{ "@context": ["https://some.context"] },
{ fetch: mockedFetch },
),
).rejects.toThrow(
/https:\/\/some\.endpoint.*could not successfully issue a VC.*400.*Bad request/,
const err: BadRequestError = await issueVerifiableCredential(
"https://some.endpoint",
{ "@context": ["https://some.context"] },
{ "@context": ["https://some.context"] },
{ fetch: mockedFetch },
).catch((e) => e);

expect(err).toBeInstanceOf(BadRequestError);
expect(err.problemDetails.status).toBe(400);
expect(err.problemDetails.title).toBe("Bad request");
expect(err.problemDetails.detail).toBe("Example detail");
expect(err.message).toMatch(
/https:\/\/some\.endpoint.*could not successfully issue a VC/,
);
});

Expand Down
9 changes: 6 additions & 3 deletions src/issue/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* @module issue
*/

import { handleErrorResponse } from "@inrupt/solid-client-errors";
import type {
Iri,
JsonLd,
Expand Down Expand Up @@ -181,9 +182,11 @@ export async function issueVerifiableCredential(
},
);
if (!response.ok) {
// TODO: use the error library when available.
throw new Error(
`The VC issuing endpoint [${issuerEndpoint}] could not successfully issue a VC: ${response.status} ${response.statusText}`,
const responseBody = await response.text();
throw handleErrorResponse(
response,
responseBody,
`The VC issuing endpoint [${issuerEndpoint}] could not successfully issue a VC`,
);
}

Expand Down
37 changes: 22 additions & 15 deletions src/lookup/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { DataFactory } from "n3";
import { NotFoundError } from "@inrupt/solid-client-errors";
import {
defaultVerifiableClaims,
mockAccessGrant,
Expand All @@ -31,6 +32,7 @@ import {
import { cred, rdf } from "../common/constants";
import type { QueryByExample } from "./query";
import { query } from "./query";
import { mockedFetchWithResponse } from "../tests.internal";

const { namedNode } = DataFactory;
const mockRequest: QueryByExample = {
Expand Down Expand Up @@ -202,22 +204,27 @@ describe("query", () => {
it.each([[true], [false]])(
"throws if the given endpoint returns an error [returnLegacyJsonld: %s]",
async (returnLegacyJsonld) => {
const mockedFetch = jest.fn<typeof fetch>(
async () =>
new Response(undefined, {
status: 404,
}),
const mockedFetch = mockedFetchWithResponse(
404,
`{"status": 404, "title": "Not found", "detail": "Example detail"}`,
{ "Content-Type": "application/problem+json" },
);
const err: NotFoundError = await query(
"https://example.org/query",
{ query: [mockRequest] },
{
fetch: mockedFetch,
returnLegacyJsonld,
},
).catch((e) => e);

expect(err).toBeInstanceOf(NotFoundError);
expect(err.problemDetails.status).toBe(404);
expect(err.problemDetails.title).toBe("Not found");
expect(err.problemDetails.detail).toBe("Example detail");
expect(err.message).toBe(
"The query endpoint [https://example.org/query] returned an error",
);
await expect(() =>
query(
"https://example.org/query",
{ query: [mockRequest] },
{
fetch: mockedFetch,
returnLegacyJsonld,
},
),
).rejects.toThrow();
},
);

Expand Down
16 changes: 10 additions & 6 deletions src/lookup/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import type { DatasetCore } from "@rdfjs/types";
import { DataFactory } from "n3";
import { handleErrorResponse } from "@inrupt/solid-client-errors";
import type {
DatasetWithId,
Iri,
Expand Down Expand Up @@ -91,14 +92,14 @@ export type MinimalPresentation = {
/**
* Send a Verifiable Presentation Request to a query endpoint in order to retrieve
* all Verifiable Credentials matching the query, wrapped in a single Presentation.
*
*
* @example The following shows how to query for credentials of a certain type. Adding
* a reason to the request is helpful when interacting with a user. The resulting
* Verifiable Presentation will wrap zero or more Verifiable Credentials.
*
*
* ```
* const verifiablePresentation = await query(
"https://example.org/query", {
"https://example.org/query", {
query: [{
type: "QueryByExample",
credentialQuery: [
Expand Down Expand Up @@ -175,8 +176,11 @@ export async function query(
body: JSON.stringify(vpRequest),
});
if (!response.ok) {
throw new Error(
`The query endpoint [${queryEndpoint}] returned an error: ${response.status} ${response.statusText}`,
const responseBody = await response.text();
throw handleErrorResponse(
response,
responseBody,
`The query endpoint [${queryEndpoint}] returned an error`,
);
}

Expand Down Expand Up @@ -292,7 +296,7 @@ export async function query(
.map(async (_vc: VerifiableCredentialBase) => {
let vc = _vc;
if (typeof vc !== "object" || vc === null) {
throw new Error(`Verifiable Credentail is an invalid object`);
throw new Error(`Verifiable Credential is an invalid object`);
}

if (options.normalize) {
Expand Down
29 changes: 17 additions & 12 deletions src/revoke/revoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
//

import { jest, it, describe, expect } from "@jest/globals";
import { BadRequestError } from "@inrupt/solid-client-errors";
import defaultRevokeVerifiableCredential, {
revokeVerifiableCredential,
} from "./revoke";
import { mockedFetchWithResponse } from "../tests.internal";

const spiedFetch = jest.spyOn(globalThis, "fetch").mockImplementation(() => {
throw new Error("Unexpected fetch call");
Expand Down Expand Up @@ -59,19 +61,22 @@ describe("revokeVerifiableCredential", () => {
});

it("throws if the issuer returns an error", async () => {
const mockedFetch = jest.fn<typeof fetch>().mockResolvedValue(
new Response(undefined, {
status: 400,
statusText: "Bad request",
}),
const mockedFetch = mockedFetchWithResponse(
400,
`{"status": 400, "title": "Bad request", "detail": "Example detail"}`,
{ "Content-Type": "application/problem+json" },
);
await expect(
revokeVerifiableCredential(
"https://some.endpoint",
"https://some.example#credential",
{ fetch: mockedFetch },
),
).rejects.toThrow(/some.endpoint.*400.*Bad request/);
const err: BadRequestError = await revokeVerifiableCredential(
"https://some.endpoint",
"https://some.example#credential",
{ fetch: mockedFetch },
).catch((e) => e);

expect(err).toBeInstanceOf(BadRequestError);
expect(err.problemDetails.status).toBe(400);
expect(err.problemDetails.title).toBe("Bad request");
expect(err.problemDetails.detail).toBe("Example detail");
expect(err.message).toMatch(/https:\/\/some\.endpoint.*returned an error/);
});

it("sends an appropriate revocation request to the issuer", async () => {
Expand Down
8 changes: 6 additions & 2 deletions src/revoke/revoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
/**
* @module revoke
*/
import { handleErrorResponse } from "@inrupt/solid-client-errors";
import type { Iri } from "../common/common";

/**
Expand Down Expand Up @@ -65,8 +66,11 @@ export async function revokeVerifiableCredential(
}),
});
if (!response.ok) {
throw new Error(
`The issuer [${issuerEndpoint}] returned an error: ${response.status} ${response.statusText}`,
const responseBody = await response.text();
throw handleErrorResponse(
response,
responseBody,
`The issuer [${issuerEndpoint}] returned an error`,
);
}
}
Expand Down
Loading

0 comments on commit 755d75c

Please sign in to comment.