Skip to content

Commit acb4c9a

Browse files
committed
improvement: add builtin transformers :camelize and :dasherize
1 parent 1c5a6c9 commit acb4c9a

9 files changed

Lines changed: 350 additions & 88 deletions

File tree

documentation/dsls/DSL-AshJsonApi.Resource.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +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-
| [`field_names`](#json_api-field_names){: #json_api-field_names } | `keyword \| (any -> any)` | | Renames fields (attributes, relationships, calculations, and 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 field_names fn name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` Or with a keyword list: ```elixir field_names [ first_name: :firstName, last_name: :lastName ] ``` 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 } | `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 ``` |
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 ``` |
7474

7575

7676
### json_api.routes

documentation/topics/field-names.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ These options affect **every** place a field or argument name appears: serializa
1212

1313
## Renaming fields
1414

15+
### Built-in transformers
16+
17+
Use `:camelize` or `:dasherize` for common conventions:
18+
19+
```elixir
20+
field_names :camelize # first_name → firstName
21+
field_names :dasherize # first_name → first-name
22+
```
23+
1524
### Keyword list
1625

1726
Use a keyword list to rename specific fields:
@@ -116,16 +125,7 @@ You can use `field_names` and `argument_names` together. A common pattern is to
116125
json_api do
117126
type "user"
118127

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
128+
field_names :camelize
129+
argument_names :camelize
130130
end
131131
```

lib/ash_json_api/json_schema/json_schema.ex

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -975,14 +975,6 @@ defmodule AshJsonApi.JsonSchema do
975975

976976
defp required_write_attributes(resource, arguments, action, route \\ nil) do
977977
AshJsonApi.OpenApi.required_write_attributes(resource, arguments, action, route)
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)
986978
end
987979

988980
defp write_attributes(resource, arguments, action, route \\ nil) do

lib/ash_json_api/json_schema/open_api.ex

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
401401
|> Ash.Resource.Info.public_attributes()
402402
|> Enum.reject(&(&1.allow_nil? || AshJsonApi.Resource.only_primary_key?(resource, &1.name)))
403403
|> Enum.map(fn attr ->
404-
resource
405-
|> AshJsonApi.Resource.Info.field_to_json_key(attr.name)
406-
|> String.to_existing_atom()
404+
AshJsonApi.Resource.Info.field_to_json_key(resource, attr.name)
407405
end)
408406
end
409407

@@ -476,9 +474,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
476474
|> with_comment_on_included(attr, fields)
477475

478476
json_key =
479-
resource
480-
|> AshJsonApi.Resource.Info.field_to_json_key(attr.name)
481-
|> String.to_existing_atom()
477+
AshJsonApi.Resource.Info.field_to_json_key(resource, attr.name)
482478

483479
{Map.put(attrs, json_key, schema), acc}
484480
end)
@@ -2409,29 +2405,19 @@ if Code.ensure_loaded?(OpenApiSpex) do
24092405
&1.generated? ||
24102406
&1.name in Map.get(action, :allow_nil_input, []))
24112407
)
2412-
|> Enum.map(
2413-
&(resource
2414-
|> AshJsonApi.Resource.Info.field_to_json_key(&1.name)
2415-
|> String.to_existing_atom())
2416-
)
2408+
|> Enum.map(&AshJsonApi.Resource.Info.field_to_json_key(resource, &1.name))
24172409
end
24182410

24192411
argument_names =
24202412
filtered_arguments
24212413
|> Enum.reject(& &1.allow_nil?)
24222414
|> Enum.map(fn arg ->
2423-
resource
2424-
|> AshJsonApi.Resource.Info.argument_to_json_key(action.name, arg.name)
2425-
|> String.to_existing_atom()
2415+
AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, arg.name)
24262416
end)
24272417

24282418
require_attributes =
24292419
Map.get(action, :require_attributes, [])
2430-
|> Enum.map(
2431-
&(resource
2432-
|> AshJsonApi.Resource.Info.field_to_json_key(&1)
2433-
|> String.to_existing_atom())
2434-
)
2420+
|> Enum.map(&AshJsonApi.Resource.Info.field_to_json_key(resource, &1))
24352421

24362422
Enum.uniq(attribute_names ++ argument_names ++ require_attributes)
24372423
end
@@ -2457,9 +2443,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
24572443
resource_write_attribute_type(attribute, resource, action.type, acc, format)
24582444

24592445
json_key =
2460-
resource
2461-
|> AshJsonApi.Resource.Info.field_to_json_key(attribute.name)
2462-
|> String.to_existing_atom()
2446+
AshJsonApi.Resource.Info.field_to_json_key(resource, attribute.name)
24632447

24642448
{Map.put(attrs, json_key, schema), acc}
24652449
end)
@@ -2473,9 +2457,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
24732457
{schema, acc} = resource_write_attribute_type(argument, resource, :create, acc, format)
24742458

24752459
json_key =
2476-
resource
2477-
|> AshJsonApi.Resource.Info.argument_to_json_key(action.name, argument.name)
2478-
|> String.to_existing_atom()
2460+
AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, argument.name)
24792461

24802462
{Map.put(attributes, json_key, schema), acc}
24812463
end)
@@ -2512,9 +2494,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
25122494
|> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name))
25132495
|> Enum.reject(& &1.allow_nil?)
25142496
|> Enum.map(fn arg ->
2515-
resource
2516-
|> AshJsonApi.Resource.Info.argument_to_json_key(action.name, arg.name)
2517-
|> String.to_existing_atom()
2497+
AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, arg.name)
25182498
end)
25192499
end
25202500

@@ -2536,9 +2516,7 @@ if Code.ensure_loaded?(OpenApiSpex) do
25362516
}
25372517

25382518
json_key =
2539-
resource
2540-
|> AshJsonApi.Resource.Info.argument_to_json_key(action.name, argument.name)
2541-
|> String.to_existing_atom()
2519+
AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, argument.name)
25422520

25432521
{json_key, schema}
25442522
end)

lib/ash_json_api/resource/info.ex

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,38 @@ defmodule AshJsonApi.Resource.Info do
7070
Extension.get_opt(resource, [:json_api], :default_fields, nil, true)
7171
end
7272

73+
defp camelize(name) do
74+
camelized = name |> to_string() |> Macro.camelize()
75+
{first, rest} = String.split_at(camelized, 1)
76+
String.downcase(first) <> rest
77+
end
78+
79+
defp dasherize(name) do
80+
name |> to_string() |> String.replace("_", "-")
81+
end
82+
7383
@doc """
74-
Returns the raw `field_names` config for the resource: either a keyword list or a
75-
1-arity function.
84+
Returns the `field_names` config for the resource: a keyword list, a 1-arity function,
85+
or one of the atoms `:camelize` / `:dasherize` (resolved to the corresponding function).
7686
"""
7787
def field_names(resource) do
78-
Extension.get_opt(resource, [:json_api], :field_names, [], true)
88+
case Extension.get_opt(resource, [:json_api], :field_names, [], true) do
89+
:camelize -> &camelize/1
90+
:dasherize -> &dasherize/1
91+
other -> other
92+
end
7993
end
8094

8195
@doc """
82-
Returns the raw `argument_names` config for the resource: either a keyword list or a
83-
1-arity function.
96+
Returns the `argument_names` config for the resource: a keyword list, a 2-arity function,
97+
or one of the atoms `:camelize` / `:dasherize` (resolved to the corresponding function).
8498
"""
8599
def argument_names(resource) do
86-
Extension.get_opt(resource, [:json_api], :argument_names, [], true)
100+
case Extension.get_opt(resource, [:json_api], :argument_names, [], true) do
101+
:camelize -> fn _action, name -> camelize(name) end
102+
:dasherize -> fn _action, name -> dasherize(name) end
103+
other -> other
104+
end
87105
end
88106

89107
@doc """

lib/ash_json_api/resource/resource.ex

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -558,22 +558,19 @@ defmodule AshJsonApi.Resource do
558558
default: true
559559
],
560560
field_names: [
561-
type: {:or, [:keyword_list, {:fun, 1}]},
561+
type: {:or, [{:literal, :camelize}, {:literal, :dasherize}, :keyword_list, {:fun, 1}]},
562562
doc: """
563563
Renames fields (attributes, relationships, calculations, and aggregates) in the
564564
JSON:API output and input.
565565
566-
Can be a keyword list of `[ash_name: :json_api_name]` mappings, or a 1-arity function
567-
that receives an atom field name and returns the desired JSON:API name (atom or string).
568-
569-
The function form is useful for applying a blanket transformation such as camelCase:
566+
Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion,
567+
a keyword list of `[ash_name: :json_api_name]` mappings, or a 1-arity function
568+
that receives an atom field name and returns the desired JSON:API name (atom or
569+
string).
570570
571571
```elixir
572-
field_names fn name ->
573-
camelized = name |> to_string() |> Macro.camelize()
574-
{first, rest} = String.split_at(camelized, 1)
575-
String.downcase(first) <> rest
576-
end
572+
field_names :camelize # first_name → firstName
573+
field_names :dasherize # first_name → first-name
577574
```
578575
579576
Or with a keyword list:
@@ -585,19 +582,37 @@ defmodule AshJsonApi.Resource do
585582
]
586583
```
587584
585+
Or with a function for custom logic:
586+
587+
```elixir
588+
field_names fn name ->
589+
camelized = name |> to_string() |> Macro.camelize()
590+
{first, rest} = String.split_at(camelized, 1)
591+
String.downcase(first) <> rest
592+
end
593+
```
594+
588595
Names are applied consistently across serialization, request parsing,
589596
sort/filter parameters, field selection, error source pointers, relationship
590597
keys, and schema generation.
591598
"""
592599
],
593600
argument_names: [
594-
type: {:or, [:keyword_list, {:fun, 2}]},
601+
type: {:or, [{:literal, :camelize}, {:literal, :dasherize}, :keyword_list, {:fun, 2}]},
595602
doc: """
596603
Renames action arguments in the JSON:API request body and schema.
597604
598-
Can be a nested keyword list of `[action_name: [ash_name: :json_api_name]]` mappings,
599-
or a 2-arity function that receives `(action_name, argument_name)` atoms and returns
600-
the desired JSON:API name (atom or string).
605+
Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion,
606+
a nested keyword list of `[action_name: [ash_name: :json_api_name]]` mappings,
607+
or a 2-arity function that receives `(action_name, argument_name)` atoms and
608+
returns the desired JSON:API name (atom or string).
609+
610+
```elixir
611+
argument_names :camelize # publish_at → publishAt
612+
argument_names :dasherize # publish_at → publish-at
613+
```
614+
615+
Or with a keyword list:
601616
602617
```elixir
603618
argument_names [
@@ -610,7 +625,9 @@ defmodule AshJsonApi.Resource do
610625
611626
```elixir
612627
argument_names fn _action, name ->
613-
name |> to_string() |> Macro.camelize() |> String.downcase_first()
628+
camelized = name |> to_string() |> Macro.camelize()
629+
{first, rest} = String.split_at(camelized, 1)
630+
String.downcase(first) <> rest
614631
end
615632
```
616633
"""

0 commit comments

Comments
 (0)