diff --git a/.formatter.exs b/.formatter.exs index 8c45c06..01893f4 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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, diff --git a/lib/data_layer/data_layer.ex b/lib/data_layer/data_layer.ex index 507c21c..47b7fdb 100644 --- a/lib/data_layer/data_layer.ex +++ b/lib/data_layer/data_layer.ex @@ -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)) diff --git a/lib/data_layer/info.ex b/lib/data_layer/info.ex index bf40f8d..dbf7ca8 100644 --- a/lib/data_layer/info.ex +++ b/lib/data_layer/info.ex @@ -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 diff --git a/lib/data_layer/transformers/set_endpoint_defaults.ex b/lib/data_layer/transformers/set_endpoint_defaults.ex index e9da7c3..663772a 100644 --- a/lib/data_layer/transformers/set_endpoint_defaults.ex +++ b/lib/data_layer/transformers/set_endpoint_defaults.ex @@ -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 diff --git a/lib/endpoint.ex b/lib/endpoint.ex index 9c63e8f..a117efc 100644 --- a/lib/endpoint.ex +++ b/lib/endpoint.ex @@ -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 diff --git a/lib/field.ex b/lib/field.ex index ec58376..ac7b869 100644 --- a/lib/field.ex +++ b/lib/field.ex @@ -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. """ ] diff --git a/lib/filter.ex b/lib/filter.ex index e3b4467..2bc507d 100644 --- a/lib/filter.ex +++ b/lib/filter.ex @@ -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( diff --git a/lib/finch/plug.ex b/lib/finch/plug.ex index f595e84..363a875 100644 --- a/lib/finch/plug.ex +++ b/lib/finch/plug.ex @@ -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(), diff --git a/lib/helpers.ex b/lib/helpers.ex new file mode 100644 index 0000000..7b7883d --- /dev/null +++ b/lib/helpers.ex @@ -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 diff --git a/lib/paginator/builtins.ex b/lib/paginator/builtins.ex new file mode 100644 index 0000000..5bc5941 --- /dev/null +++ b/lib/paginator/builtins.ex @@ -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 diff --git a/lib/paginator/continuation_property.ex b/lib/paginator/continuation_property.ex new file mode 100644 index 0000000..c33ee8e --- /dev/null +++ b/lib/paginator/continuation_property.ex @@ -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 diff --git a/lib/paginator/paginator.ex b/lib/paginator/paginator.ex new file mode 100644 index 0000000..18d8d45 --- /dev/null +++ b/lib/paginator/paginator.ex @@ -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 diff --git a/mix.exs b/mix.exs index ef07279..d6baf3d 100644 --- a/mix.exs +++ b/mix.exs @@ -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} diff --git a/mix.lock b/mix.lock index de28ce8..ca7df20 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/test/hackernews_test.exs b/test/hackernews_test.exs index 515e181..d396bab 100644 --- a/test/hackernews_test.exs +++ b/test/hackernews_test.exs @@ -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)