Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/popular-panthers-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@farfetched/core': minor
---

Added a new feature - `mapError` mapper to all Queries and Mutations
85 changes: 85 additions & 0 deletions apps/website/docs/api/factories/create_json_mutation.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ Config fields:
- `params`: params which were passed to the [_Mutation_](/api/primitives/mutation)
- `headers`: <Badge type="tip" text="since v0.13" /> raw response headers

- `mapError?`: <Badge type="tip" text="since v0.14" /> optional mapper for the error, available overloads:

- `(err) => mapped`
- `{ source: Store, fn: (err, sourceValue) => mapped }`

`err` object contains:

- `error`: the original error that occurred
- `params`: params which were passed to the [_Mutation_](/api/primitives/mutation)
- `headers`: raw response headers (available for HTTP errors and contract/validation errors where the response was received, not available for network errors)

- `status.expected`: `number` or `Array<number>` of expected HTTP status codes, if the response status code is not in the list, the mutation will be treated as failed

- `concurrency?`: concurrency settings for the [_Mutation_](/api/primitives/mutation)
Expand All @@ -50,3 +61,77 @@ Config fields:
:::

- `abort?`: [_Event_](https://effector.dev/en/api/effector/event/) after calling which all in-flight requests will be aborted

## Examples

### Error mapping

You can transform errors before they are passed to the [_Mutation_](/api/primitives/mutation) using `mapError`:

```ts
const loginMutation = createJsonMutation({
params: declareParams<{ login: string; password: string }>(),
request: {
method: 'POST',
url: 'https://api.salo.com/login',
body: ({ login, password }) => ({ login, password }),
},
response: {
contract: loginContract,
mapError({ error, params, headers }) {
if (isHttpError({ status: 401, error })) {
return {
type: 'unauthorized',
message: 'Invalid credentials',
requestId: headers?.get('X-Request-Id'),
};
}
if (isHttpError({ status: 429, error })) {
return {
type: 'rate_limited',
message: 'Too many attempts, please try again later',
requestId: headers?.get('X-Request-Id'),
};
}
return {
type: 'unknown',
message: 'Something went wrong',
requestId: headers?.get('X-Request-Id'),
};
},
},
});
```

With a sourced mapper:

```ts
const $errorMessages = createStore({
401: 'Invalid credentials',
429: 'Too many attempts',
});

const loginMutation = createJsonMutation({
params: declareParams<{ login: string; password: string }>(),
request: {
method: 'POST',
url: 'https://api.salo.com/login',
body: ({ login, password }) => ({ login, password }),
},
response: {
contract: loginContract,
mapError: {
source: $errorMessages,
fn({ error, headers }, messages) {
if (isHttpError({ error })) {
return {
message: messages[error.status] ?? 'Unknown error',
requestId: headers?.get('X-Request-Id'),
};
}
return { message: 'Network error', requestId: null };
},
},
},
});
```
11 changes: 11 additions & 0 deletions apps/website/docs/api/factories/create_json_query.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ Config fields:
- `params`: params which were passed to the [_Query_](/api/primitives/query)
- `headers`: <Badge type="tip" text="since v0.13" /> raw response headers

- `mapError?`: <Badge type="tip" text="since v0.14" /> optional mapper for the error data, available overloads:

- `(err) => mapped`
- `{ source: Store, fn: (err, data) => mapped }`

`err` object contains:

- `error`: the error that occurred (can be `HttpError`, `NetworkError`, `InvalidDataError`, or `PreparationError`)
- `params`: params which were passed to the [_Query_](/api/primitives/query)
- `headers`: raw response headers (available for HTTP errors and contract/validation errors, not available for network errors)

- `concurrency?`: concurrency settings for the [_Query_](/api/primitives/query)
::: danger Deprecation warning

Expand Down
51 changes: 51 additions & 0 deletions apps/website/docs/api/factories/create_mutation.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,54 @@ const loginMutation = createMutation({
// params: { login: string, password: string }
// }>
```

### `createMutation({ effect, contract?, mapError: Function })` <Badge type="tip" text="since v0.14" />

Creates [_Mutation_](/api/primitives/mutation) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Mutation_](/api/primitives/mutation) fails, the error is passed to `mapError` callback, and the result of the callback will be treated as the error of the [_Mutation_](/api/primitives/mutation).

```ts
const loginMutation = createMutation({
effect: loginFx,
contract: loginContract,
mapError({ error, params }) {
// Transform any error into a user-friendly message
if (isHttpError({ status: 401, error })) {
return { code: 'UNAUTHORIZED', message: 'Invalid credentials' };
}
return { code: 'UNKNOWN', message: 'Failed to login' };
},
});

// typeof loginMutation.finished.failure === Event<{
// error: { code: string, message: string },
// params: { login: string, password: string }
// }>
```

### `createMutation({ effect, contract?, mapError: { source, fn } })` <Badge type="tip" text="since v0.14" />

Creates [_Mutation_](/api/primitives/mutation) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Mutation_](/api/primitives/mutation) fails, the error is passed to `mapError.fn` callback as well as original parameters of the [_Mutation_](/api/primitives/mutation) and current value of `mapError.source` [_Store_](https://effector.dev/en/api/effector/store/), result of the callback will be treated as the error of the [_Mutation_](/api/primitives/mutation).

```ts
const $errorMessages = createStore({
401: 'Invalid credentials',
403: 'Access denied',
});

const loginMutation = createMutation({
effect: loginFx,
contract: loginContract,
mapError: {
source: $errorMessages,
fn({ error, params }, errorMessages) {
if (isHttpError({ error })) {
return {
message: errorMessages[error.status] ?? 'Unknown error',
status: error.status,
};
}
return { message: 'Network error', status: null };
},
},
});
```
52 changes: 52 additions & 0 deletions apps/website/docs/api/factories/create_query.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,55 @@ const languagesQuery = createQuery({
* }>
*/
```

### `createQuery({ effect, contract?, validate?, mapData?, mapError: Function, initialData? })` <Badge type="tip" text="since v0.14" />

Creates [_Query_](/api/primitives/query) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Query_](/api/primitives/query) fails, the error is passed to `mapError` callback, and the result of the callback will be treated as the error of the [_Query_](/api/primitives/query).

```ts
const languagesQuery = createQuery({
effect: fetchLanguagesFx,
contract: languagesContract,
mapError({ error, params }) {
// Transform any error into a user-friendly message
if (isHttpError({ status: 404, error })) {
return { code: 'NOT_FOUND', message: 'Languages not found' };
}
return { code: 'UNKNOWN', message: 'Failed to fetch languages' };
},
});

/* typeof languagesQuery.$error === Store<{
* code: string,
* message: string,
* } | null>
*/
```

### `createQuery({ effect, contract?, validate?, mapData?, mapError: { source, fn }, initialData? })` <Badge type="tip" text="since v0.14" />

Creates [_Query_](/api/primitives/query) based on given [_Effect_](https://effector.dev/en/api/effector/effect/). When the [_Query_](/api/primitives/query) fails, the error is passed to `mapError.fn` callback as well as original parameters of the [_Query_](/api/primitives/query) and current value of `mapError.source` [_Store_](https://effector.dev/en/api/effector/store/), result of the callback will be treated as the error of the [_Query_](/api/primitives/query).

```ts
const $errorMessages = createStore({
404: 'Resource not found',
500: 'Server error',
});

const languagesQuery = createQuery({
effect: fetchLanguagesFx,
contract: languagesContract,
mapError: {
source: $errorMessages,
fn({ error, params }, errorMessages) {
if (isHttpError({ error })) {
return {
message: errorMessages[error.status] ?? 'Unknown error',
status: error.status,
};
}
return { message: 'Network error', status: null };
},
},
});
```
72 changes: 72 additions & 0 deletions apps/website/docs/recipes/data_flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@ sequenceDiagram
activate S
S->>C: response
deactivate S
C->>C: apply error mapper
C-->>A: finished.failed

C->>C: parse response
C->>C: apply error mapper
C-->>A: finished.failed

C->>C: apply contract
C->>C: apply error mapper
C-->>A: finished.failed

C->>C: apply validator
C->>C: apply error mapper
C-->>A: finished.failed

C->>C: apply data mapper
Expand Down Expand Up @@ -133,6 +137,74 @@ const userQuery = createJsonQuery({
});
```

### Error mapping <Badge type="tip" text="since v0.14" />

This is optional stage. If any of the previous stages fail, you can define a mapper to transform the error to the desired format before it reaches `.finished.failure` [_Event_](https://effector.dev/en/api/effector/event/) and `.$error` [_Store_](https://effector.dev/en/api/effector/store/).

::: warning
Error mappers have to be pure functions, so they are not allowed to throw an error. If the mapper throws an error, the data-flow stops immediately without any error handling.
:::

Since error mapper is a [_Sourced_](/api/primitives/sourced), it's possible to add some extra data from the application to the mapping process. For example, it could be localized error messages:

```ts
const $errorMessages = createStore({
404: 'User not found',
500: 'Server error, please try again later',
});

const userQuery = createJsonQuery({
//...
response: {
mapError: {
source: $errorMessages,
fn: ({ error, headers }, messages) => {
if (isHttpError({ error })) {
return {
message: messages[error.status] ?? 'Unknown error',
requestId: headers?.get('X-Request-Id'),
};
}
return { message: 'Network error', requestId: null };
},
},
},
});
```

The error mapper receives the following data:

- `error`: the original error that occurred
- `params`: the parameters that were passed to the [_Query_](/api/primitives/query) or [_Mutation_](/api/primitives/mutation)
- `headers`: raw response headers (available for HTTP errors and contract/validation errors where the response was received, not available for network errors)

The same `mapError` option is available for [_Mutations_](/api/primitives/mutation):

```ts
const $errorMessages = createStore({
401: 'Invalid credentials',
429: 'Too many attempts',
});

const loginMutation = createJsonMutation({
//...
response: {
mapError: {
source: $errorMessages,
fn: ({ error, headers }, messages) => {
if (isHttpError({ error })) {
return {
message: messages[error.status] ?? 'Unknown error',
requestId: headers?.get('X-Request-Id'),
};
}
return { message: 'Network error', requestId: null };
},
},
},
});
```

## Data-flow in basic factories

**Basic factories** are used to create _Remote Operations_ with a more control of data-flow in user-land. In this case, the user-land code have to describe **request-response cycle** and **response parsing** stages. Other stages could be handled by the library, but it is not required for **basic factories**.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"size-limit": [
{
"path": "./dist/core.js",
"limit": "16 kB"
"limit": "16.17 kB"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe('concurrency', async () => {
"explanation": "Request was cancelled due to concurrency policy",
},
"meta": {
"responseMeta": undefined,
"stale": false,
"stopErrorPropagation": false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ describe('fetch/api.response.all_in_one', () => {
});

expect(watcher.listeners.onFailData).toBeCalledWith(
preparationError({
response: 'This is not JSON',
reason: 'Unexpected token T in JSON at position 0',
expect.objectContaining({
error: preparationError({
response: 'This is not JSON',
reason: 'Unexpected token T in JSON at position 0',
}),
responseMeta: expect.objectContaining({ headers: expect.anything() }),
})
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ describe('fetch/api.response.exceptions', () => {
});

expect(effectWatcher.listeners.onFailData).toHaveBeenCalledWith(
preparationError({ response: 'ok', reason: 'oops' })
expect.objectContaining({
error: preparationError({ response: 'ok', reason: 'oops' }),
responseMeta: expect.objectContaining({ headers: expect.anything() }),
})
);
}
);
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/fetch/__tests__/json.failed.data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,13 @@ describe('createJsonApi', () => {
});

expect(watcher.listeners.onFailData).toBeCalledWith(
httpError({
status: 500,
statusText: '',
response: { customError: true },
expect.objectContaining({
error: httpError({
status: 500,
statusText: '',
response: { customError: true },
}),
responseMeta: expect.objectContaining({ headers: expect.anything() }),
})
);
});
Expand Down
Loading