Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(graphql): added Error Handler that will throw GraphQL errors #6506

Merged
5 changes: 5 additions & 0 deletions .changeset/clever-bikes-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@refinedev/graphql": minor
---

Added error handler that will throw GraphQL errors via Tanstack query from urql
52 changes: 42 additions & 10 deletions documentation/docs/data/packages/graphql/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,21 +290,51 @@ export const PostCreatePage () => {
}
```

## Authentication
### Handling Errors

When using GraphQL there are two kinds of errors we may see. Network errors and GraphQL errors from the API. URQL provides a _CombinedError_ class which holds these errors when encountered. The Refine GraphQL data provider package hooks identified these errors and provides them back along with a Refine specific error type for missing information. Each error type is prefixed with the `[type]` so that the you can determine how they can be handled.

- **Code errors** `[Code]`: Error is when a required parameter such as a Query or a Mutation has not been provided
- **Network errors** `[Network]`: Contains any error that would prevent making the network request.
- **GraphQL errors** `[GraphQL]` : Any errors recevied from the errors array from the GraphQL API, converted into a string.

The errors are provided through the Notification provider if used or through the Refine's error handlers.

If your API uses authentication, you can easily provide a custom fetcher for the requests and handle the authentication logic there. When creating a GraphQL Client, you can pass a `fetch` function to the client options. This function will be used to append the authentication headers to the requests.
### Managing Retries

TBA: https://commerce.nearform.com/open-source/urql/docs/advanced/authentication/
Refine uses Tanstack Query which by default retries the API call 3 times before showing the error to the user. For `[Code]` or `[GraphQL]` errors, these retries can be avoided, and the users would only need this for `[Network]` type errors using the `queryOptions` parameter passed into the data fetch hooks using a function call can help prevent non-required API calls.

```tsx
useList({
meta: {
gqlQuery: GET_LIST_QUERY,
},
// highlight-start
queryOptions: {
retry(failureCount, error) {
// failureCount provides the number of times the request has failed
// error is the error thrown from the GraphQL provider
return error?.message.includes("[Network]") && failureCount <= 3;
},
},
// highlight-end
});
```

## Authentication

If your API uses authentication, the easiest way of passing in the token through the header is via the **fetchOptions** parameter passed into the GraphQL Client.

```tsx title="data-provider.tsx"
import graphqlDataProvider, { GraphQLClient } from "@refinedev/graphql";
import createDataProvider from "@refinedev/graphql";
import { Client, fetchExchange } from "urql";

const client = new GraphQLClient(API_URL, {
fetch: (url: string, options: RequestInit) => {
return fetch(url, {
...options,
export const client = new Client({
url: API_URL,
exchanges: [fetchExchange],
fetchOptions: () => {
return {
headers: {
...options.headers,
/**
* For demo purposes, we're using `localStorage` to access the token.
* You can use your own authentication logic here.
Expand All @@ -313,7 +343,7 @@ const client = new GraphQLClient(API_URL, {
// highlight-next-line
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
};
},
});

Expand All @@ -323,6 +353,8 @@ const client = new GraphQLClient(API_URL, {
const dataProvider = graphqlDataProvider(client);
```

For more advanced authentication requirements you can use the urql _authExchange_ as documented in https://commerce.nearform.com/open-source/urql/docs/advanced/authentication

## Usage with Inferencer

You can also use `@refinedev/inferencer` package to generate sample codes for your views. Since the GraphQL data providers rely on `meta` fields, you'll need to provide some `meta` values beforehand and then Inferencer will use these values to infer the fields of the data provider's response, generate a code and a preview.
Expand Down
85 changes: 73 additions & 12 deletions packages/graphql/src/dataProvider/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BaseRecord, CustomParams, DataProvider } from "@refinedev/core";
import type { Client } from "@urql/core";
import type { Client, CombinedError } from "@urql/core";
import { isMutation } from "../utils";
import { defaultOptions, type GraphQLDataProviderOptions } from "./options";
import dm from "deepmerge";
Expand All @@ -10,20 +10,41 @@ const createDataProvider = (
): Required<DataProvider> => {
const options = dm(defaultOptions, baseOptions);

const errorHandler = (error: CombinedError | undefined): string => {
let errorMsg = "";

if (error?.networkError) {
errorMsg = `[Network] ${JSON.stringify(error?.networkError)}`;
}

if (error?.graphQLErrors) {
const message = error.graphQLErrors
.map(({ message }) => message)
.join(", ");
errorMsg = `[GraphQL] ${message}`;
}

return errorMsg;
};

return {
create: async (params) => {
const { meta } = params;

const gqlOperation = meta?.gqlMutation ?? meta?.gqlQuery;

if (!gqlOperation) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

const response = await client
.mutation(gqlOperation, options.create.buildVariables(params))
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

const data = options.create.dataMapper(response, params);

return {
Expand All @@ -36,14 +57,18 @@ const createDataProvider = (
const gqlOperation = meta?.gqlMutation ?? meta?.gqlQuery;

if (!gqlOperation) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

const response = await client.mutation<BaseRecord>(
gqlOperation,
options.createMany.buildVariables(params),
);

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return {
data: options.createMany.dataMapper(response, params),
};
Expand All @@ -54,7 +79,7 @@ const createDataProvider = (
const gqlOperation = meta?.gqlQuery ?? meta?.gqlMutation;

if (!gqlOperation) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

let query = gqlOperation;
Expand All @@ -67,6 +92,10 @@ const createDataProvider = (
.query(query, options.getOne.buildVariables(params))
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response.error));
}

return {
data: options.getOne.dataMapper(response, params),
};
Expand All @@ -75,13 +104,17 @@ const createDataProvider = (
const { meta } = params;

if (!meta?.gqlQuery) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

const variables = options.getList.buildVariables(params);

const response = await client.query(meta.gqlQuery, variables).toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return {
data: options.getList.dataMapper(response, params),
total: options.getList.getTotalCount(response, params),
Expand All @@ -91,13 +124,17 @@ const createDataProvider = (
const { meta } = params;

if (!meta?.gqlQuery) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

const response = await client
.query(meta.gqlQuery, { filter: options.getMany.buildFilter(params) })
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return {
data: options.getMany.dataMapper(response, params),
};
Expand All @@ -107,13 +144,17 @@ const createDataProvider = (
const gqlOperation = meta?.gqlMutation ?? meta?.gqlQuery;

if (!gqlOperation) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

const response = await client
.mutation(gqlOperation, options.update.buildVariables(params))
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return {
data: options.update.dataMapper(response, params),
};
Expand All @@ -122,26 +163,34 @@ const createDataProvider = (
const { meta } = params;

if (!meta?.gqlMutation) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

const response = await client
.mutation(meta.gqlMutation, options.updateMany.buildVariables(params))
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return { data: options.updateMany.dataMapper(response, params) };
},
deleteOne: async (params) => {
const { meta } = params;

if (!meta?.gqlMutation) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

const response = await client
.mutation(meta.gqlMutation, options.deleteOne.buildVariables(params))
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return {
data: options.deleteOne.dataMapper(response, params),
};
Expand All @@ -150,13 +199,17 @@ const createDataProvider = (
const { meta } = params;

if (!meta?.gqlMutation) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

const response = await client
.mutation(meta.gqlMutation, options.deleteMany.buildVariables(params))
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return {
data: options.deleteMany.dataMapper(response, params),
};
Expand All @@ -167,7 +220,7 @@ const createDataProvider = (
const url = params.url !== "" ? params.url : undefined;

if (!meta?.gqlMutation && !meta?.gqlQuery) {
throw new Error("Operation is required.");
throw new Error("[Code] Operation is required.");
}

if (meta?.gqlMutation) {
Expand All @@ -179,6 +232,10 @@ const createDataProvider = (
)
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return { data: options.custom.dataMapper(response, params) };
}

Expand All @@ -190,10 +247,14 @@ const createDataProvider = (
)
.toPromise();

if (response?.error) {
throw new Error(errorHandler(response?.error));
}

return { data: options.custom.dataMapper(response, params) };
},
getApiUrl: () => {
throw Error("Not implemented on refine-graphql data provider.");
throw Error("[Code] Not implemented on refine-graphql data provider.");
},
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/graphql/test/create/create.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("create", () => {
it("throws error", async () => {
expect(
dataProvider(client).create({ resource: "blogPosts", variables: {} }),
).rejects.toEqual(new Error("Operation is required."));
).rejects.toEqual(new Error("[Code] Operation is required."));
});
});
});
2 changes: 1 addition & 1 deletion packages/graphql/test/createMany/createMany.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("createMany", () => {
resource: "blogPosts",
variables: [],
}),
).rejects.toEqual(new Error("Operation is required."));
).rejects.toEqual(new Error("[Code] Operation is required."));
});
});
});
23 changes: 13 additions & 10 deletions packages/graphql/test/custom/custom.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,27 @@ nock("https://api.crm.refine.dev:443", { encodedQueryParams: true })
variables: {},
})
.reply(
400,
200,
{
errors: [
{
message: 'Cannot query field "blogPost" on type "Query".',
locations: [{ line: 3, column: 5 }],
extensions: { code: "GRAPHQL_VALIDATION_FAILED" },
data: {
blogPost: {
id: "113",
title: "Updated Title 3",
content:
"Pariatur est corporis necessitatibus quos consequuntur nostrum. Libero nesciunt delectus sunt eligendi ullam doloribus ratione. Rem dolore odio.\nLaudantium ea quis ut fuga minus molestias facilis laudantium. Hic ut nisi possimus natus asperiores aspernatur. Vel alias placeat ipsum.\nSuscipit quis blanditiis tempora consequatur veniam nam voluptatibus accusamus. Eum dolores sunt eius aperiam perferendis autem eligendi optio perspiciatis. Culpa corrupti nobis incidunt non.",
status: "REJECTED",
category: { id: "23" },
},
],
},
},
{
"access-control-allow-origin": "*",
"cache-control": "no-store",
connection: "keep-alive",
"content-length": "164",
"content-length": "593",
"content-type": "application/graphql-response+json; charset=utf-8",
date: "Wed, 09 Oct 2024 11:55:39 GMT",
etag: 'W/"a4-vSpSYZ0XC1WfMxDhM5qamiBEZ6g"',
date: "Wed, 09 Oct 2024 11:37:18 GMT",
etag: 'W/"251-G8+P5DwQ2zKsMvBGJrZiDiszAEk"',
"strict-transport-security": "max-age=15724800; includeSubDomains",
"x-powered-by": "Express",
},
Expand Down
6 changes: 2 additions & 4 deletions packages/graphql/test/custom/custom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,15 @@ describe("custom", () => {
meta: { gqlQuery },
});

expect(data).toEqual(
'[GraphQL] Cannot query field "blogPost" on type "Query".',
);
expect(data.blogPost).toBeInstanceOf(Object);
});
});

describe("when operation is not provided", () => {
it("throws error", () => {
expect(
dataProvider(client).custom({ url: "", method: "get" }),
).rejects.toEqual(new Error("Operation is required."));
).rejects.toEqual(new Error("[Code] Operation is required."));
});
});
});
Loading
Loading