improvement: paginators & better filter support

improvement: new spark_function_behaviour for `before_request`
This commit is contained in:
Zach Daniel 2023-01-15 01:07:41 -05:00
parent 92cf4fd8bf
commit a9d94bdd9b
15 changed files with 409 additions and 147 deletions

View file

@ -1,6 +1,7 @@
spark_locals_without_parens = [
base: 1,
base_entity_path: 1,
base_paginator: 1,
before_request: 1,
endpoint: 1,
endpoint: 2,
@ -13,6 +14,7 @@ spark_locals_without_parens = [
get_endpoint: 2,
get_endpoint: 3,
limit_with: 1,
paginator: 1,
path: 1,
runtime_sort?: 1,
write_entity_path: 1,

View file

@ -92,6 +92,9 @@ defmodule AshJsonApiWrapper.DataLayer do
@fields,
@endpoints
],
imports: [
AshJsonApiWrapper.Paginator.Builtins
],
schema: [
before_request: [
type:
@ -108,6 +111,13 @@ defmodule AshJsonApiWrapper.DataLayer do
Where in the response to find resulting entities. Can be overridden per endpoint.
"""
],
base_paginator: [
type:
{:spark_behaviour, AshJsonApiWrapper.Paginator, AshJsonApiWrapper.Paginator.Builtins},
doc: """
A module implementing the `AshJSonApiWrapper.Paginator` behaviour, to allow scanning pages when reading.
"""
],
finch: [
type: :atom,
required: true,
@ -132,6 +142,7 @@ defmodule AshJsonApiWrapper.DataLayer do
defmodule Query do
defstruct [
:api,
:request,
:context,
:headers,
@ -139,6 +150,7 @@ defmodule AshJsonApiWrapper.DataLayer do
:limit,
:offset,
:filter,
:runtime_filter,
:sort,
:endpoint,
:templates,
@ -213,11 +225,73 @@ defmodule AshJsonApiWrapper.DataLayer do
else
if query.action do
case validate_filter(filter, resource, query.action) do
{:ok, {endpoint, templates, instructions}} ->
{:ok, {endpoint, templates, instructions}, remaining_filter} ->
{instructions, templates} =
if templates && !Enum.empty?(templates) do
{templates, instructions}
else
instructions =
instructions
|> Enum.reduce([], fn
{:expand_set, field, values} = instruction, new_instructions ->
if Enum.any?(new_instructions, fn
{:expand_set, ^field, _other_values} ->
true
_ ->
false
end) do
Enum.map(new_instructions, fn
{:expand_set, ^field, other_values} ->
{:expand_set, field,
other_values
|> MapSet.new()
|> MapSet.intersection(MapSet.new(values))
|> MapSet.to_list()}
other ->
other
end)
else
[instruction | new_instructions]
end
instruction, new_instructions ->
[instruction | new_instructions]
end)
{expand_set, instructions} =
Enum.split_with(instructions || [], fn
{:expand_set, _, _} ->
true
_ ->
false
end)
templates =
expand_set
|> Enum.at(0)
|> case do
nil ->
nil
{:expand_set, field, values} ->
Enum.map(values, &{:set, field, &1})
end
{instructions, templates}
end
new_query_params =
Enum.reduce(instructions, query.request.query || %{}, fn
{:simple, field, value}, query ->
Map.put(query, to_string(field), value)
Enum.reduce(instructions || [], query.request.query || %{}, fn
{:set, field, value}, query ->
field =
field
|> List.wrap()
|> Enum.map(&to_string/1)
AshJsonApiWrapper.Helpers.put_at_path(query, field, value)
{:place_in_list, path, value}, query ->
update_in!(query, path, [], &[value | &1])
@ -228,6 +302,7 @@ defmodule AshJsonApiWrapper.DataLayer do
query
| endpoint: endpoint,
templates: templates,
runtime_filter: remaining_filter,
request: %{query.request | query: new_query_params}
}}
@ -267,6 +342,7 @@ defmodule AshJsonApiWrapper.DataLayer do
%{
query
| request: %{query.request | query: params, headers: headers},
api: query.api,
action: action,
headers: headers,
context: context
@ -274,26 +350,20 @@ defmodule AshJsonApiWrapper.DataLayer do
end
defp validate_filter(filter, resource, action) when filter in [nil, true] do
{:ok, {AshJsonApiWrapper.DataLayer.Info.endpoint(resource, action.name), nil, []}}
{:ok, {AshJsonApiWrapper.DataLayer.Info.endpoint(resource, action.name), nil, []}, filter}
end
defp validate_filter(filter, resource, action) do
case AshJsonApiWrapper.Filter.find_filter_that_uses_get_endpoint(filter, resource, action) do
{:ok, {remaining_filter, get_endpoint, templates}} ->
case filter_instructions(remaining_filter, resource, get_endpoint) do
{:ok, instructions} ->
{:ok, {get_endpoint, templates, instructions}}
{:error, error} ->
{:error, error}
end
{:ok, {get_endpoint, templates, []}, remaining_filter}
{:ok, nil} ->
endpoint = AshJsonApiWrapper.DataLayer.Info.endpoint(resource, action.name)
case filter_instructions(filter, resource, endpoint) do
{:ok, instructions} ->
{:ok, {endpoint, nil, instructions}}
{:ok, instructions, remaining_filter} ->
{:ok, {endpoint, nil, instructions}, remaining_filter}
{:error, error} ->
{:error, error}
@ -304,25 +374,40 @@ defmodule AshJsonApiWrapper.DataLayer do
end
end
defp filter_instructions(filter, resource, endpoint) do
base_fields =
resource
|> AshJsonApiWrapper.DataLayer.Info.fields()
|> Map.new(&{&1.name, &1})
defp filter_instructions(filter, _resource, endpoint) do
fields =
endpoint.fields
|> Enum.reduce(base_fields, fn field, acc ->
Map.put(acc, field.name, field)
end)
|> Map.values()
|> List.wrap()
|> Enum.filter(& &1.filter_handler)
Enum.reduce_while(fields, {:ok, [], filter}, fn field, {:ok, instructions, filter} ->
result =
case field.filter_handler do
:simple ->
AshJsonApiWrapper.Filter.find_simple_filter(filter, field)
AshJsonApiWrapper.Filter.find_simple_filter(filter, field.name)
{:simple, path} ->
case AshJsonApiWrapper.Filter.find_simple_filter(filter, field.name) do
{:ok, {remaining_filter, new_instructions}} ->
field_name = field.name
{:ok,
{remaining_filter,
Enum.map(new_instructions, fn
{:set, ^field_name, value} ->
{:set, path, value}
{:expand_set, ^field_name, values} ->
{:expand_set, path, values}
other ->
# don't think this is possible
other
end)}}
other ->
other
end
{:place_in_list, path} ->
AshJsonApiWrapper.Filter.find_place_in_list_filter(
@ -338,18 +423,14 @@ defmodule AshJsonApiWrapper.DataLayer do
{:ok, {remaining_filter, new_instructions}} ->
{:cont, {:ok, new_instructions ++ instructions, remaining_filter}}
{:error, error} ->
{:halt, {:error, error}}
end
end)
|> case do
{:ok, instructions, nil} ->
{:ok, instructions}
{:ok, instructions, nil}
{:ok, _instructions, remaining_filter} ->
{:error,
"Some part of the provided filter statement was not processes: #{inspect(remaining_filter)}"}
{:ok, instructions, remaining_filter} ->
{:ok, instructions, remaining_filter}
{:error, error} ->
{:error, error}
@ -436,11 +517,13 @@ defmodule AshJsonApiWrapper.DataLayer do
)
|> Map.put(:query, params)
with {:ok, %{status: status} = response} when status >= 200 and status < 300 <-
request(request, changeset, resource, endpoint.path),
with request <- request(request, changeset, resource, endpoint.path),
{:ok, %{status: status} = response} when status >= 200 and status < 300 <-
do_request(request, resource),
{:ok, body} <- Jason.decode(response.body),
{:ok, entities} <- get_entities(body, endpoint),
{:ok, processed} <- process_entities(entities, resource, endpoint) do
{:ok, entities} <- get_entities(body, endpoint, resource),
{:ok, processed} <-
process_entities(entities, resource, endpoint) do
{:ok, Enum.at(processed, 0)}
else
{:ok, %{status: status} = response} ->
@ -486,11 +569,12 @@ defmodule AshJsonApiWrapper.DataLayer do
def run_query(query, resource, overridden?) do
if query.templates do
query.templates
|> Enum.uniq()
|> Task.async_stream(
fn template ->
query = %{
query
| request: Finch.build(:get, fill_template(query.endpoint.path, template)),
| request: fill_template(query.request, template),
templates: nil
}
@ -549,10 +633,11 @@ defmodule AshJsonApiWrapper.DataLayer do
query
end
with {:ok, %{status: status} = response} when status >= 200 and status < 300 <-
request(query.request, query.context, resource, path),
with request <- request(query.request, query.context, resource, path),
{:ok, %{status: status} = response} when status >= 200 and status < 300 <-
do_request(request, resource),
{:ok, body} <- Jason.decode(response.body),
{:ok, entities} <- get_entities(body, endpoint) do
{:ok, entities} <- get_entities(body, endpoint, resource, paginate_with: request) do
entities
|> limit_offset(query)
|> process_entities(resource, endpoint)
@ -566,6 +651,19 @@ defmodule AshJsonApiWrapper.DataLayer do
end
end
|> do_sort(query)
|> runtime_filter(query)
end
defp runtime_filter({:ok, results}, query) do
if not is_nil(query.runtime_filter) do
Ash.Filter.Runtime.filter_matches(query.api, results, query.runtime_filter)
else
{:ok, results}
end
end
defp runtime_filter(other, _) do
other
end
defp do_sort({:ok, results}, %{sort: sort}) when sort not in [nil, []] do
@ -574,11 +672,23 @@ defmodule AshJsonApiWrapper.DataLayer do
defp do_sort(other, _), do: other
defp fill_template(string, template) do
defp fill_template(request, template) do
template
|> List.wrap()
|> Enum.reduce(string, fn {key, replacement}, acc ->
String.replace(acc, ":#{key}", to_string(replacement))
|> Enum.reduce(request, fn
{key, replacement}, request ->
%{
request
| path: String.replace(request.path, ":#{key}", to_string(replacement))
}
{:set, key, value}, request ->
key =
key
|> List.wrap()
|> Enum.map(&to_string/1)
%{request | query: AshJsonApiWrapper.Helpers.put_at_path(request.query, key, value)}
end)
end
@ -600,27 +710,24 @@ defmodule AshJsonApiWrapper.DataLayer do
defp request(request, query_or_changeset, resource, path) do
case AshJsonApiWrapper.DataLayer.Info.before_request(resource) do
{module, opts} ->
request
|> Map.put(:path, path)
|> module.(query_or_changeset, opts)
|> encode_query()
|> encode_body()
|> log_send()
|> make_request(AshJsonApiWrapper.DataLayer.Info.finch(resource))
|> log_resp()
module.call(Map.put(request, :path, path), query_or_changeset, opts)
nil ->
request
|> Map.put(:path, path)
|> encode_query()
|> encode_body()
|> log_send()
|> make_request(AshJsonApiWrapper.DataLayer.Info.finch(resource))
|> log_resp()
end
end
def make_request(request, finch) do
defp do_request(request, resource) do
request
|> encode_query()
|> encode_body()
|> log_send()
|> make_request(AshJsonApiWrapper.DataLayer.Info.finch(resource))
|> log_resp()
end
defp make_request(request, finch) do
case Finch.request(request, finch) do
{:ok, %{status: code, headers: headers} = response} when code >= 300 and code < 400 ->
headers
@ -770,18 +877,68 @@ defmodule AshJsonApiWrapper.DataLayer do
end
end
defp get_entities(body, endpoint) do
case endpoint.entity_path do
nil ->
{:ok, List.wrap(body)}
defp get_entities(body, endpoint, resource, opts \\ []) do
if opts[:paginate_with] && endpoint.paginator do
with {:ok, entities} <-
get_entities(body, endpoint, resource, Keyword.delete(opts, :paginate_with)),
{:ok, bodies} <-
get_all_bodies(
body,
endpoint,
resource,
opts[:paginate_with],
&get_entities(&1, endpoint, resource, Keyword.delete(opts, :paginate_with)),
[entities]
) do
{:ok, bodies |> Enum.reverse() |> List.flatten()}
end
else
case endpoint.entity_path do
nil ->
{:ok, List.wrap(body)}
path ->
case ExJSONPath.eval(body, path) do
{:ok, [entities | _]} ->
{:ok, List.wrap(entities)}
path ->
case ExJSONPath.eval(body, path) do
{:ok, [entities | _]} ->
{:ok, List.wrap(entities)}
{:ok, _} ->
{:ok, []}
{:ok, _} ->
{:ok, []}
{:error, error} ->
{:error, error}
end
end
end
end
defp get_all_bodies(
body,
%{paginator: {module, opts}} = endpoint,
resource,
request,
entity_callback,
bodies
) do
case module.continue(body, Enum.at(bodies, 0), request, opts) do
:halt ->
{:ok, bodies}
{:ok, instructions} ->
request = apply_instructions(request, instructions)
case do_request(request, resource) do
{:ok, %{status: status} = response} when status >= 200 and status < 300 ->
with {:ok, new_body} <- Jason.decode(response.body),
{:ok, entities} <- entity_callback.(new_body) do
get_all_bodies(new_body, endpoint, resource, request, entity_callback, [
entities | bodies
])
end
{:ok, %{status: status} = response} ->
{:error,
"Received status code #{status} in request #{inspect(request)}. Response: #{inspect(response)}"}
{:error, error} ->
{:error, error}
@ -789,6 +946,32 @@ defmodule AshJsonApiWrapper.DataLayer do
end
end
defp apply_instructions(request, instructions) do
request
|> apply_params(instructions)
|> apply_headers(instructions)
end
defp apply_params(request, %{params: params}) when is_map(params) do
%{request | query: Ash.Helpers.deep_merge_maps(request.query || %{}, params)}
end
defp apply_params(request, _), do: request
defp apply_headers(request, %{headers: headers}) when is_map(headers) do
%{
request
| headers:
request.headers
|> Kernel.||(%{})
|> Map.new()
|> Map.merge(headers)
|> Map.to_list()
}
end
defp apply_headers(request, _), do: request
defp get_field(resource, endpoint, field) do
Enum.find(endpoint.fields || [], &(&1.name == field)) ||
Enum.find(AshJsonApiWrapper.DataLayer.Info.fields(resource), &(&1.name == field))

View file

@ -3,39 +3,44 @@ defmodule AshJsonApiWrapper.DataLayer.Info do
alias Spark.Dsl.Extension
@spec endpoint_base(Ash.Resource.t()) :: String.t() | nil
@spec endpoint_base(map | Ash.Resource.t()) :: String.t() | nil
def endpoint_base(resource) do
Extension.get_opt(resource, [:json_api_wrapper, :endpoints], :base, nil, false)
end
@spec finch(Ash.Resource.t()) :: module | nil
@spec finch(map | Ash.Resource.t()) :: module | nil
def finch(resource) do
Extension.get_opt(resource, [:json_api_wrapper], :finch, nil, false)
end
@spec base_entity_path(Ash.Resource.t()) :: String.t() | nil
@spec base_entity_path(map | Ash.Resource.t()) :: String.t() | nil
def base_entity_path(resource) do
Extension.get_opt(resource, [:json_api_wrapper], :base_entity_path, nil, false)
end
@spec before_request(Ash.Resource.t()) :: AshJsonApiWrapper.Finch.Plug.ref() | nil
@spec base_paginator(map | Ash.Resource.t()) :: AshJsonApiWrapper.Paginator.ref()
def base_paginator(resource) do
Extension.get_opt(resource, [:json_api_wrapper], :base_paginator, nil, false)
end
@spec before_request(map | Ash.Resource.t()) :: AshJsonApiWrapper.Finch.Plug.ref() | nil
def before_request(resource) do
Extension.get_opt(resource, [:json_api_wrapper], :before_request, nil)
end
@spec field(Ash.Resource.t(), atom) :: AshJsonApiWrapper.Field.t() | nil
@spec field(map | Ash.Resource.t(), atom) :: AshJsonApiWrapper.Field.t() | nil
def field(resource, name) do
resource
|> fields()
|> Enum.find(&(&1.name == name))
end
@spec fields(Ash.Resource.t()) :: list(AshJsonApiWrapper.Field.t())
@spec fields(map | Ash.Resource.t()) :: list(AshJsonApiWrapper.Field.t())
def fields(resource) do
Extension.get_entities(resource, [:json_api_wrapper, :fields])
end
@spec endpoint(Ash.Resource.t(), atom) :: AshJsonApiWrapper.Endpoint.t() | nil
@spec endpoint(map | Ash.Resource.t(), atom) :: AshJsonApiWrapper.Endpoint.t() | nil
def endpoint(resource, action) do
default_endpoint = AshJsonApiWrapper.Endpoint.default(resource)
@ -56,7 +61,7 @@ defmodule AshJsonApiWrapper.DataLayer.Info do
end
end
@spec get_endpoint(Ash.Resource.t(), atom, atom) :: AshJsonApiWrapper.Endpoint.t() | nil
@spec get_endpoint(map | Ash.Resource.t(), atom, atom) :: AshJsonApiWrapper.Endpoint.t() | nil
def get_endpoint(resource, action, get_for) do
default_endpoint = AshJsonApiWrapper.Endpoint.default(resource)
@ -78,7 +83,7 @@ defmodule AshJsonApiWrapper.DataLayer.Info do
end
end
@spec endpoints(Ash.Resource.t()) :: list(AshJsonApiWrapper.Endpoint.t())
@spec endpoints(map | Ash.Resource.t()) :: list(AshJsonApiWrapper.Endpoint.t())
def endpoints(resource) do
Extension.get_entities(resource, [:json_api_wrapper, :endpoints])
end

View file

@ -3,26 +3,30 @@ defmodule AshJsonApiWrapper.DataLayer.Transformers.SetEndpointDefaults do
alias Spark.Dsl.Transformer
@impl Spark.Dsl.Transformer
def transform(dsl) do
base_entity_path = AshJsonApiWrapper.DataLayer.Info.base_entity_path(dsl) || nil
base_entity_path = AshJsonApiWrapper.DataLayer.Info.base_entity_path(dsl)
base_paginator = AshJsonApiWrapper.DataLayer.Info.base_paginator(dsl)
base_fields = AshJsonApiWrapper.DataLayer.Info.fields(dsl)
dsl
|> AshJsonApiWrapper.DataLayer.Info.endpoints()
|> Enum.reduce({:ok, dsl}, fn endpoint, {:ok, dsl} ->
if endpoint.entity_path || is_nil(base_entity_path) do
{:ok, dsl}
else
{:ok,
Transformer.replace_entity(
dsl,
[:ash_json_api_wrapper, :endpoint],
%{
endpoint
| entity_path: base_entity_path
},
&(&1.action == endpoint.action)
)}
end
endpoint_field_names = Enum.map(endpoint.fields, & &1.name)
{:ok,
Transformer.replace_entity(
dsl,
[:ash_json_api_wrapper, :endpoint],
%{
endpoint
| entity_path: endpoint.entity_path || base_entity_path,
paginator: endpoint.paginator || base_paginator,
fields:
Enum.reject(base_fields, &(&1.name in endpoint_field_names)) ++ endpoint.fields
},
&(&1.action == endpoint.action)
)}
end)
end
end

View file

@ -8,7 +8,8 @@ defmodule AshJsonApiWrapper.Endpoint do
:write_entity_path,
:get_for,
:runtime_sort?,
:limit_with
:limit_with,
:paginator
]
@type t :: %__MODULE__{}
@ -48,6 +49,12 @@ defmodule AshJsonApiWrapper.Endpoint do
default: false,
doc:
"Whether or not this endpoint should support sorting at runtime after the data has been received."
],
paginator: [
type:
{:spark_behaviour, AshJsonApiWrapper.Paginator, AshJsonApiWrapper.Paginator.Builtins},
doc:
"A module implementing the `AshJSonApiWrapper.Paginator` behaviour, to allow scanning pages when reading."
]
]
end
@ -70,6 +77,8 @@ defmodule AshJsonApiWrapper.Endpoint do
%__MODULE__{
path: AshJsonApiWrapper.DataLayer.Info.endpoint_base(resource),
entity_path: AshJsonApiWrapper.DataLayer.Info.base_entity_path(resource),
paginator: AshJsonApiWrapper.DataLayer.Info.base_paginator(resource),
fields: AshJsonApiWrapper.DataLayer.Info.fields(resource),
fields_in: :body
}
end

View file

@ -24,7 +24,8 @@ defmodule AshJsonApiWrapper.Field do
Specification for how the field is handled when used in filters. This is relatively limited at the moment.
Supports the following:
* `:simple` - Sets the value directly into the query params. Does not support `or equals` or `in` filters.
* `:simple` - Sets the value directly into the query params.
* `{:simple, "key" | ["path", "to", "key"]}` - Sets the value directly into the query params using the provided key.
* `{:place_in_list, ["path", "to", "list"]}` - Supports `or equals` and `in` filters over the given field, by placing their values in the provided list.
"""
]

View file

@ -1,64 +1,51 @@
defmodule AshJsonApiWrapper.Filter do
@moduledoc false
def find_simple_filter(
filter,
field,
context \\ %{in_an_or?: false, other_branch_instructions: nil}
)
def find_simple_filter(%Ash.Filter{expression: expression}, field, context) do
find_simple_filter(expression, field, context)
def find_simple_filter(%Ash.Filter{expression: expression}, field) do
find_simple_filter(expression, field)
end
def find_simple_filter(
%Ash.Query.BooleanExpression{op: op, left: left, right: right} = expr,
field,
context
%Ash.Query.BooleanExpression{op: :and, left: left, right: right} = expr,
field
) do
case find_simple_filter(left, field, context) do
case find_simple_filter(left, field) do
{:ok, nil} ->
case find_simple_filter(right, field, context) do
case find_simple_filter(right, field) do
{:ok, nil} ->
{:ok, expr, []}
{:ok, {right_remaining, right_instructions}} ->
{:ok, Ash.Query.BooleanExpression.new(op, left, right_remaining), right_instructions}
{:ok, Ash.Query.BooleanExpression.new(:and, left, right_remaining),
right_instructions}
end
{:ok, {left_remaining, left_instructions}} ->
case find_simple_filter(right, field, %{
context
| other_branch_instructions: left_instructions
}) do
case find_simple_filter(right, field) do
{:ok, nil} ->
{:ok, {Ash.Query.BooleanExpression.new(op, left_remaining, right), left_instructions}}
{:ok,
{Ash.Query.BooleanExpression.new(:and, left_remaining, right), left_instructions}}
{:ok, {right_remaining, right_instructions}} ->
{:ok,
{Ash.Query.BooleanExpression.new(op, left_remaining, right_remaining),
{Ash.Query.BooleanExpression.new(:and, left_remaining, right_remaining),
left_instructions ++ right_instructions}}
{:error, error} ->
{:error, error}
end
end
end
def find_simple_filter(
%Ash.Query.Operator.Eq{left: left, right: %Ash.Query.Ref{} = right} = op,
field,
context
field
) do
find_simple_filter(%{op | right: left, left: right}, field, context)
find_simple_filter(%{op | right: left, left: right}, field)
end
def find_simple_filter(
%Ash.Query.Operator.Eq{
left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: name}}
},
field,
_context
field
)
when name != field do
{:ok, nil}
@ -69,20 +56,23 @@ defmodule AshJsonApiWrapper.Filter do
left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: field}},
right: value
},
field,
context
field
) do
if Enum.any?(context.other_branch_instructions, fn
{:simple, other_field, other_value} ->
other_field == field && other_value != value
{:ok, {nil, [{:set, field, value}]}}
end
_ ->
false
end) do
{:error, "Would set a simple filter for #{field} with two different values"}
else
{:ok, {nil, [{:set, field, value}]}}
end
def find_simple_filter(
%Ash.Query.Operator.In{
left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: field}},
right: values
},
field
) do
{:ok, {nil, [{:expand_set, field, values}]}}
end
def find_simple_filter(_, _) do
{:ok, nil}
end
def find_place_in_list_filter(
@ -303,15 +293,14 @@ defmodule AshJsonApiWrapper.Filter do
end
def find_filter_that_uses_get_endpoint(
%Ash.Query.Operator.Eq{left: %Ash.Query.Ref{}, right: %Ash.Query.Ref{}},
%Ash.Query.Operator.Eq{left: %Ash.Query.Ref{}, right: %Ash.Query.Ref{}} = expr,
_,
_,
_,
_,
_
uses_endpoint
) do
{:error,
"References on both sides of operators not supported in ash_json_api_wrapper currently"}
{:ok, {expr, uses_endpoint, nil}}
end
def find_filter_that_uses_get_endpoint(

View file

@ -5,6 +5,8 @@ defmodule AshJsonApiWrapper.Finch.Plug do
end
end
@type ref :: {module, Keyword.t()}
@callback call(
request :: Finch.Request.t(),
query_or_changeset :: Ash.Changeset.t() | Ash.Query.t(),

14
lib/helpers.ex Normal file
View file

@ -0,0 +1,14 @@
defmodule AshJsonApiWrapper.Helpers do
@moduledoc false
def put_at_path(_, [], value), do: value
def put_at_path(nil, [key | rest], value) do
%{key => put_at_path(nil, rest, value)}
end
def put_at_path(map, [key | rest], value) when is_map(map) do
map
|> Map.put_new(key, %{})
|> Map.update!(key, &put_at_path(&1, rest, value))
end
end

View file

@ -0,0 +1,9 @@
defmodule AshJsonApiWrapper.Paginator.Builtins do
@moduledoc "Builtin paginators"
@spec continuation_property(String.t(), opts :: Keyword.t()) ::
AshJsonApiWrapper.Paginator.ref()
def continuation_property(get, opts) do
{AshJsonApiWrapper.Paginator.ContinuationProperty, Keyword.put(opts, :get, get)}
end
end

View file

@ -0,0 +1,24 @@
defmodule AshJsonApiWrapper.Paginator.ContinuationProperty do
use AshJsonApiWrapper.Paginator
def continue(_response, [], _, _), do: :halt
def continue(response, _entities, _request, opts) do
case ExJSONPath.eval(response, opts[:get]) do
{:ok, [value | _]} when not is_nil(value) ->
if opts[:header] do
{:ok, %{headers: %{opts[:header] => value}}}
else
if opts[:param] do
{:ok,
%{params: AshJsonApiWrapper.Helpers.put_at_path(%{}, List.wrap(opts[:param]), value)}}
else
:halt
end
end
_ ->
:halt
end
end
end

View file

@ -0,0 +1,20 @@
defmodule AshJsonApiWrapper.Paginator do
@moduledoc """
Behavior for scanning pages of a paginated endpoint.
"""
@type ref :: {module, Keyword.t()}
defmacro __using__(_) do
quote do
@behaviour AshJsonApiWrapper.Paginator
end
end
@callback continue(
response :: term,
entities :: [Ash.Resource.record()],
request :: Finch.Request.t(),
opts :: Keyword.t()
) :: {:ok, %{optional(:params) => map, optional(:headers) => map}} | :halt
end

View file

@ -37,7 +37,7 @@ defmodule AshJsonApiWrapper.MixProject do
{:credo, ">= 0.0.0", only: :dev, runtime: false},
{:dialyxir, ">= 0.0.0", only: :dev, runtime: false},
{:sobelow, ">= 0.0.0", only: :dev, runtime: false},
{:git_ops, "~> 2.4.4", only: :dev},
{:git_ops, "~> 2.5", only: :dev},
{:excoveralls, "~> 0.13.0", only: [:dev, :test]},
{:mix_test_watch, "~> 1.0", only: :dev, runtime: false},
{:parse_trans, "3.3.0", only: [:dev, :test], override: true}

View file

@ -1,11 +1,11 @@
%{
"ash": {:hex, :ash, "2.5.2", "018dfaeb78b97810dfbc12d13842dd3fe4b8a797deebd4b9fbe32a9cd4471238", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 0.3.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69df569a557f55bb0479cf270ecaf80ef58780fa607e118e2889af1ae4448b45"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"},
"credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"},
"credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"},
"earmark_parser": {:hex, :earmark_parser, "1.4.16", "607709303e1d4e3e02f1444df0c821529af1c03b8578dfc81bb9cf64553d02b9", [:mix], [], "hexpm", "69fcf696168f5a274dd012e3e305027010658b2d1630cef68421d6baaeaccead"},
@ -21,7 +21,7 @@
"finch": {:hex, :finch, "0.14.0", "619bfdee18fc135190bf590356c4bf5d5f71f916adb12aec94caa3fa9267a4bc", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5459acaf18c4fdb47a8c22fb3baff5d8173106217c8e56c5ba0b93e66501a8dd"},
"gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"},
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
"git_ops": {:hex, :git_ops, "2.4.5", "185a724dfde3745edd22f7571d59c47a835cf54ded67e9ccbc951920b7eec4c2", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e323a5b01ad53bc8c19c3a444be3e61ed7803ecd2e95530446ae9327d0143ecc"},
"git_ops": {:hex, :git_ops, "2.5.4", "1f303c9952eccfc183631b7c3cceeb6604cb641a40dd29269bcd622416395de9", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "85d1fe718cacad67a7ca1e9e809a6cbb9771c2e9238c96e9aebbb286114f64e0"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
@ -35,7 +35,7 @@
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
"mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"},
"nimble_options": {:hex, :nimble_options, "0.5.2", "42703307b924880f8c08d97719da7472673391905f528259915782bb346e0a1b", [:mix], [], "hexpm", "4da7f904b915fd71db549bcdc25f8d56f378ef7ae07dc1d372cbe72ba950dce0"},
"nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},

View file

@ -159,11 +159,11 @@ defmodule AshJsonApiWrapper.Hackernews.Test do
Finch.start_link(name: MyApp.Finch)
assert [top_story] =
TopStory
|> Ash.Query.limit(1)
|> Ash.Query.load(story: :user)
|> Api.read!()
|> Enum.map(& &1.story)
TopStory
|> Ash.Query.limit(1)
|> Ash.Query.load(story: :user)
|> Api.read!()
|> Enum.map(& &1.story)
assert is_binary(top_story.url)
assert is_binary(top_story.title)