Skip to content

Commit 2e7f361

Browse files
authored
support index queries with generic actions (#363)
1 parent b36a76c commit 2e7f361

File tree

4 files changed

+197
-16
lines changed

4 files changed

+197
-16
lines changed

lib/ash_json_api/controllers/helpers.ex

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,7 @@ defmodule AshJsonApi.Controllers.Helpers do
5656
request = Request.assign(request, :action_input, action_input)
5757

5858
with {:ok, result} <- Ash.run_action(action_input),
59-
{:ok, result} <-
60-
Ash.load(
61-
result,
62-
fields(request, request.resource),
63-
Request.load_opts(request, reuse_values?: true)
64-
),
65-
{:ok, result} <-
66-
Ash.load(result, request.includes_keyword, Request.load_opts(request)) do
59+
{:ok, result} <- load_action_data(result, request) do
6760
Request.assign(request, :result, result)
6861
else
6962
{:error, error} ->
@@ -103,6 +96,30 @@ defmodule AshJsonApi.Controllers.Helpers do
10396
end)
10497
end
10598

99+
defp load_action_data(result, request) do
100+
# If the resouce has no primary read action, we can skip loading
101+
if is_nil(Ash.Resource.Info.primary_action(request.resource, :read)) do
102+
{:ok, result}
103+
else
104+
with {:ok, result} <-
105+
Ash.load(
106+
result,
107+
fields(
108+
request,
109+
request.resource
110+
),
111+
Request.load_opts(request, reuse_values?: true)
112+
),
113+
{:ok, result} <-
114+
Ash.load(result, request.includes_keyword, Request.load_opts(request)) do
115+
{:ok, result}
116+
else
117+
{:error, error} ->
118+
{:error, error}
119+
end
120+
end
121+
end
122+
106123
def run_action(request) do
107124
chain(request, fn request ->
108125
case path_args_and_filter(

lib/ash_json_api/request.ex

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ defmodule AshJsonApi.Request do
114114
|> parse_sort()
115115
|> parse_attributes()
116116
|> parse_query_params()
117-
|> parse_read_arguments()
117+
|> parse_action_arguments()
118118
|> parse_relationships()
119119
|> parse_resource_identifiers()
120120
end
@@ -840,7 +840,7 @@ defmodule AshJsonApi.Request do
840840

841841
defp parse_attributes(request), do: %{request | attributes: %{}, arguments: %{}}
842842

843-
defp parse_read_arguments(%{action: %{type: :read} = action} = request) do
843+
defp parse_action_arguments(%{action: %{type: :read} = action} = request) do
844844
action.arguments
845845
|> Enum.filter(& &1.public?)
846846
|> Enum.reduce(request, fn argument, request ->
@@ -856,7 +856,23 @@ defmodule AshJsonApi.Request do
856856
end)
857857
end
858858

859-
defp parse_read_arguments(request), do: request
859+
defp parse_action_arguments(%{action: %{type: :action} = action} = request) do
860+
action.arguments
861+
|> Enum.filter(& &1.public?)
862+
|> Enum.reduce(request, fn argument, request ->
863+
name = to_string(argument.name)
864+
865+
with :error <- Map.fetch(request.query_params, name),
866+
:error <- Map.fetch(request.path_params, name) do
867+
request
868+
else
869+
{:ok, value} ->
870+
%{request | arguments: Map.put(request.arguments, argument.name, value)}
871+
end
872+
end)
873+
end
874+
875+
defp parse_action_arguments(request), do: request
860876

861877
defp parse_relationships(
862878
%{

lib/ash_json_api/resource/resource.ex

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -591,11 +591,15 @@ defmodule AshJsonApi.Resource do
591591
case primary_key_fields(resource) do
592592
[] ->
593593
# Expect resource to have only 1 primary key if :primary_key section is not used
594-
[key] = Ash.Resource.Info.primary_key(resource)
595-
596-
case Map.get(record, key) do
597-
nil -> nil
598-
value -> to_string(value)
594+
case Ash.Resource.Info.primary_key(resource) do
595+
[] ->
596+
nil
597+
598+
[key] ->
599+
case Map.get(record, key) do
600+
nil -> nil
601+
value -> to_string(value)
602+
end
599603
end
600604

601605
keys ->
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
defmodule Test.Acceptance.GenericActionIndexTest do
2+
use ExUnit.Case, async: true
3+
4+
defmodule SearchResult do
5+
use Ash.Resource,
6+
domain: Test.Acceptance.GenericActionIndexTest.Domain,
7+
extensions: [AshJsonApi.Resource]
8+
9+
resource do
10+
require_primary_key?(false)
11+
end
12+
13+
json_api do
14+
type("search_result")
15+
16+
routes do
17+
base("/search")
18+
route(:get, "/", :search, query_params: [:query, :category])
19+
end
20+
end
21+
22+
actions do
23+
action :search, {:array, :struct} do
24+
constraints(items: [instance_of: __MODULE__])
25+
argument(:query, :string, allow_nil?: false)
26+
argument(:category, :string, allow_nil?: true)
27+
28+
run(fn input, _ ->
29+
query = input.arguments.query
30+
category = Map.get(input.arguments, :category)
31+
32+
results =
33+
case category do
34+
nil ->
35+
[
36+
%__MODULE__{title: "Result 1 for #{query}", content: "Content 1"},
37+
%__MODULE__{title: "Result 2 for #{query}", content: "Content 2"}
38+
]
39+
40+
category ->
41+
[
42+
%__MODULE__{
43+
title: "#{category} Result 1 for #{query}",
44+
content: "Category content 1"
45+
},
46+
%__MODULE__{
47+
title: "#{category} Result 2 for #{query}",
48+
content: "Category content 2"
49+
}
50+
]
51+
end
52+
53+
{:ok, results}
54+
end)
55+
end
56+
end
57+
58+
attributes do
59+
attribute(:title, :string, public?: true)
60+
attribute(:content, :string, public?: true)
61+
end
62+
end
63+
64+
defmodule Domain do
65+
use Ash.Domain,
66+
otp_app: :ash_json_api,
67+
extensions: [
68+
AshJsonApi.Domain
69+
]
70+
71+
json_api do
72+
log_errors?(false)
73+
end
74+
75+
resources do
76+
resource(SearchResult)
77+
end
78+
end
79+
80+
defmodule Router do
81+
use AshJsonApi.Router, domain: Domain
82+
end
83+
84+
import AshJsonApi.Test
85+
86+
setup do
87+
Application.put_env(:ash_json_api, Domain, json_api: [test_router: Router])
88+
89+
:ok
90+
end
91+
92+
test "generic action index route with required argument" do
93+
response =
94+
Domain
95+
|> get("/search?query=elixir", status: 200)
96+
97+
assert response.resp_body == [
98+
%{
99+
"title" => "Result 1 for elixir",
100+
"content" => "Content 1"
101+
},
102+
%{
103+
"title" => "Result 2 for elixir",
104+
"content" => "Content 2"
105+
}
106+
]
107+
end
108+
109+
test "generic action index route with optional argument" do
110+
response =
111+
Domain
112+
|> get("/search?query=elixir&category=tutorial", status: 200)
113+
114+
assert response.resp_body == [
115+
%{
116+
"title" => "tutorial Result 1 for elixir",
117+
"content" => "Category content 1"
118+
},
119+
%{
120+
"title" => "tutorial Result 2 for elixir",
121+
"content" => "Category content 2"
122+
}
123+
]
124+
end
125+
126+
test "generic action index route with missing required argument returns error" do
127+
response =
128+
Domain
129+
|> get("/search", status: 400)
130+
131+
assert %{"errors" => errors} = response.resp_body
132+
assert is_list(errors)
133+
assert length(errors) > 0
134+
135+
required_error = Enum.find(errors, &(&1["code"] == "required"))
136+
assert required_error, "Expected to find a 'required' error"
137+
138+
source_pointer = get_in(required_error, ["source", "pointer"])
139+
assert source_pointer, "Expected source pointer to be present"
140+
141+
assert String.contains?(source_pointer, "query"),
142+
"Expected source pointer '#{source_pointer}' to contain field name 'query'"
143+
end
144+
end

0 commit comments

Comments
 (0)