Skip to content

Commit

Permalink
feat: open api client errors (#1582)
Browse files Browse the repository at this point in the history
* feat: error interceptor refactor

* refactor: consistent error displaying
  • Loading branch information
mkucmus authored Jul 19, 2021
1 parent ed564b5 commit c8b7c83
Show file tree
Hide file tree
Showing 57 changed files with 514 additions and 288 deletions.
8 changes: 6 additions & 2 deletions api/composables.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { SessionContext } from '@shopware-pwa/commons/interfaces/response/Sessio
import { ShippingAddress } from '@shopware-pwa/commons/interfaces/models/checkout/customer/ShippingAddress';
import { ShippingMethod } from '@shopware-pwa/commons/interfaces/models/checkout/shipping/ShippingMethod';
import { ShopwareApiInstance } from '@shopware-pwa/shopware-6-client';
import { ShopwareError } from '@shopware-pwa/commons/interfaces/errors/ApiError';
import { ShopwareSearchParams } from '@shopware-pwa/commons/interfaces/search/SearchCriteria';
import { Sort } from '@shopware-pwa/commons/interfaces/search/SearchCriteria';
import { StoreNavigationElement } from '@shopware-pwa/commons/interfaces/models/content/navigation/Navigation';
Expand Down Expand Up @@ -379,8 +380,11 @@ export interface IUseUser {
error: Ref<any>;
// (undocumented)
errors: UnwrapRef<{
login: string;
register: string[];
login: ShopwareError[];
register: ShopwareError[];
resetPassword: ShopwareError[];
updatePassword: ShopwareError[];
updateEmail: ShopwareError[];
}>;
// (undocumented)
getOrderDetails: (orderId: string) => Promise<Order | undefined>;
Expand Down
2 changes: 1 addition & 1 deletion api/helpers.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function getListingAvailableFilters(aggregations: Aggregations | undefine
// @beta (undocumented)
export function getListingFilters(aggregations: Aggregations | undefined | null): ListingFilter[];

// @alpha
// @alpha @deprecated
export function getMessagesFromErrorsArray(errors: ShopwareError[]): string[];

// @alpha
Expand Down
1 change: 1 addition & 0 deletions docs/.vuepress/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ module.exports = {
"/landing/concepts/payment",
"/landing/concepts/snippets",
"/landing/concepts/interceptor",
"/landing/concepts/api-client-errors",
],
},
{
Expand Down
6 changes: 6 additions & 0 deletions docs/landing/concepts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ Checkout and payment are critical parts within every eCommerce application. Lear

[Payment Guide](./payment)

## Client API Errors and handling

Shopware-pwa provides an unified error's structure for all errors that may appear during working with Shopware 6 API.

[API Errors Guide](./api-client-errors)

## User Management <Badge text="coming soon"/>

How do you manage user data, newsletter sign-ins, address updates? We've got you covered. Follow our guide to show how customize the self-service experience within the PWA.
Expand Down
147 changes: 147 additions & 0 deletions docs/landing/concepts/api-client-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# API specific errors & handling

Shopware-pwa provides an unified error's structure for all errors that may appear during working with Shopware 6 API.

## Shopware 6 API error structure

API throws an error in specific format. The API responds error details as an array of errors (always, even there is only one error in the response).

Default response containing errors looks like this:
```ts
{
"errors": ShopwareError[]
}

```
where, `ShopwareError` interface is:
```ts
interface ShopwareError {
status: string; // HTTP Status code, like "403"
code: string; // internal error code, like "CHECKOUT__CUSTOMER_NOT_LOGGED_IN", or "VIOLATION::IS_BLANK_ERROR"
title: string; // title of an error, like "Forbidden" or "Not found"
detail: string; // additional information, like "Customer is not logged in."
source?: any; // only for HTTP 400 type errors, like `{"pointer": "/email"}`
meta: any; // unknown data that can be passed on backend side, like stacktrace in API's development mode
}
```

## Other API client's errors

Besides errors that may be returned by API, shopware-pwa recognize another type of errors: the Client errors itself, independent from API response:
- timeout (axios waits too long for the response, and timeout setting is reached)
- network error (there are some connection issues)

Each one is transformed into consistent format and gets appriopriate status code.

## Consisten format

`@shopware-pwa/shopware-6-client` package is responsible for connection layer between **shopware-pwa and API**. The error interceptor translates every (or almost every) type of an error into consistent one. Thanks to this, every error can be handled in the same way in the application in the next layers.


```ts
interface ClientApiError {
messages: ShopwareError[]; // contains array of ShopwareError objects, even if it's an issue on axios side
statusCode: number; // HTTP status code
}
```

## Example from the code

Here's a simple scenario of what may happen durin login and how to deal with such errors relying on `ClientApiError` & `SwErrorsList` vue component.

1. Let's say we are trying to log in. The code below show what it can look like:

```ts
// somewhere in the logic

const errors = ref([]); // errors reference that can be imported in the Vue component.

try {
await apiLogout();
broadcast(INTERCEPTOR_KEYS.USER_LOGOUT);
} catch (e) { // we expect the ClientApiError, always
const err: ClientApiError = e;
errors.value = err.messages; // (3) and need only array of messages to be displayed later on

broadcast(INTERCEPTOR_KEYS.ERROR, { // (4) optionally, you can plug into broadcasted error using interceptors (useIntercept composable) to show notifications or do something with an error.
methodName: `[${contextName}][logout]`,
inputParams: {},
error: err,
});
}

```

2. The customer provides the wrong data
3. `errors` object is fullfilled with `ClientApiErrors`'s `messages` array.
4. `SwErrorsList` component receives `loginErrors` (our `errors` reference from previous step).
```js
import SwErrorsList from "@/components/SwErrorsList.vue"
<SwErrorsList :list="loginErrors" />
```
5. Component displays the errors.

## SwErrorsList component

The component is located at `@/components/SwErrorsList.vue` and accepts only one prop: `list` and in fact that's the `ShopwareError[]` interface.

```ts
props: {
list: {
type: Array,
default: [],
},
},
```

1. The component detects if there is only one message or more. If an amount of errors is more than 1 -> the errors are shown as a bullet list and prepended with `encountered problems` title. Otherwise the error message is only one string.

2. The error can be "field" related as well, that means the error comes together with HTTP 400 error and should display additional details like `field` that causes validation errors.


## Intercept the errors

The errors that may occure in the logic layer (composables) can be broadcasted and intercepted in one place as well. There are many places the errors are broadcasted in order to be listened by some functions like additional logger or own way of error notification.

Let's try to intercept the error from the `## Example from the code` above.

```js
broadcast(INTERCEPTOR_KEYS.ERROR, {
methodName: `[${contextName}][logout]`,
inputParams: {},
error: err,
}
);
```

Example of how to deal with broadcasted errors. The example of a [nuxt plugin](https://nuxtjs.org/docs/2.x/directory-structure/plugins) below shows how to listen for ERROR type event within `useIntercept` functionality and do something about it. In this case, plugin subscribes the events for `INTERCEPTOR_KEYS.ERROR` key and pushes errors to the external logs server using UDP.

```js
import { useIntercept, INTERCEPTOR_KEYS } from "@shopware-pwa/composables"
import { configure, getLogger } from 'log4js'
export default ({ app }) => {
configure({
appenders: {
logstash: {
type: '@log4js-node/logstashudp', // UDP "driver" works only on SSR
host: 'mylogstash.server', // for demo only; use value from env instead
port: 5000 // for demo only; use value from env instead
}
},
categories: {
default: { appenders: ['logstash'], level: 'info' }
}
})
const logger = getLogger() // get the logstash client instance
const { intercept } = useIntercept(app)
intercept(INTERCEPTOR_KEYS.ERROR, (payload, rootContext) => {
logger.error(payload) // send the error to the logstash server
})
}
```

Thanks to this, all the errors can be captured in one place. Of course there can be some conditions and filtering needed depending on the case.
7 changes: 5 additions & 2 deletions docs/landing/resources/api/composables.iuseuser.errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@

```typescript
errors: UnwrapRef<{
login: string;
register: string[];
login: ShopwareError[];
register: ShopwareError[];
resetPassword: ShopwareError[];
updatePassword: ShopwareError[];
updateEmail: ShopwareError[];
}>;
```
2 changes: 1 addition & 1 deletion docs/landing/resources/api/composables.iuseuser.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface IUseUser
| [country](./composables.iuseuser.country.md) | Ref&lt;Country \| null&gt; | <b><i>(BETA)</i></b> |
| [deleteAddress](./composables.iuseuser.deleteaddress.md) | (addressId: string) =&gt; Promise&lt;boolean&gt; | <b><i>(BETA)</i></b> |
| [error](./composables.iuseuser.error.md) | Ref&lt;any&gt; | <b><i>(BETA)</i></b> |
| [errors](./composables.iuseuser.errors.md) | UnwrapRef&lt;{ login: string; register: string\[\]; }&gt; | <b><i>(BETA)</i></b> |
| [errors](./composables.iuseuser.errors.md) | UnwrapRef&lt;{ login: ShopwareError\[\]; register: ShopwareError\[\]; resetPassword: ShopwareError\[\]; updatePassword: ShopwareError\[\]; updateEmail: ShopwareError\[\]; }&gt; | <b><i>(BETA)</i></b> |
| [getOrderDetails](./composables.iuseuser.getorderdetails.md) | (orderId: string) =&gt; Promise&lt;Order \| undefined&gt; | <b><i>(BETA)</i></b> |
| [isCustomerSession](./composables.iuseuser.iscustomersession.md) | ComputedRef&lt;boolean&gt; | <b><i>(BETA)</i></b> |
| [isGuestSession](./composables.iuseuser.isguestsession.md) | ComputedRef&lt;boolean&gt; | <b><i>(BETA)</i></b> |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
extends: ["@commitlint/config-conventional"],
}
16 changes: 8 additions & 8 deletions packages/cli/src/templates/project-template/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
'^~/(.*)$': '<rootDir>/$1',
'^vue$': 'vue/dist/vue.common.js',
"^@/(.*)$": "<rootDir>/$1",
"^~/(.*)$": "<rootDir>/$1",
"^vue$": "vue/dist/vue.common.js",
},
moduleFileExtensions: ['js', 'vue', 'json'],
moduleFileExtensions: ["js", "vue", "json"],
transform: {
'^.+\\.js$': 'babel-jest',
'.*\\.(vue)$': 'vue-jest',
"^.+\\.js$": "babel-jest",
".*\\.(vue)$": "vue-jest",
},
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/components/**/*.vue',
'<rootDir>/pages/**/*.vue',
"<rootDir>/components/**/*.vue",
"<rootDir>/pages/**/*.vue",
],
}
6 changes: 3 additions & 3 deletions packages/cli/src/templates/project-template/nuxt.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import extendNuxtConfig from '@shopware-pwa/nuxt-module/config'
import extendNuxtConfig from "@shopware-pwa/nuxt-module/config"

export default extendNuxtConfig({
head: {
title: 'Shopware PWA',
meta: [{ hid: 'description', name: 'description', content: '' }],
title: "Shopware PWA",
meta: [{ hid: "description", name: "description", content: "" }],
},
})
4 changes: 2 additions & 2 deletions packages/commons/interfaces/errors/ApiError.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AxiosResponse, AxiosError } from "axios";

/**
* @alpha
* @beta
*/
export interface ShopwareError {
status: string;
Expand All @@ -25,6 +25,6 @@ export interface ShopwareApiError extends AxiosError {
* @alpha
*/
export interface ClientApiError {
message: string | ShopwareError[];
messages: ShopwareError[];
statusCode: number;
}
4 changes: 2 additions & 2 deletions packages/composables/__tests__/useCart.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,11 @@ describe("Composables - useCart", () => {
it("should show an error when cart is not refreshed", async () => {
const { count, refreshCart, error } = useCart(rootContextMock);
mockedShopwareClient.getCart.mockRejectedValueOnce({
message: "Some problem",
messages: [{ detail: "Some problem" }],
});
await refreshCart();
expect(count.value).toEqual(0);
expect(error.value).toEqual("Some problem");
expect(error.value).toEqual([{ detail: "Some problem" }]);
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/composables/__tests__/useCountries.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("Composables - useCountries", () => {
describe("fetchCoutries", () => {
it("should assing error to error message if getAvailableCountries throws one", async () => {
mockedApiClient.getAvailableCountries.mockRejectedValueOnce({
message: "Couldn't fetch available countries.",
messages: "Couldn't fetch available countries.",
});
const { fetchCountries, error } = useCountries(rootContextMock);
await fetchCountries();
Expand Down
6 changes: 4 additions & 2 deletions packages/composables/__tests__/useNavigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,16 @@ describe("Composables - useNavigation", () => {
});

it("should assign empty array for navigation if the response throws an error", async () => {
mockedGetPage.getStoreNavigation.mockRejectedValueOnce("some error");
mockedGetPage.getStoreNavigation.mockRejectedValueOnce({
messages: [{ detail: "some error" }],
});
const { navigationElements, loadNavigationElements } =
useNavigation(rootContextMock);
await loadNavigationElements({ depth: 2 });
expect(navigationElements.value).toEqual([]);
expect(consoleErrorSpy).toBeCalledWith(
"[useNavigation][loadNavigationElements]",
"some error"
[{ detail: "some error" }]
);
});
});
Expand Down
4 changes: 2 additions & 2 deletions packages/composables/__tests__/useProduct.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,12 @@ describe("Composables - useProduct", () => {
it("should have failed on bad url settings", async () => {
const { search, product, error } = useProduct(rootContextMock);
mockedAxios.getProduct.mockRejectedValueOnce({
message: "Something went wrong...",
messages: [{ detail: "Something went wrong..." }],
} as ClientApiError);
expect(product.value).toBeUndefined();
await search("");
expect(product.value).toBeUndefined();
expect(error.value).toEqual("Something went wrong...");
expect(error.value).toEqual([{ detail: "Something went wrong..." }]);
});
});
});
8 changes: 4 additions & 4 deletions packages/composables/__tests__/useSalutations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ describe("Composables - useSalutations", () => {
describe("fetchSalutations", () => {
it("should assing error to error message if getAvailableSalutations throws one", async () => {
mockedApiClient.getAvailableSalutations.mockRejectedValueOnce({
message: "Couldn't fetch available salutations.",
messages: [{ detail: "Couldn't fetch available salutations." }],
});
const { fetchSalutations, error } = useSalutations(rootContextMock);
await fetchSalutations();
expect(error.value.toString()).toBe(
"Couldn't fetch available salutations."
);
expect(error.value).toStrictEqual([
{ detail: "Couldn't fetch available salutations." },
]);
});
});
describe("onMounted", () => {
Expand Down
Loading

1 comment on commit c8b7c83

@vercel
Copy link

@vercel vercel bot commented on c8b7c83 Jul 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.