Skip to content

Commit 6f032d2

Browse files
authored
Add require_type_on_create? for JSON:API spec compliance (issue #164) (#414)
* Add require_type_on_create? for JSON:API spec compliance (issue #164) * mix.lock and test changes * Shortened require-type-on-create.md * small fixes to require-type-on-create and md file removed and added small bit to DSL-AshJsonApi.Domain.md * removed require-type-on-create.md * mix spark.formatter, mix spark.cheat_sheets, and mix format ran
1 parent 5e59c1c commit 6f032d2

9 files changed

Lines changed: 483 additions & 84 deletions

File tree

.formatter.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ spark_locals_without_parens = [
6565
relationship: 3,
6666
relationship: 4,
6767
relationship_arguments: 1,
68+
require_type_on_create?: 1,
6869
resource: 1,
6970
route: 1,
7071
route: 3,

documentation/dsls/DSL-AshJsonApi.Domain.md

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ This file was generated by Spark. Do not edit it by hand.
33
-->
44
# AshJsonApi.Domain
55

6-
The entrypoint for adding JSON:API behavior to an Ash domain
6+
The entrypoint for adding JSON:API behavior to an Ash domain
77

88

99
## json_api
10-
Global configuration for JSON:API
10+
Global configuration for JSON:API
1111

1212

1313
### Nested DSLs
@@ -40,10 +40,10 @@ Global configuration for JSON:API
4040

4141
### Examples
4242
```
43-
json_api do
44-
prefix "/json_api"
45-
log_errors? true
46-
end
43+
json_api do
44+
prefix "/json_api"
45+
log_errors? true
46+
end
4747
4848
```
4949

@@ -61,7 +61,8 @@ end
6161
| [`authorize?`](#json_api-authorize?){: #json_api-authorize? } | `boolean` | `true` | Whether or not to perform authorization on requests. |
6262
| [`log_errors?`](#json_api-log_errors?){: #json_api-log_errors? } | `boolean` | `true` | Whether or not to log any errors produced |
6363
| [`include_nil_values?`](#json_api-include_nil_values?){: #json_api-include_nil_values? } | `boolean` | `true` | Whether or not to include properties for values that are nil in the JSON output |
64-
| [`error_handler`](#json_api-error_handler){: #json_api-error_handler } | `mfa` | | Set an MFA to intercept/handle any errors that are generated. The function will be called with a `AshJsonApi.Error` struct and a context map, and should return a modified `AshJsonApi.Error` struct. The context map contains `:domain` and `:resource`. For example: ```elixir defmodule MyApp.ErrorHandler do def handle_error(error, _context) do %{error \| detail: "Something went wrong"} end end ``` And in your domain: ```elixir json_api do error_handler {MyApp.ErrorHandler, :handle_error, []} end ``` |
64+
| [`error_handler`](#json_api-error_handler){: #json_api-error_handler } | `mfa` | | Set an MFA to intercept/handle any errors that are generated. The function will be called with a `AshJsonApi.Error` struct and a context map, and should return a modified `AshJsonApi.Error` struct. The context map contains `:domain` and `:resource`. For example: ```elixir defmodule MyApp.ErrorHandler do def handle_error(error, _context) do %{error \| detail: "Something went wrong"} end end ``` And in your domain: ```elixir json_api do error_handler {MyApp.ErrorHandler, :handle_error, []} end ``` |
65+
| [`require_type_on_create?`](#json_api-require_type_on_create?){: #json_api-require_type_on_create? } | `boolean` | `false` | When true, POST create requests MUST include type in data. Default false for backwards compatibility; in a future major version may default to true. |
6566

6667

6768
### json_api.open_api
@@ -71,13 +72,13 @@ OpenAPI configurations
7172

7273
### Examples
7374
```
74-
json_api do
75-
...
76-
open_api do
77-
tag "Users"
78-
group_by :api
79-
end
80-
end
75+
json_api do
76+
...
77+
open_api do
78+
tag "Users"
79+
group_by :api
80+
end
81+
end
8182
8283
```
8384

@@ -125,20 +126,20 @@ Configure the routes that will be exposed via the JSON:API
125126

126127
### Examples
127128
```
128-
routes do
129-
base "/posts"
130-
131-
get :read
132-
get :me, route: "/me"
133-
index :read
134-
post :confirm_name, route: "/confirm_name"
135-
patch :update
136-
related :comments, :read
137-
relationship :comments, :read
138-
post_to_relationship :comments
139-
patch_relationship :comments
140-
delete_from_relationship :comments
141-
end
129+
routes do
130+
base "/posts"
131+
132+
get :read
133+
get :me, route: "/me"
134+
index :read
135+
post :confirm_name, route: "/confirm_name"
136+
patch :update
137+
related :comments, :read
138+
relationship :comments, :read
139+
post_to_relationship :comments
140+
patch_relationship :comments
141+
delete_from_relationship :comments
142+
end
142143
143144
```
144145

@@ -151,7 +152,7 @@ base_route route, resource \\ nil
151152
```
152153

153154

154-
Sets a prefix for a list of contained routes
155+
Sets a prefix for a list of contained routes
155156

156157

157158
### Nested DSLs
@@ -170,14 +171,14 @@ Sets a prefix for a list of contained routes
170171

171172
### Examples
172173
```
173-
base_route "/posts" do
174-
index :read
175-
get :read
176-
end
177-
178-
base_route "/comments" do
179-
index :read
180-
end
174+
base_route "/posts" do
175+
index :read
176+
get :read
177+
end
178+
179+
base_route "/comments" do
180+
index :read
181+
end
181182
182183
```
183184

documentation/dsls/DSL-AshJsonApi.Resource.md

Lines changed: 46 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ This file was generated by Spark. Do not edit it by hand.
33
-->
44
# AshJsonApi.Resource
55

6-
The entrypoint for adding JSON:API behavior to a resource"
6+
The entrypoint for adding JSON:API behavior to a resource"
77

88

99
## json_api
@@ -27,30 +27,30 @@ Configure the resource's behavior in the JSON:API
2727

2828
### Examples
2929
```
30-
json_api do
31-
type "post"
32-
includes [
33-
friends: [
34-
:comments
35-
],
36-
comments: []
37-
]
38-
39-
routes do
40-
base "/posts"
41-
42-
get :read
43-
get :me, route: "/me"
44-
index :read
45-
post :confirm_name, route: "/confirm_name"
46-
patch :update
47-
related :comments, :read
48-
relationship :comments, :read
49-
post_to_relationship :comments
50-
patch_relationship :comments
51-
delete_from_relationship :comments
52-
end
53-
end
30+
json_api do
31+
type "post"
32+
includes [
33+
friends: [
34+
:comments
35+
],
36+
comments: []
37+
]
38+
39+
routes do
40+
base "/posts"
41+
42+
get :read
43+
get :me, route: "/me"
44+
index :read
45+
post :confirm_name, route: "/confirm_name"
46+
patch :update
47+
related :comments, :read
48+
relationship :comments, :read
49+
post_to_relationship :comments
50+
patch_relationship :comments
51+
delete_from_relationship :comments
52+
end
53+
end
5454
5555
```
5656

@@ -69,9 +69,9 @@ end
6969
| [`default_fields`](#json_api-default_fields){: #json_api-default_fields } | `list(atom)` | | The fields to include in the object if the `fields` query parameter does not specify. Defaults to all public |
7070
| [`derive_sort?`](#json_api-derive_sort?){: #json_api-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
7171
| [`derive_filter?`](#json_api-derive_filter?){: #json_api-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
72-
| [`field_names`](#json_api-field_names){: #json_api-field_names } | `:camelize \| :dasherize \| keyword \| (any -> any)` | | Renames fields (attributes, relationships, calculations, and aggregates) in the JSON:API output and input. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a keyword list of `[ash_name: :json_api_name]` mappings, or a 1-arity function that receives an atom field name and returns the desired JSON:API name (atom or string). ```elixir field_names :camelize # first_name → firstName field_names :dasherize # first_name → first-name ``` Or with a keyword list: ```elixir field_names [ first_name: :firstName, last_name: :lastName ] ``` Or with a function for custom logic: ```elixir field_names fn name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` Names are applied consistently across serialization, request parsing, sort/filter parameters, field selection, error source pointers, relationship keys, and schema generation. |
73-
| [`argument_names`](#json_api-argument_names){: #json_api-argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames action arguments in the JSON:API request body and schema. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[action_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(action_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir argument_names :camelize # publish_at → publishAt argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir argument_names [ create: [my_arg: :myArg], update: [my_arg: :myArg] ] ``` Or with a function: ```elixir argument_names fn _action, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
74-
| [`calculation_argument_names`](#json_api-calculation_argument_names){: #json_api-calculation_argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames calculation arguments in the JSON:API request and schema. Works the same way as `argument_names` but applies to calculation arguments instead of action arguments. The 2-arity function receives `(calculation_name, argument_name)`. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[calc_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(calculation_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir calculation_argument_names :camelize # publish_at → publishAt calculation_argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir calculation_argument_names [ full_name: [separator: :sep] ] ``` Or with a function: ```elixir calculation_argument_names fn _calc, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
72+
| [`field_names`](#json_api-field_names){: #json_api-field_names } | `:camelize \| :dasherize \| keyword \| (any -> any)` | | Renames fields (attributes, relationships, calculations, and aggregates) in the JSON:API output and input. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a keyword list of `[ash_name: :json_api_name]` mappings, or a 1-arity function that receives an atom field name and returns the desired JSON:API name (atom or string). ```elixir field_names :camelize # first_name → firstName field_names :dasherize # first_name → first-name ``` Or with a keyword list: ```elixir field_names [ first_name: :firstName, last_name: :lastName ] ``` Or with a function for custom logic: ```elixir field_names fn name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` Names are applied consistently across serialization, request parsing, sort/filter parameters, field selection, error source pointers, relationship keys, and schema generation. |
73+
| [`argument_names`](#json_api-argument_names){: #json_api-argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames action arguments in the JSON:API request body and schema. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[action_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(action_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir argument_names :camelize # publish_at → publishAt argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir argument_names [ create: [my_arg: :myArg], update: [my_arg: :myArg] ] ``` Or with a function: ```elixir argument_names fn _action, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
74+
| [`calculation_argument_names`](#json_api-calculation_argument_names){: #json_api-calculation_argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames calculation arguments in the JSON:API request and schema. Works the same way as `argument_names` but applies to calculation arguments instead of action arguments. The 2-arity function receives `(calculation_name, argument_name)`. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[calc_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(calculation_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir calculation_argument_names :camelize # publish_at → publishAt calculation_argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir calculation_argument_names [ full_name: [separator: :sep] ] ``` Or with a function: ```elixir calculation_argument_names fn _calc, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
7575

7676

7777
### json_api.routes
@@ -93,20 +93,20 @@ Configure the routes that will be exposed via the JSON:API
9393

9494
### Examples
9595
```
96-
routes do
97-
base "/posts"
98-
99-
get :read
100-
get :me, route: "/me"
101-
index :read
102-
post :confirm_name, route: "/confirm_name"
103-
patch :update
104-
related :comments, :read
105-
relationship :comments, :read
106-
post_to_relationship :comments
107-
patch_relationship :comments
108-
delete_from_relationship :comments
109-
end
96+
routes do
97+
base "/posts"
98+
99+
get :read
100+
get :me, route: "/me"
101+
index :read
102+
post :confirm_name, route: "/confirm_name"
103+
patch :update
104+
related :comments, :read
105+
relationship :comments, :read
106+
post_to_relationship :comments
107+
patch_relationship :comments
108+
delete_from_relationship :comments
109+
end
110110
111111
```
112112

@@ -667,10 +667,10 @@ Encode the id of the JSON API response from selected attributes of a resource
667667

668668
### Examples
669669
```
670-
primary_key do
671-
keys [:first_name, :last_name]
672-
delimiter "~"
673-
end
670+
primary_key do
671+
keys [:first_name, :last_name]
672+
delimiter "~"
673+
end
674674
675675
```
676676

lib/ash_json_api/domain/domain.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ defmodule AshJsonApi.Domain do
188188
end
189189
```
190190
"""
191+
],
192+
require_type_on_create?: [
193+
type: :boolean,
194+
default: false,
195+
doc:
196+
"When true, POST create requests MUST include type in data. Default false for backwards compatibility; in a future major version may default to true."
191197
]
192198
],
193199
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
@@ -50,4 +50,8 @@ defmodule AshJsonApi.Domain.Info do
5050
def error_handler(domain) do
5151
Extension.get_opt(domain, [:json_api], :error_handler, nil, true)
5252
end
53+
54+
def require_type_on_create?(domain) do
55+
Extension.get_opt(domain, [:json_api], :require_type_on_create?, false, true)
56+
end
5357
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SPDX-FileCopyrightText: 2019 ash_json_api contributors <https://github.com/ash-project/ash_json_api/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule AshJsonApi.Error.MissingTypeOnCreate do
6+
@moduledoc """
7+
Returned when a POST create request has a data object but no type member,
8+
and the domain has require_type_on_create? enabled (JSON:API spec compliance).
9+
"""
10+
use Splode.Error, class: :invalid, fields: []
11+
12+
def message(_error) do
13+
"The resource object MUST contain at least a type member."
14+
end
15+
16+
defimpl AshJsonApi.ToJsonApiError do
17+
def to_json_api_error(_error) do
18+
%AshJsonApi.Error{
19+
id: Ash.UUID.generate(),
20+
status_code: 400,
21+
code: "missing_type",
22+
title: "Invalid resource object",
23+
detail: "The resource object MUST contain at least a type member.",
24+
source_pointer: "/data",
25+
meta: %{}
26+
}
27+
end
28+
end
29+
end

lib/ash_json_api/request.ex

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ defmodule AshJsonApi.Request do
1515
InvalidQuery,
1616
InvalidRelationshipInput,
1717
InvalidType,
18+
MissingTypeOnCreate,
1819
UnacceptableMediaType,
1920
UnsupportedMediaType
2021
}
@@ -111,6 +112,7 @@ defmodule AshJsonApi.Request do
111112
|> validate_href_schema()
112113
|> validate_req_headers()
113114
|> validate_body()
115+
|> validate_require_type_on_create()
114116
|> parse_fields()
115117
|> parse_field_inputs()
116118
|> parse_filter_included()
@@ -197,6 +199,29 @@ defmodule AshJsonApi.Request do
197199
end)
198200
end
199201

202+
defp validate_require_type_on_create(
203+
%{
204+
route: %{type: :post},
205+
body: %{"data" => data},
206+
domain: domain
207+
} = request
208+
)
209+
when is_map(data) do
210+
if AshJsonApi.Domain.Info.require_type_on_create?(domain) do
211+
type_value = Map.get(data, "type")
212+
213+
if type_value in [nil, ""] do
214+
add_error(request, MissingTypeOnCreate.exception([]), request.route.type)
215+
else
216+
request
217+
end
218+
else
219+
request
220+
end
221+
end
222+
223+
defp validate_require_type_on_create(request), do: request
224+
200225
defp validate_body(%{body: body, schema: %{"schema" => schema}} = request) do
201226
json_xema = JsonXema.new(schema)
202227

lib/ash_json_api/serializer.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ defmodule AshJsonApi.Serializer do
383383

384384
defp add_prev_link(links, uri, query, %Ash.Page.Keyset{} = paginator) do
385385
case paginator do
386-
# First page in request, there should be no previous links since its fetching the latest data at the point in time
386+
# First page: no previous link (fetching latest data at this point in time)
387387
%{before: nil, after: nil} ->
388388
Map.put(links, :prev, nil)
389389

0 commit comments

Comments
 (0)