Skip to content

Commit 06e82d7

Browse files
committed
improvement: add an error_handler option
1 parent efbe7e0 commit 06e82d7

6 files changed

Lines changed: 486 additions & 53 deletions

File tree

documentation/topics/errors.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2020 Zach Daniel
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Errors
8+
9+
AshJsonApi converts Ash errors into [JSON:API error objects](https://jsonapi.org/format/#errors). This topic covers how that conversion works, how to customize it, and the available configuration options.
10+
11+
## Error Format
12+
13+
Every error response follows the JSON:API error object format:
14+
15+
```json
16+
{
17+
"errors": [
18+
{
19+
"id": "a1b2c3d4-...",
20+
"status": "422",
21+
"code": "invalid_attribute",
22+
"title": "InvalidAttribute",
23+
"detail": "must be present",
24+
"source": {
25+
"pointer": "/data/attributes/name"
26+
}
27+
}
28+
]
29+
}
30+
```
31+
32+
## The `AshJsonApi.ToJsonApiError` Protocol
33+
34+
AshJsonApi uses the `AshJsonApi.ToJsonApiError` protocol to convert Ash exceptions into `AshJsonApi.Error` structs. Built-in implementations are provided for common Ash errors such as `Ash.Error.Changes.InvalidChanges`, `Ash.Error.Query.NotFound`, `Ash.Error.Forbidden.Policy`, and others.
35+
36+
If your application raises a custom Ash exception and you want it to produce a specific JSON:API error, implement the protocol:
37+
38+
```elixir
39+
defimpl AshJsonApi.ToJsonApiError, for: MyApp.Errors.PaymentRequired do
40+
def to_json_api_error(error) do
41+
%AshJsonApi.Error{
42+
id: Ash.UUID.generate(),
43+
status_code: 402,
44+
code: "payment_required",
45+
title: "PaymentRequired",
46+
detail: error.message,
47+
meta: %{}
48+
}
49+
end
50+
end
51+
```
52+
53+
The `AshJsonApi.Error` struct has the following fields:
54+
55+
| Field | Description |
56+
|---|---|
57+
| `id` | Unique identifier for this error occurrence |
58+
| `status_code` | HTTP status code (integer) |
59+
| `code` | Machine-readable error code string |
60+
| `title` | Human-readable error title |
61+
| `detail` | Human-readable explanation specific to this occurrence |
62+
| `source_pointer` | JSON Pointer to the source of the error (e.g. `/data/attributes/name`) |
63+
| `source_parameter` | Query parameter that caused the error |
64+
| `meta` | Arbitrary metadata map |
65+
| `about` | Link to further information about this error |
66+
| `log_level` | Log level for this error (default: `:debug`) |
67+
| `internal_description` | Internal description used for logging, not sent to clients |
68+
69+
## Transforming Errors with `error_handler`
70+
71+
The `error_handler` domain option lets you intercept and transform any `AshJsonApi.Error` struct before it is sent to the client. This is useful for sanitizing error messages, adding metadata, translating error text, or applying any other cross-cutting transformation.
72+
73+
Configure it in your domain as an MFA:
74+
75+
```elixir
76+
defmodule MyApp.Domain do
77+
use Ash.Domain, extensions: [AshJsonApi.Domain]
78+
79+
json_api do
80+
error_handler {MyApp.JsonApiErrorHandler, :handle_error, []}
81+
end
82+
end
83+
```
84+
85+
The handler receives the `AshJsonApi.Error` struct and a context map, and must return a modified `AshJsonApi.Error` struct:
86+
87+
```elixir
88+
defmodule MyApp.JsonApiErrorHandler do
89+
def handle_error(error, _context) do
90+
# Sanitize internal details from 500 errors
91+
if error.status_code >= 500 do
92+
%{error | detail: "An internal error occurred. Please try again later."}
93+
else
94+
error
95+
end
96+
end
97+
end
98+
```
99+
100+
The context map contains:
101+
102+
| Key | Description |
103+
|---|---|
104+
| `:domain` | The domain module handling the request |
105+
| `:resource` | The resource module associated with the request (may be `nil`) |
106+
107+
### Example: Translating Error Messages
108+
109+
```elixir
110+
defmodule MyApp.JsonApiErrorHandler do
111+
def handle_error(error, _context) do
112+
%{error | detail: MyApp.Gettext.translate_error(error.code, error.detail)}
113+
end
114+
end
115+
```
116+
117+
### Example: Adding Custom Metadata
118+
119+
```elixir
120+
defmodule MyApp.JsonApiErrorHandler do
121+
def handle_error(error, %{domain: domain}) do
122+
%{error | meta: Map.put(error.meta || %{}, :api_version, "v2")}
123+
end
124+
end
125+
```
126+
127+
### Example: Context-Specific Handling
128+
129+
```elixir
130+
defmodule MyApp.JsonApiErrorHandler do
131+
def handle_error(error, %{resource: resource}) do
132+
case resource do
133+
MyApp.PaymentResource ->
134+
%{error | detail: MyApp.Payments.format_error(error)}
135+
136+
_ ->
137+
error
138+
end
139+
end
140+
end
141+
```
142+
143+
## Configuration Options
144+
145+
### `show_raised_errors?`
146+
147+
By default, if an error is *raised* (i.e. an unexpected exception, not a structured Ash error), AshJsonApi returns a generic error message with only a UUID for reference. This prevents leaking internal implementation details.
148+
149+
Set `show_raised_errors? true` to include the full exception in the response — useful during development:
150+
151+
```elixir
152+
json_api do
153+
show_raised_errors? true
154+
end
155+
```
156+
157+
### `log_errors?`
158+
159+
Controls whether errors are logged. Defaults to `true`.
160+
161+
```elixir
162+
json_api do
163+
log_errors? false
164+
end
165+
```
166+
167+
### Policy Breakdown Details
168+
169+
By default, authorization failures return a generic "forbidden" message. To include a breakdown of which policies failed (useful for debugging), set this in your application config:
170+
171+
```elixir
172+
# config/dev.exs
173+
config :ash_json_api, :policies, show_policy_breakdowns?: true
174+
```
175+
176+
> **Warning:** Do not enable this in production, as it may expose details about your authorization logic.

lib/ash_json_api/domain/domain.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,33 @@ defmodule AshJsonApi.Domain do
161161
type: :boolean,
162162
doc: "Whether or not to include properties for values that are nil in the JSON output",
163163
default: true
164+
],
165+
error_handler: [
166+
type: :mfa,
167+
doc: """
168+
Set an MFA to intercept/handle any errors that are generated.
169+
170+
The function will be called with a `AshJsonApi.Error` struct and a context map, and should
171+
return a modified `AshJsonApi.Error` struct. The context map contains `:domain` and `:resource`.
172+
173+
For example:
174+
175+
```elixir
176+
defmodule MyApp.ErrorHandler do
177+
def handle_error(error, _context) do
178+
%{error | detail: "Something went wrong"}
179+
end
180+
end
181+
```
182+
183+
And in your domain:
184+
185+
```elixir
186+
json_api do
187+
error_handler {MyApp.ErrorHandler, :handle_error, []}
188+
end
189+
```
190+
"""
164191
]
165192
],
166193
sections: [@open_api, @routes]

lib/ash_json_api/domain/info.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,8 @@ defmodule AshJsonApi.Domain.Info do
4646
def include_nil_values?(domain) do
4747
Extension.get_opt(domain, [:json_api], :include_nil_values?, true, true)
4848
end
49+
50+
def error_handler(domain) do
51+
Extension.get_opt(domain, [:json_api], :error_handler, nil, true)
52+
end
4953
end

lib/ash_json_api/error/error.ex

Lines changed: 70 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ defmodule AshJsonApi.Error do
3131
Enum.flat_map(errors, &to_json_api_errors(domain, resource, &1, type))
3232
end
3333

34-
def to_json_api_errors(_domain, _resource, %__MODULE__{} = error, _type) do
35-
[error]
34+
def to_json_api_errors(domain, resource, %__MODULE__{} = error, _type) do
35+
apply_error_handler([error], domain, resource)
3636
end
3737

3838
def to_json_api_errors(domain, resource, error, type) when is_binary(error) do
@@ -41,60 +41,77 @@ defmodule AshJsonApi.Error do
4141
end
4242

4343
def to_json_api_errors(domain, resource, error, type) do
44-
if AshJsonApi.ToJsonApiError.impl_for(error) do
45-
error
46-
|> AshJsonApi.ToJsonApiError.to_json_api_error()
47-
|> List.wrap()
48-
|> Enum.flat_map(&with_source_pointer(&1, error, resource, type))
49-
else
50-
uuid = Ash.UUID.generate()
44+
errors =
45+
if AshJsonApi.ToJsonApiError.impl_for(error) do
46+
error
47+
|> AshJsonApi.ToJsonApiError.to_json_api_error()
48+
|> List.wrap()
49+
|> Enum.flat_map(&with_source_pointer(&1, error, resource, type))
50+
else
51+
uuid = Ash.UUID.generate()
52+
53+
stacktrace =
54+
case error do
55+
%{stacktrace: %{stacktrace: v}} ->
56+
v
57+
58+
_ ->
59+
nil
60+
end
61+
62+
Logger.warning(
63+
"`#{uuid}`: AshJsonApi.Error not implemented for error:\n\n#{Exception.format(:error, error, stacktrace)}"
64+
)
65+
66+
code = if error.class == :forbidden, do: "forbidden", else: "something_went_wrong"
67+
title = if error.class == :forbidden, do: "Forbidden", else: "SomethingWentWrong"
68+
69+
detail =
70+
if error.class == :forbidden,
71+
do: "forbidden",
72+
else: "Something went wrong. Error id: #{uuid}"
73+
74+
if AshJsonApi.Domain.Info.show_raised_errors?(domain) do
75+
[
76+
%__MODULE__{
77+
id: uuid,
78+
status_code: class_to_status(error.class),
79+
code: code,
80+
title: title,
81+
detail: """
82+
Raised error: #{uuid}
83+
84+
#{Exception.format(:error, error, stacktrace)}"
85+
"""
86+
}
87+
]
88+
else
89+
[
90+
%__MODULE__{
91+
id: uuid,
92+
status_code: class_to_status(error.class),
93+
code: code,
94+
title: title,
95+
detail: detail
96+
}
97+
]
98+
end
99+
end
51100

52-
stacktrace =
53-
case error do
54-
%{stacktrace: %{stacktrace: v}} ->
55-
v
101+
apply_error_handler(errors, domain, resource)
102+
end
56103

57-
_ ->
58-
nil
59-
end
104+
defp apply_error_handler(errors, nil, _resource), do: errors
60105

61-
Logger.warning(
62-
"`#{uuid}`: AshJsonApi.Error not implemented for error:\n\n#{Exception.format(:error, error, stacktrace)}"
63-
)
64-
65-
code = if error.class == :forbidden, do: "forbidden", else: "something_went_wrong"
66-
title = if error.class == :forbidden, do: "Forbidden", else: "SomethingWentWrong"
67-
68-
detail =
69-
if error.class == :forbidden,
70-
do: "forbidden",
71-
else: "Something went wrong. Error id: #{uuid}"
72-
73-
if AshJsonApi.Domain.Info.show_raised_errors?(domain) do
74-
[
75-
%__MODULE__{
76-
id: uuid,
77-
status_code: class_to_status(error.class),
78-
code: code,
79-
title: title,
80-
detail: """
81-
Raised error: #{uuid}
82-
83-
#{Exception.format(:error, error, stacktrace)}"
84-
"""
85-
}
86-
]
87-
else
88-
[
89-
%__MODULE__{
90-
id: uuid,
91-
status_code: class_to_status(error.class),
92-
code: code,
93-
title: title,
94-
detail: detail
95-
}
96-
]
97-
end
106+
defp apply_error_handler(errors, domain, resource) do
107+
case AshJsonApi.Domain.Info.error_handler(domain) do
108+
nil ->
109+
errors
110+
111+
{m, f, a} ->
112+
Enum.map(errors, fn error ->
113+
apply(m, f, [error, %{domain: domain, resource: resource} | a])
114+
end)
98115
end
99116
end
100117

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ defmodule AshJsonApi.MixProject do
6868
"documentation/topics/upgrade.md",
6969
"documentation/topics/authorize-with-json-api.md",
7070
"documentation/topics/authenticate-with-json-api.md",
71+
"documentation/topics/errors.md",
7172
"documentation/topics/paginated-relationships.md",
7273
{"documentation/dsls/DSL-AshJsonApi.Resource.md",
7374
search_data: Spark.Docs.search_data_for(AshJsonApi.Resource)},

0 commit comments

Comments
 (0)