Skip to content

Commit 871f3de

Browse files
committed
feat: add field mapping utilities
1 parent 06e82d7 commit 871f3de

17 files changed

Lines changed: 1295 additions & 69 deletions

File tree

.formatter.exs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
spark_locals_without_parens = [
66
action_names_in_schema: 1,
77
always_include_linkage: 1,
8+
argument_names: 1,
89
authorize?: 1,
910
base: 1,
1011
base_route: 1,
@@ -21,6 +22,8 @@ spark_locals_without_parens = [
2122
derive_filter?: 1,
2223
derive_sort?: 1,
2324
description: 1,
25+
error_handler: 1,
26+
field_names: 1,
2427
get: 1,
2528
get: 2,
2629
get: 3,

documentation/dsls/DSL-AshJsonApi.Domain.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ 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 ``` |
6465

6566

6667
### json_api.open_api

documentation/dsls/DSL-AshJsonApi.Resource.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ 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+
| [`attribute_names`](#json_api-attribute_names){: #json_api-attribute_names } | `keyword \| (any -> any)` | | Renames attributes (and calculations/aggregates) in the JSON:API output and input. Can be 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). The function form is useful for applying a blanket transformation such as camelCase: ```elixir attribute_names fn name -> name \|> to_string() \|> Macro.camelize() \|> String.downcase_first() end ``` Or with a keyword list: ```elixir attribute_names [ first_name: :firstName, last_name: :lastName ] ``` Names are applied consistently across serialization, request parsing, sort/filter parameters, field selection, error source pointers, and schema generation. |
73+
| [`argument_names`](#json_api-argument_names){: #json_api-argument_names } | `keyword \| (any, any -> any)` | | Renames action arguments in the JSON:API request body and schema. Can be 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 [ create: [my_arg: :myArg], update: [my_arg: :myArg] ] ``` Or with a function: ```elixir argument_names fn _action, name -> name \|> to_string() \|> Macro.camelize() \|> String.downcase_first() end ``` |
7274

7375

7476
### json_api.routes
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2020 Zach Daniel
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Transforming Field Names
8+
9+
By default, AshJsonApi uses the Ash resource's attribute, relationship, calculation, and aggregate names directly as JSON:API field names. This means a `:first_name` attribute appears as `"first_name"` in requests and responses. The `field_names` and `argument_names` DSL options let you change this — for example, to expose a camelCase API while keeping snake_case internals.
10+
11+
These options affect **every** place a field or argument name appears: serialization output, request body parsing, sort and filter parameters, sparse fieldsets, error source pointers, relationship keys, JSON Schema, and OpenAPI spec generation.
12+
13+
## Renaming fields
14+
15+
### Keyword list
16+
17+
Use a keyword list to rename specific fields:
18+
19+
```elixir
20+
json_api do
21+
type "user"
22+
23+
field_names first_name: :firstName, last_name: :lastName
24+
end
25+
```
26+
27+
A `GET /users/:id` response would then return:
28+
29+
```json
30+
{
31+
"data": {
32+
"type": "user",
33+
"id": "...",
34+
"attributes": {
35+
"firstName": "Ada",
36+
"lastName": "Lovelace"
37+
}
38+
}
39+
}
40+
```
41+
42+
Fields not listed in the keyword list keep their original names.
43+
44+
### Function
45+
46+
Use a 1-arity function for a blanket transformation. This is useful for converting all field names to camelCase:
47+
48+
```elixir
49+
json_api do
50+
type "user"
51+
52+
field_names fn name ->
53+
camelized = name |> to_string() |> Macro.camelize()
54+
{first, rest} = String.split_at(camelized, 1)
55+
String.downcase(first) <> rest
56+
end
57+
end
58+
```
59+
60+
This applies to all public attributes, relationships, calculations, and aggregates on the resource.
61+
62+
## Renaming action arguments
63+
64+
Action arguments (the values sent in the request body under `data.attributes`) can also be renamed with `argument_names`.
65+
66+
### Keyword list
67+
68+
Provide a nested keyword list keyed by action name:
69+
70+
```elixir
71+
json_api do
72+
type "post"
73+
74+
argument_names [
75+
create: [publish_at: :publishAt],
76+
update: [publish_at: :publishAt]
77+
]
78+
end
79+
```
80+
81+
### Function
82+
83+
Use a 2-arity function that receives `(action_name, argument_name)`:
84+
85+
```elixir
86+
json_api do
87+
type "post"
88+
89+
argument_names fn _action_name, arg_name ->
90+
camelized = arg_name |> to_string() |> Macro.camelize()
91+
{first, rest} = String.split_at(camelized, 1)
92+
String.downcase(first) <> rest
93+
end
94+
end
95+
```
96+
97+
The `action_name` parameter lets you apply different mappings per action if needed.
98+
99+
## Where renaming is applied
100+
101+
Once configured, name mapping is applied consistently across:
102+
103+
- **Serialization** — response `attributes` and `relationships` objects use the renamed keys.
104+
- **Request body parsing**`data.attributes` keys in POST/PATCH bodies are expected under their renamed forms.
105+
- **Sort parameters**`?sort=firstName` works when `:first_name` is renamed to `firstName`.
106+
- **Filter parameters**`?filter[firstName]=Ada` maps back to the `:first_name` attribute via a ref transformer passed to `Ash.Filter.parse_input/3`.
107+
- **Sparse fieldsets**`?fields[user]=firstName,lastName` selects the renamed fields.
108+
- **Error source pointers** — validation errors point to `/data/attributes/firstName` instead of `/data/attributes/first_name`.
109+
- **JSON Schema & OpenAPI** — generated schemas use the renamed property names.
110+
111+
## Combining both options
112+
113+
You can use `field_names` and `argument_names` together. A common pattern is to camelCase everything:
114+
115+
```elixir
116+
json_api do
117+
type "user"
118+
119+
field_names fn name ->
120+
camelized = name |> to_string() |> Macro.camelize()
121+
{first, rest} = String.split_at(camelized, 1)
122+
String.downcase(first) <> rest
123+
end
124+
125+
argument_names fn _action, name ->
126+
camelized = name |> to_string() |> Macro.camelize()
127+
{first, rest} = String.split_at(camelized, 1)
128+
String.downcase(first) <> rest
129+
end
130+
end
131+
```

lib/ash_json_api/controllers/helpers.ex

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,19 @@ defmodule AshJsonApi.Controllers.Helpers do
9090
other
9191
end
9292

93-
query =
93+
base_query =
9494
request.resource
9595
|> Ash.Query.load(request.includes_keyword)
9696
|> Ash.Query.set_context(request.context)
97-
|> Ash.Query.do_filter(filter)
97+
98+
query =
99+
if filter do
100+
Ash.Query.filter_input(base_query, filter,
101+
ref_transformer: AshJsonApi.Resource.Info.filter_ref_transformer()
102+
)
103+
else
104+
base_query
105+
end
98106
|> Ash.Query.sort(request.sort)
99107
|> Ash.Query.load(fields(request, request.resource))
100108
|> Ash.Query.for_read(request.action.name, request.arguments, Request.opts(request))
@@ -690,7 +698,9 @@ defmodule AshJsonApi.Controllers.Helpers do
690698

691699
query =
692700
if filter do
693-
case Ash.Filter.parse_input(resource, filter) do
701+
case Ash.Filter.parse_input(resource, filter,
702+
ref_transformer: AshJsonApi.Resource.Info.filter_ref_transformer()
703+
) do
694704
{:ok, parsed} ->
695705
{:ok, Ash.Query.filter(resource, ^parsed)}
696706

@@ -788,7 +798,9 @@ defmodule AshJsonApi.Controllers.Helpers do
788798

789799
query =
790800
if filter do
791-
case Ash.Filter.parse_input(resource, filter) do
801+
case Ash.Filter.parse_input(resource, filter,
802+
ref_transformer: AshJsonApi.Resource.Info.filter_ref_transformer()
803+
) do
792804
{:ok, parsed} ->
793805
{:ok, Ash.Query.filter(resource, ^parsed)}
794806

lib/ash_json_api/error/error.ex

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,16 +188,18 @@ defmodule AshJsonApi.Error do
188188
[built_error]
189189
end
190190

191-
defp source_pointer(_resource, field, path, :action) do
192-
"/data/attributes/#{Enum.join(List.wrap(path) ++ [field], "/")}"
191+
defp source_pointer(resource, field, path, :action) do
192+
json_key = AshJsonApi.Resource.Info.field_to_json_key(resource, field)
193+
"/data/attributes/#{Enum.join(List.wrap(path) ++ [json_key], "/")}"
193194
end
194195

195196
defp source_pointer(resource, field, path, type)
196197
when type in [:create, :update] and not is_nil(field) do
197198
if path == [] && Ash.Resource.Info.public_relationship(resource, field) do
198199
"/data/relationships/#{field}"
199200
else
200-
"/data/attributes/#{Enum.join(List.wrap(path) ++ [field], "/")}"
201+
json_key = AshJsonApi.Resource.Info.field_to_json_key(resource, field)
202+
"/data/attributes/#{Enum.join(List.wrap(path) ++ [json_key], "/")}"
201203
end
202204
end
203205

lib/ash_json_api/json_schema/json_schema.ex

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ defmodule AshJsonApi.JsonSchema do
279279
resource
280280
|> Ash.Resource.Info.public_attributes()
281281
|> Enum.reject(&(&1.allow_nil? || AshJsonApi.Resource.only_primary_key?(resource, &1.name)))
282-
|> Enum.map(&to_string(&1.name))
282+
|> Enum.map(&AshJsonApi.Resource.Info.field_to_json_key(resource, &1.name))
283283
end
284284

285285
defp resource_attributes(resource) do
@@ -292,7 +292,11 @@ defmodule AshJsonApi.JsonSchema do
292292
)
293293
|> Enum.reject(&AshJsonApi.Resource.only_primary_key?(resource, &1.name))
294294
|> Enum.reduce(%{}, fn attr, acc ->
295-
Map.put(acc, to_string(attr.name), resource_attribute_type(attr))
295+
Map.put(
296+
acc,
297+
AshJsonApi.Resource.Info.field_to_json_key(resource, attr.name),
298+
resource_attribute_type(attr)
299+
)
296300
end)
297301
end
298302

@@ -735,16 +739,15 @@ defmodule AshJsonApi.JsonSchema do
735739
action.arguments
736740
|> Enum.filter(& &1.public?)
737741
|> Enum.reduce(props, fn argument, props ->
738-
Map.put(
739-
props,
740-
to_string(argument.name),
741-
resource_write_attribute_type(argument, argument.type)
742-
)
742+
json_key =
743+
AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, argument.name)
744+
745+
Map.put(props, json_key, resource_write_attribute_type(argument, argument.type))
743746
end),
744747
action.arguments
745748
|> Enum.filter(& &1.public?)
746749
|> Enum.reject(& &1.allow_nil?)
747-
|> Enum.map(&"#{&1.name}")
750+
|> Enum.map(&AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, &1.name))
748751
}
749752
end
750753

@@ -759,7 +762,7 @@ defmodule AshJsonApi.JsonSchema do
759762
defp sort_format(resource) do
760763
sorts = sortable_fields(resource)
761764

762-
"(#{Enum.map_join(sorts, "|", & &1.name)}),*"
765+
"(#{Enum.map_join(sorts, "|", &AshJsonApi.Resource.Info.field_to_json_key(resource, &1.name))}),*"
763766
end
764767

765768
defp page_props(_domain, _resource) do
@@ -972,7 +975,14 @@ defmodule AshJsonApi.JsonSchema do
972975

973976
defp required_write_attributes(resource, arguments, action, route \\ nil) do
974977
AshJsonApi.OpenApi.required_write_attributes(resource, arguments, action, route)
975-
|> Enum.map(&to_string/1)
978+
|> Enum.map(fn atom_name ->
979+
# Try argument first, then attribute
980+
if Enum.any?(arguments, &(&1.name == atom_name)) do
981+
AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, atom_name)
982+
else
983+
AshJsonApi.Resource.Info.field_to_json_key(resource, atom_name)
984+
end
985+
end)
976986
end
977987

978988
defp write_attributes(resource, arguments, action, route \\ nil) do
@@ -986,7 +996,7 @@ defmodule AshJsonApi.JsonSchema do
986996
|> Enum.reduce(%{}, fn attribute, acc ->
987997
Map.put(
988998
acc,
989-
to_string(attribute.name),
999+
AshJsonApi.Resource.Info.field_to_json_key(resource, attribute.name),
9901000
resource_write_attribute_type(attribute, action.type)
9911001
)
9921002
end)
@@ -997,7 +1007,7 @@ defmodule AshJsonApi.JsonSchema do
9971007
|> Enum.reduce(attributes, fn argument, attributes ->
9981008
Map.put(
9991009
attributes,
1000-
to_string(argument.name),
1010+
AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, argument.name),
10011011
resource_write_attribute_type(argument, :create)
10021012
)
10031013
end)
@@ -1018,11 +1028,11 @@ defmodule AshJsonApi.JsonSchema do
10181028

10191029
defp without_path_arguments(arguments, _, _), do: arguments
10201030

1021-
defp required_relationship_attributes(_resource, relationship_arguments, action) do
1031+
defp required_relationship_attributes(resource, relationship_arguments, action) do
10221032
action.arguments
10231033
|> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name))
10241034
|> Enum.reject(& &1.allow_nil?)
1025-
|> Enum.map(&to_string(&1.name))
1035+
|> Enum.map(&AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, &1.name))
10261036
end
10271037

10281038
defp write_relationships(resource, relationship_arguments, action) do
@@ -1035,7 +1045,7 @@ defmodule AshJsonApi.JsonSchema do
10351045

10361046
Map.put(
10371047
acc,
1038-
to_string(argument.name),
1048+
AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, argument.name),
10391049
object
10401050
)
10411051
end)

0 commit comments

Comments
 (0)