Packages

A resource declaration and interaction library. Built with pluggable data layers, and designed to be used by multiple front ends.

Retired package: Renamed - renamed to ash

Current section

Files

Jump to
ash_core lib ash engine request.ex
Raw

lib/ash/engine/request.ex

defmodule Ash.Engine.Request do
@moduledoc false
alias Ash.Error.Forbidden.MustPassStrictCheck
alias Ash.Error.Framework.AssumptionFailed
alias Ash.Error.Invalid.{DuplicatedPath, ImpossiblePath}
defmodule UnresolvedField do
@moduledoc false
defstruct [:resolver, deps: [], data?: false]
def new(dependencies, func) do
%__MODULE__{
resolver: func,
deps: deps(dependencies)
}
end
defp deps(deps) do
deps
|> List.wrap()
|> Enum.map(fn dep -> List.wrap(dep) end)
end
end
defimpl Inspect, for: UnresolvedField do
import Inspect.Algebra
def inspect(field, opts) do
concat([
"#UnresolvedField<",
to_doc(field.deps, opts),
">"
])
end
end
defstruct [
:id,
:error?,
:resource,
:changeset,
:path,
:action_type,
:action,
:data,
:name,
:api,
:query,
:authorization_filter,
:write_to_data?,
:strict_check_only?,
:verbose?,
:state,
:actor,
:authorize?,
:engine_pid,
authorized?: false,
authorizer_state: %{},
dependencies_to_send: %{},
dependency_data: %{},
dependencies_requested: []
]
@type t :: %__MODULE__{}
require Logger
alias Ash.Authorizer
def resolve(dependencies \\ [], func) do
UnresolvedField.new(dependencies, func)
end
def new(opts) do
query =
case opts[:query] do
%UnresolvedField{} = query ->
query
%Ash.Query{} = query ->
query
nil ->
nil
other ->
raise "Got a weird thing #{inspect(other)}"
end
id = Ecto.UUID.generate()
data =
case opts[:data] do
%UnresolvedField{} = unresolved ->
%{unresolved | data?: true}
other ->
other
end
%__MODULE__{
id: id,
resource: opts[:resource],
changeset: opts[:changeset],
path: List.wrap(opts[:path]),
action_type: opts[:action_type],
action: opts[:action],
data: data,
query: query,
api: opts[:api],
name: opts[:name],
strict_check_only?: opts[:strict_check_only?],
state: :strict_check,
actor: opts[:actor],
authorized?: opts[:authorize?] == false,
verbose?: opts[:verbose?] || false,
authorize?: opts[:authorize?] || true,
write_to_data?: Keyword.get(opts, :write_to_data?, true)
}
end
def next(request) do
case do_next(request) do
{:complete, new_request, notifications, dependencies} ->
if request.state != :complete do
{:complete, new_request, notifications, dependencies}
else
{:already_complete, new_request, notifications, dependencies}
end
{:waiting, new_request, notifications, dependencies} ->
{:wait, new_request, notifications, dependencies}
{:continue, new_request, notifications} ->
{:continue, new_request, notifications}
{:error, error, request} ->
{:error, error, request}
end
end
def do_next(%{state: :strict_check, authorize?: false} = request) do
log(request, "Skipping strict check due to authorize?: false")
{:continue, %{request | state: :fetch_data}, []}
end
def do_next(%{state: :strict_check} = request) do
case Ash.Resource.authorizers(request.resource) do
[] ->
log(request, "No authorizers found, skipping strict check")
{:continue, %{request | state: :fetch_data}, []}
authorizers ->
case strict_check(authorizers, request) do
{:ok, new_request, notifications, []} ->
new_request = set_authorized(new_request)
log(new_request, "Strict check complete")
{:continue, %{new_request | state: :fetch_data}, notifications}
{:ok, new_request, notifications, dependencies} ->
log(new_request, "Strict check incomplete, waiting on dependencies")
{:waiting, new_request, notifications, dependencies}
{:error, error} ->
log(request, "Strict checking failed")
{:error, error, request}
end
end
end
def do_next(%{state: :fetch_data} = request) do
case try_resolve_local(request, :data, true) do
{:skipped, _, _, _} ->
{:error, AssumptionFailed.exception(message: "Skipped fetching data"), request}
{:ok, request, notifications, []} ->
log(request, "data fetched: #{inspect(notifications)}")
{:continue, %{request | state: :check}, notifications}
{:ok, new_request, notifications, waiting_for} ->
log(request, "data waiting on dependencies: #{inspect(waiting_for)}")
{:waiting, new_request, notifications, waiting_for}
{:error, error} ->
log(request, "error fetching data: #{inspect(error)}")
{:error, error, request}
end
end
def do_next(%{state: :check, authorize?: false} = request) do
log(request, "Skipping check due to `authorize?: false`")
{:complete, %{request | state: :complete}, [], []}
end
def do_next(%{state: :check} = request) do
case Ash.Resource.authorizers(request.resource) do
[] ->
log(request, "No authorizers found, skipping check")
{:complete, %{request | state: :complete}, [], []}
authorizers ->
case check(authorizers, request) do
{:ok, new_request, notifications, []} ->
log(new_request, "Check complete")
new_request = set_authorized(new_request)
{:complete, %{new_request | state: :complete}, notifications, []}
{:ok, new_request, notifications, waiting} ->
log(request, "Check incomplete, waiting on dependencies")
{:waiting, new_request, notifications, waiting}
{:error, error} ->
log(request, "Check failed")
{:error, error, request}
end
end
end
def do_next(%{state: :complete} = request) do
if request.dependencies_to_send == %{} do
{:complete, request, [], []}
else
Enum.reduce_while(request.dependencies_to_send, {:complete, request, [], []}, fn
{field, _paths}, {:complete, request, notifications, deps} ->
case try_resolve_local(request, field, false) do
{:skipped, new_request, new_notifications, other_deps} ->
{:cont,
{:complete, new_request, new_notifications ++ notifications, other_deps ++ deps}}
{:ok, new_request, new_notifications, other_deps} ->
{:cont,
{:complete, new_request, new_notifications ++ notifications, other_deps ++ deps}}
{:error, error} ->
{:halt, {:error, error, request}}
end
end)
end
end
def wont_receive(request, path, field) do
log(request, "Request failed due to failed dependency #{inspect(path ++ [field])}")
{:stop, :dependency_failed, request}
end
def send_field(request, receiver_path, field) do
log(request, "Attempting to provide #{inspect(field)} for #{inspect(receiver_path)}")
case store_dependency(request, receiver_path, field) do
{:value, value, new_request} ->
{:ok, new_request, [{receiver_path, request.path, field, value}]}
{:ok, new_request, notifications} ->
{:ok, new_request, notifications}
{:waiting, new_request, notifications, []} ->
{:ok, new_request, notifications}
{:waiting, new_request, notifications, waiting_for} ->
{:waiting, new_request, notifications, waiting_for}
{:error, error, new_request} ->
log(request, "Error resolving #{field}: #{inspect(error)}")
{:error, error, new_request}
end
end
def receive_field(request, path, field, value) do
log(request, "Receiving field #{field} from #{inspect(path)}")
new_request = put_dependency_data(request, path ++ [field], value)
{:continue, new_request}
end
defp set_authorized(%{authorized?: false, resource: resource} = request) do
authorized? =
resource
|> Ash.Resource.authorizers()
|> Enum.all?(fn authorizer ->
authorizer_state(request, authorizer) == :authorizer
end)
%{request | authorized?: authorized?}
end
defp set_authorized(request), do: request
def put_dependency_data(request, dep, value) do
%{request | dependency_data: Map.put(request.dependency_data, dep, value)}
end
def store_dependency(request, receiver_path, field, internal? \\ false) do
request = do_store_dependency(request, field, receiver_path)
case try_resolve_local(request, field, internal?) do
{:skipped, new_request, notifications, []} ->
log(request, "Field #{field} was skipped, no additional dependencies")
{:ok, new_request, notifications}
{:skipped, new_request, notifications, waiting} ->
log(request, "Field #{field} was skipped, registering dependencies: #{inspect(waiting)}")
{:waiting, new_request, notifications, waiting}
{:ok, new_request, _, _} ->
case Map.get(new_request, field) do
%UnresolvedField{} ->
log(request, "Field could not be resolved #{field}, registering dependency")
{:ok, new_request, []}
value ->
log(request, "Field #{field}, was resolved and provided")
{:value, value, new_request}
end
{:error, error} ->
{:error, error, request}
end
end
defp do_store_dependency(request, field, receiver_path) do
log(request, "storing dependency on #{field} from #{inspect(receiver_path)}")
new_deps_to_send =
Map.update(request.dependencies_to_send, field, [receiver_path], fn paths ->
paths = Enum.reject(paths, &Kernel.==(&1, receiver_path))
[receiver_path | paths]
end)
%{request | dependencies_to_send: new_deps_to_send}
end
defp strict_check(authorizers, request) do
authorizers
|> Enum.reject(&(authorizer_state(request, &1) == :authorized))
|> Enum.reduce_while({:ok, request, [], []}, fn authorizer,
{:ok, request, notifications, waiting_for} ->
log(request, "strict checking")
case do_strict_check(authorizer, request) do
{:ok, new_request} ->
log(new_request, "strict check succeeded for #{inspect(authorizer)}")
{:cont, {:ok, new_request, notifications, waiting_for}}
{:ok, new_request, new_notifications, new_deps} ->
log(new_request, "strict check succeeded for #{inspect(authorizer)}")
{:cont, {:ok, new_request, new_notifications ++ notifications, waiting_for ++ new_deps}}
{:waiting, new_request, new_notifications, new_deps} ->
log(
new_request,
"waiting on dependencies: #{inspect(new_deps)} for #{inspect(authorizer)}"
)
{:cont, {:ok, new_request, notifications ++ new_notifications, new_deps ++ waiting_for}}
{:error, error} ->
log(request, "strict check failed for #{inspect(authorizer)}: #{inspect(error)}")
{:halt, {:error, error}}
end
end)
end
defp do_strict_check(authorizer, request, notifications \\ []) do
strict_check_only? = request.strict_check_only?
case missing_strict_check_dependencies?(authorizer, request) do
[] ->
case strict_check_authorizer(authorizer, request) do
:authorized ->
{:ok, set_authorizer_state(request, authorizer, :authorized)}
{:filter, filter} ->
request
|> Map.update!(:query, &Ash.Query.filter(&1, filter))
|> Map.update(
:authorization_filter,
filter,
&add_to_or_parse(&1, filter, request.resource)
)
|> set_authorizer_state(authorizer, :authorized)
|> try_resolve([request.path ++ [:query]], false)
{:filter_and_continue, _, _} when strict_check_only? ->
{:error, MustPassStrictCheck.exception(resource: request.resource)}
{:filter_and_continue, filter, new_authorizer_state} ->
new_request =
request
|> Map.update!(:query, &Ash.Query.filter(&1, filter))
|> Map.update(:authorization_filter, filter, &Ash.Filter.add_to_filter(&1, filter))
|> set_authorizer_state(authorizer, new_authorizer_state)
{:ok, new_request}
{:continue, _} when strict_check_only? ->
{:error, MustPassStrictCheck.exception(resource: request.resource)}
{:continue, authorizer_state} ->
{:ok, set_authorizer_state(request, authorizer, authorizer_state)}
{:error, error} ->
{:error, error}
end
deps ->
deps =
Enum.map(deps, fn dep ->
request.path ++ [dep]
end)
case try_resolve(request, deps, true) do
{:ok, new_request, new_notifications, []} ->
do_strict_check(authorizer, new_request, notifications ++ new_notifications)
{:ok, new_request, new_notifications, waiting_for} ->
{:waiting, new_request, notifications ++ new_notifications, waiting_for}
{:error, error} ->
{:error, error}
end
end
end
defp add_to_or_parse(existing_authorization_filter, filter, resource) do
if existing_authorization_filter do
Ash.Filter.add_to_filter(existing_authorization_filter, filter)
else
Ash.Filter.parse!(resource, filter)
end
end
defp check(authorizers, request) do
authorizers
|> Enum.reject(&(authorizer_state(request, &1) == :authorized))
|> Enum.reduce_while({:ok, request, [], []}, fn authorizer,
{:ok, request, notifications, waiting_for} ->
case do_check(authorizer, request) do
{:ok, new_request} ->
log(request, "check succeeded for #{inspect(authorizer)}")
{:cont, {:ok, new_request, notifications, waiting_for}}
{:ok, new_request, new_notifications, new_deps} ->
log(request, "check succeeded for #{inspect(authorizer)}")
{:cont, {:ok, new_request, new_notifications ++ notifications, new_deps ++ waiting_for}}
{:waiting, new_request, new_notifications, new_deps} ->
log(
request,
"waiting on dependencies: #{inspect(new_deps)} for #{inspect(authorizer)}"
)
{:cont, {:ok, new_request, new_notifications ++ notifications, new_deps ++ waiting_for}}
{:error, error} ->
log(request, "check failed for #{inspect(authorizer)}: #{inspect(error)}")
{:halt, {:error, error}}
end
end)
end
defp do_check(authorizer, request, notifications \\ []) do
case missing_check_dependencies(authorizer, request) do
[] ->
case check_authorizer(authorizer, request) do
:authorized ->
{:ok, set_authorizer_state(request, authorizer, :authorized)}
{:filter, filter} ->
request
|> set_authorizer_state(authorizer, :authorized)
|> Map.update(
:authorization_filter,
filter,
&Ash.Filter.add_to_filter(&1, filter)
)
|> runtime_filter(authorizer, filter)
{:error, error} ->
{:error, error}
end
deps ->
deps =
Enum.map(deps, fn dep ->
request.path ++ [dep]
end)
case try_resolve(request, deps, true) do
{:ok, new_request, new_notifications, []} ->
do_check(authorizer, new_request, notifications ++ new_notifications)
{:ok, new_request, new_notifications, waiting_for} ->
{:waiting, new_request, new_notifications ++ notifications, waiting_for}
{:error, error} ->
{:error, error}
end
end
end
defp runtime_filter(request, authorizer, filter) do
case do_runtime_filter(request, filter) do
{:ok, request} ->
request
|> set_authorizer_state(authorizer, :authorized)
|> try_resolve([request.path ++ [:data], request.path ++ [:query]], false)
{:error, error} ->
{:error, error}
end
end
defp do_runtime_filter(%{data: empty} = request, _filter) when empty in [nil, []],
do: {:ok, request}
defp do_runtime_filter(request, filter) do
pkey = Ash.Resource.primary_key(request.resource)
pkeys =
request.data
|> List.wrap()
|> Enum.map(fn record ->
record |> Map.take(pkey) |> Map.to_list()
end)
primary_key_filter =
case pkeys do
[pkey] -> [pkey]
pkeys -> [or: pkeys]
end
new_query =
request.query
|> Ash.Query.filter(primary_key_filter)
|> Ash.Query.filter(filter)
request.api.read(new_query)
|> case do
{:ok, results} ->
pkey = Ash.Resource.primary_key(request.resource)
pkeys = Enum.map(results, &Map.take(&1, pkey))
new_data = Enum.filter(request.data, &(Map.take(&1, pkey) in pkeys))
{:ok, %{request | data: new_data, query: new_query}}
{:error, error} ->
{:error, error}
end
end
defp try_resolve(request, deps, internal?) do
Enum.reduce_while(deps, {:ok, request, [], []}, fn dep,
{:ok, request, notifications, skipped} ->
case get_dependency_data(request, dep) do
{:ok, _value} ->
{:cont, {:ok, request, notifications, skipped}}
:error ->
do_try_resolve(request, notifications, skipped, dep, internal?)
end
end)
end
defp do_try_resolve(request, notifications, skipped, dep, internal?) do
if local_dep?(request, dep) do
case try_resolve_local(request, List.last(dep), internal?) do
{:skipped, request, new_notifications, other_deps} ->
{:cont, {:ok, request, new_notifications ++ notifications, skipped ++ other_deps}}
{:ok, request, new_notifications, other_deps} ->
{:cont, {:ok, request, new_notifications ++ notifications, skipped ++ other_deps}}
{:error, error} ->
{:halt, {:error, error}}
end
else
{:cont, {:ok, request, notifications, [dep | skipped]}}
end
end
defp try_resolve_local(request, field, internal?) do
authorized? = Enum.all?(Map.values(request.authorizer_state), &(&1 == :authorized))
# Don't fetch honor requests for dat until the request is authorized
if field in [:data, :query] and not authorized? and not internal? do
try_resolve_dependencies_of(request, field, internal?)
else
case Map.get(request, field) do
%UnresolvedField{} = unresolved ->
do_try_resolve_local(request, field, unresolved, internal?)
value ->
notify_existing_value(request, field, value, internal?)
end
end
end
defp try_resolve_dependencies_of(request, field, internal?) do
case Map.get(request, field) do
%UnresolvedField{deps: deps} ->
case try_resolve(request, deps, internal?) do
{:ok, new_request, notifications, remaining_deps} ->
{:skipped, new_request, notifications, remaining_deps}
error ->
error
end
_ ->
{:skipped, request, [], []}
end
end
defp notify_existing_value(request, field, value, internal?) do
if internal? do
{new_request, notifications} = notifications(request, field, value)
{:ok, new_request, notifications, []}
else
{:ok, request, [], []}
end
end
defp do_try_resolve_local(request, field, unresolved, internal?) do
%{deps: deps, resolver: resolver} = unresolved
with {:ok, new_request, notifications, []} <-
try_resolve(request, deps, internal?) do
resolver_context = resolver_context(new_request, deps)
log(request, "resolving #{field}")
case resolver.(resolver_context) do
{:ok, value} ->
{new_request, notifications} =
if internal? do
{new_request, new_notifications} = notifications(new_request, field, value)
notifications =
Enum.concat([
notifications,
new_notifications
])
{new_request, notifications}
else
{request, []}
end
new_request = Map.put(new_request, field, value)
{:ok, new_request, notifications, []}
{:error, error} ->
{:error, error}
end
end
end
defp get_dependency_data(request, dep) do
if local_dep?(request, dep) do
case Map.fetch(request, List.last(dep)) do
{:ok, %UnresolvedField{}} -> :error
{:ok, value} -> {:ok, value}
:error -> :error
end
else
Map.fetch(request.dependency_data, dep)
end
end
defp notifications(request, field, value) do
case Map.fetch(request.dependencies_to_send, field) do
{:ok, paths} ->
new_request = %{
request
| dependencies_to_send: Map.delete(request.dependencies_to_send, field)
}
notifications =
Enum.map(paths, fn path ->
{path, request.path, field, value}
end)
{new_request, notifications}
:error ->
{request, []}
end
end
defp resolver_context(request, deps) do
Enum.reduce(deps, %{}, fn dep, resolver_context ->
case get_dependency_data(request, dep) do
{:ok, value} ->
Ash.Engine.put_nested_key(resolver_context, dep, value)
:error ->
resolver_context
end
end)
end
defp local_dep?(request, dep) do
:lists.droplast(dep) == request.path
end
def add_initial_authorizer_state(request) do
request.resource
|> Ash.Resource.authorizers()
|> Enum.reduce(request, fn authorizer, request ->
if request.authorize? do
initial_state =
Authorizer.initial_state(
authorizer,
request.actor,
request.resource,
request.action,
request.verbose?
)
set_authorizer_state(request, authorizer, initial_state)
else
set_authorizer_state(request, authorizer, :authorized)
end
end)
end
defp missing_strict_check_dependencies?(authorizer, request) do
authorizer
|> Authorizer.strict_check_context(authorizer_state(request, authorizer))
|> Enum.filter(fn dependency ->
match?(%UnresolvedField{}, Map.get(request, dependency))
end)
end
defp missing_check_dependencies(authorizer, request) do
authorizer
|> Authorizer.check_context(authorizer_state(request, authorizer))
|> Enum.filter(fn dependency ->
match?(%UnresolvedField{}, Map.get(request, dependency))
end)
end
defp strict_check_authorizer(authorizer, request) do
log(request, "strict checking for #{inspect(authorizer)}")
authorizer_state = authorizer_state(request, authorizer)
keys = Authorizer.strict_check_context(authorizer, authorizer_state)
Authorizer.strict_check(authorizer, authorizer_state, Map.take(request, keys))
end
defp check_authorizer(authorizer, request) do
log(request, "checking for #{inspect(authorizer)}")
authorizer_state = authorizer_state(request, authorizer)
keys = Authorizer.check_context(authorizer, authorizer_state)
Authorizer.check(authorizer, authorizer_state, Map.take(request, keys))
end
defp set_authorizer_state(request, authorizer, authorizer_state) do
%{
request
| authorizer_state: Map.put(request.authorizer_state, authorizer, authorizer_state)
}
end
defp authorizer_state(request, authorizer) do
Map.get(request.authorizer_state, authorizer) || %{}
end
def validate_requests(requests) do
with :ok <- validate_unique_paths(requests),
:ok <- validate_dependencies(requests) do
:ok
else
{:error, {:impossible, path}} ->
{:error, ImpossiblePath.exception(impossible_path: path)}
{:error, paths} ->
{:error, DuplicatedPath.exception(paths: paths)}
end
end
defp validate_unique_paths(requests) do
requests
|> Enum.group_by(& &1.path)
|> Enum.filter(fn {_path, value} ->
Enum.count(value, & &1.write_to_data?) > 1
end)
|> case do
[] ->
:ok
invalid_paths ->
invalid_paths = Enum.map(invalid_paths, &elem(&1, 0))
{:error, invalid_paths}
end
end
defp validate_dependencies(requests) do
result =
Enum.reduce_while(requests, :ok, fn request, :ok ->
case do_build_dependencies(request, requests) do
:ok -> {:cont, :ok}
{:error, error} -> {:halt, {:error, error}}
end
end)
case result do
{:ok, requests} -> {:ok, Enum.reverse(requests)}
other -> other
end
end
defp do_build_dependencies(request, requests, trail \\ []) do
request
|> Map.from_struct()
|> Enum.reduce_while(:ok, fn
{_key, %UnresolvedField{deps: deps}}, :ok ->
case expand_deps(deps, requests, trail) do
{:error, error} ->
{:halt, {:error, error}}
:ok ->
{:cont, :ok}
end
_, :ok ->
{:cont, :ok}
end)
end
defp expand_deps([], _, _), do: :ok
defp expand_deps(deps, requests, trail) do
Enum.reduce_while(deps, :ok, fn dep, :ok ->
case do_expand_dep(dep, requests, trail) do
:ok -> {:cont, :ok}
{:error, error} -> {:halt, {:error, error}}
end
end)
end
defp do_expand_dep(dep, requests, trail) do
if dep in trail do
{:error, {:circular, dep}}
else
request_path = :lists.droplast(dep)
request_key = List.last(dep)
case Enum.find(requests, &(&1.path == request_path)) do
nil ->
{:error, {:impossible, dep}}
%{^request_key => %UnresolvedField{deps: nested_deps}} ->
case expand_deps(nested_deps, requests, [dep | trail]) do
:ok -> :ok
other -> other
end
_ ->
:ok
end
end
end
defp log(request, message, level \\ :debug)
defp log(%{verbose?: true, name: name} = request, message, level) do
if is_list(request.data) do
Logger.log(level, "#{name}: #{Enum.count(request.data)} #{message}")
else
Logger.log(level, "#{name}: #{message}")
end
end
defp log(_, _, _) do
false
end
end