Packages
ash_json_api
1.3.6
1.7.1
1.7.0
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0-rc.2
1.6.0-rc.1
1.6.0-rc.0
1.5.1
1.5.0
1.4.45
1.4.44
1.4.43
1.4.42
1.4.41
1.4.40
1.4.39
1.4.38
1.4.37
1.4.36
1.4.35
1.4.34
1.4.33
1.4.32
1.4.31
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.13
1.4.12
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.8
1.3.7
1.3.6
1.3.5
1.3.4
retired
1.3.3
1.3.2
1.3.1
1.3.0
1.2.2
1.2.1
1.2.0
1.1.2
1.1.1
1.1.0
1.0.0
1.0.0-rc.6
1.0.0-rc.5
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.34.2
0.34.1
0.34.0
0.33.1
0.33.0
0.32.1
0.32.0
0.31.3
0.31.2
retired
0.31.1
0.31.0
0.30.1
0.30.0-rc.4
0.30.0-rc.3
0.30.0-rc.2
0.30.0-rc.1
0.30.0-rc.0
0.29.1
0.29.0
0.28.6
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.6
0.27.5
0.27.1
0.27.0
0.25.0
0.24.4
0.24.2
0.24.1
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.1
0.10.0
0.9.0
0.8.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.4
0.2.3
0.2.1
0.2.0
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
The JSON:API extension for the Ash Framework.
Current section
Files
Jump to
Current section
Files
lib/ash_json_api/request.ex
defmodule AshJsonApi.Request do
@moduledoc false
require Logger
alias AshJsonApi.Error.{
InvalidBody,
InvalidField,
InvalidHeader,
InvalidIncludes,
InvalidQuery,
InvalidType,
UnacceptableMediaType,
UnsupportedMediaType
}
alias AshJsonApi.Includes
alias Plug.Conn
require Ash.Query
import Ash.PlugHelpers, only: [get_actor: 1, get_tenant: 1, get_context: 1]
defstruct [
:domain,
:action,
:resource,
:path_params,
:query_params,
:includes,
:includes_keyword,
:filter,
:resource_identifiers,
:body,
:url,
:json_api_prefix,
:actor,
:tenant,
:context,
:schema,
:req_headers,
:relationship,
:route,
attributes: %{},
arguments: %{},
filter_included: %{},
sort: [],
fields: %{},
field_inputs: %{},
errors: [],
all_domains: [],
# assigns is used by controllers to store state while piping
# the request around
assigns: %{}
]
@type t() :: %__MODULE__{}
@type error :: {:error, AshJsonApi.Error.InvalidIncludes.t()}
@spec from(
conn :: Plug.Conn.t(),
resource :: Ash.Resource.t(),
action :: atom,
Ash.Domain.t(),
list(Ash.Domain.t()),
AshJsonApi.Resource.Route.t()
) ::
t
def from(conn, resource, action, domain, all_domains, route) do
includes = Includes.Parser.parse_and_validate_includes(resource, conn.query_params)
%__MODULE__{
domain: domain,
resource: resource,
action: action,
includes: includes.allowed,
url: url(conn),
path_params: conn.path_params,
query_params: conn.query_params,
req_headers: conn.req_headers,
actor: get_actor(conn),
tenant: get_tenant(conn),
context: get_context(conn),
body: conn.body_params,
all_domains: all_domains,
schema: AshJsonApi.JsonSchema.route_schema(route, domain, resource),
relationship: route.relationship,
route: route,
json_api_prefix: AshJsonApi.Domain.Info.prefix(domain)
}
|> validate_params()
|> validate_href_schema()
|> validate_req_headers()
|> validate_body()
|> parse_fields()
|> parse_field_inputs()
|> parse_filter_included()
|> parse_includes()
|> parse_filter()
|> parse_sort()
|> parse_attributes()
|> parse_query_params()
|> parse_read_arguments()
|> parse_relationships()
|> parse_resource_identifiers()
end
def load_opts(request) do
[
actor: request.actor,
authorize?: AshJsonApi.Domain.Info.authorize?(request.domain),
tenant: request.tenant
]
end
def opts(request, merge \\ []) do
page_params = Map.get(request.assigns, :page)
opts = [
actor: request.actor,
domain: request.domain,
authorize?: AshJsonApi.Domain.Info.authorize?(request.domain),
tenant: request.tenant
]
opts =
if page_params && page_params != [] && request.action.pagination do
Keyword.put(opts, :page, page_params)
else
opts
end
Keyword.merge(merge, opts)
end
def assign(request, key, value) do
%{request | assigns: Map.put(request.assigns, key, value)}
end
def update_assign(request, key, default, function) do
%{request | assigns: Map.update(request.assigns, key, default, function)}
end
def add_error(request, error_or_errors, operation, resource \\ nil) do
resource = resource || request.resource
error_or_errors
|> List.wrap()
|> Enum.reduce(request, fn error, request ->
new_errors =
AshJsonApi.Error.to_json_api_errors(request.domain, resource, error, operation) ++
request.errors
%{request | errors: new_errors}
end)
end
defp validate_body(%{body: body, schema: %{"schema" => schema}} = request) do
json_xema = JsonXema.new(schema)
json_xema
|> JsonXema.validate(body)
|> case do
:ok ->
request
{:error, error} ->
add_error(request, InvalidBody.exception(json_xema_error: error), request.route.type)
end
end
defp validate_body(request) do
request
end
defp validate_req_headers(
%{req_headers: req_headers, schema: %{"headerSchema" => schema}} = request
) do
json_xema = JsonXema.new(schema)
headers = Enum.group_by(req_headers, &elem(&1, 0), &elem(&1, 1))
json_xema
|> JsonXema.validate(headers)
|> case do
:ok ->
request
{:error, error} ->
add_error(request, InvalidHeader.exception(json_xema_error: error), request.route.type)
end
|> validate_accept_header()
|> validate_content_type_header()
end
defp validate_content_type_header(%{req_headers: headers} = request) do
headers
|> Enum.filter(fn {header, _value} ->
header == "content-type"
end)
|> Enum.map(fn {_header, value} ->
Conn.Utils.media_type(value)
end)
|> case do
[] ->
request
values ->
any_content_type_supported? =
Enum.any?(values, fn
{:ok, "*", _, _} ->
true
_ ->
false
end)
json_api_content_type_supported? =
Enum.all?(values, fn
{:ok, "*", _, _} ->
true
{:ok, "application", "vnd.api+json", params} ->
Application.get_env(:ash_json_api, :allow_all_media_type_params?, false) ||
valid_header_params?(params)
_ ->
false
end)
if any_content_type_supported? || json_api_content_type_supported? do
request
else
add_error(request, UnacceptableMediaType.exception([]), request.route.type)
end
end
end
defp validate_accept_header(%{req_headers: headers} = request) do
accepts_json_api? =
headers
|> Enum.filter(fn {header, _value} -> header == "accept" end)
|> Enum.flat_map(fn {_, value} ->
String.split(value, ",")
end)
|> Enum.map(fn
"" ->
""
value ->
Conn.Utils.media_type(value)
end)
|> Enum.filter(fn
{:ok, "application", "vnd.api+json", _} -> true
_ -> false
end)
|> case do
[] ->
true
headers ->
Enum.any?(headers, fn {:ok, "application", "vnd.api+json", params} ->
Application.get_env(:ash_json_api, :allow_all_media_type_params?, false) ||
valid_header_params?(params)
end)
end
if accepts_json_api? do
request
else
add_error(request, UnsupportedMediaType.exception([]), request.route.type)
end
end
defp valid_header_params?(params) do
params
|> Map.keys()
|> Enum.sort()
|> case do
[] ->
true
["ext"] ->
true
["profile"] ->
true
["ext", "profile"] ->
true
_ ->
false
end
end
defp validate_params(%{query_params: query_params, path_params: path_params} = request) do
if Enum.any?(Map.keys(query_params), &Map.has_key?(path_params, &1)) do
add_error(request, "conflict path and query params", request.route.type)
else
request
end
end
defp validate_href_schema(%{schema: nil} = request) do
add_error(request, "no schema found", request.route.type)
end
defp validate_href_schema(
%{
schema: %{"hrefSchema" => schema},
query_params: query_params,
path_params: path_params
} = request
) do
json_xema = JsonXema.new(schema)
case JsonXema.validate(json_xema, Map.merge(path_params, query_params)) do
:ok ->
request
{:error, error} ->
add_error(request, InvalidQuery.exception(json_xema_error: error), request.route.type)
end
end
defp parse_filter_included(
%{resource: resource, query_params: %{"filter_included" => filter_included}} = request
)
when is_map(filter_included) do
Enum.reduce(filter_included, request, fn {relationship_path, filter_statement}, request ->
path = String.split(relationship_path)
case public_related(resource, path) do
nil ->
add_error(request, "Invalid filter included", request.route.type)
related ->
if AshJsonApi.Resource.Info.derive_filter?(related) do
path = Enum.map(path, &String.to_existing_atom/1)
%{request | filter_included: Map.put(request.filter_included, path, filter_statement)}
else
add_error(request, "Invalid filter included", request.route.type)
end
end
end)
end
defp parse_filter_included(request), do: request
defp parse_fields(%{resource: resource, query_params: %{"fields" => fields}} = request)
when is_binary(fields) do
add_fields(request, resource, fields, false)
end
defp parse_fields(%{query_params: %{"fields" => fields}} = request) when is_map(fields) do
Enum.reduce(fields, request, fn {type, fields}, request ->
# Get all relevant resources better here, i.e using the includes keyword
# this could miss relationships to things in other apis
request.domain
|> Ash.Domain.Info.resources()
|> Enum.find(&(AshJsonApi.Resource.Info.type(&1) == type))
|> case do
nil ->
add_error(request, InvalidType.exception(type: type), request.route.type)
resource ->
add_fields(request, resource, fields, true)
end
end)
end
defp parse_fields(request), do: request
defp parse_field_inputs(%{query_params: %{"field_inputs" => field_inputs}} = request)
when is_map(field_inputs) do
Enum.reduce(field_inputs, request, fn {type, inputs}, request ->
add_field_inputs(request, type, inputs)
end)
end
defp parse_field_inputs(request), do: request
if function_exported?(Ash.Resource.Info, :public_related, 2) do
defp public_related(resource, relationship) do
Ash.Resource.Info.public_related(resource, relationship)
end
else
defp public_related(resource, relationship) when not is_list(relationship) do
public_related(resource, [relationship])
end
defp public_related(resource, []), do: resource
defp public_related(resource, [path | rest]) do
case Ash.Resource.Info.public_relationship(resource, path) do
%{destination: destination} -> public_related(destination, rest)
nil -> nil
end
end
end
defp add_field_inputs(request, type, field_inputs) do
resource =
request.domain
|> Ash.Domain.Info.resources()
|> Enum.find(&(AshJsonApi.Resource.Info.type(&1) == type))
Enum.reduce(field_inputs, request, fn {calculation_name, arguments}, request ->
case Ash.Resource.Info.public_calculation(resource, calculation_name) do
nil ->
add_error(
request,
InvalidField.exception(type: type, parameter?: true, field: calculation_name),
request.route.type
)
calculation ->
Enum.reduce(arguments, request, fn {arg_name, arg_value}, request ->
calculation_arg =
Enum.find(calculation.arguments, fn argument ->
to_string(argument.name) == arg_name
end)
case calculation_arg do
nil ->
add_error(
request,
InvalidField.exception(type: type, parameter?: true, field: arg_name),
request.route.type
)
_ ->
cur_resource_field_inputs = Map.get(request.field_inputs, resource, %{})
cur_calculation_field_inputs =
Map.get(cur_resource_field_inputs, calculation.name, %{})
updated_calculation_field_inputs =
Map.put(cur_calculation_field_inputs, calculation_arg.name, arg_value)
updated_resource_field_inputs =
Map.put(
cur_resource_field_inputs,
calculation.name,
updated_calculation_field_inputs
)
updated_field_inputs =
Map.put(request.field_inputs, resource, updated_resource_field_inputs)
%{request | field_inputs: updated_field_inputs}
end
end)
end
end)
end
defp add_fields(request, resource, fields, parameter?) do
type = AshJsonApi.Resource.Info.type(resource)
fields
|> String.split(",")
|> Enum.reduce(request, fn key, request ->
cond do
attr = Ash.Resource.Info.public_attribute(resource, key) ->
fields = Map.update(request.fields, resource, [attr.name], &[attr.name | &1])
%{request | fields: fields}
rel = Ash.Resource.Info.public_relationship(resource, key) ->
fields = Map.update(request.fields, resource, [rel.name], &[rel.name | &1])
%{request | fields: fields}
agg = Ash.Resource.Info.public_aggregate(resource, key) ->
fields = Map.update(request.fields, resource, [agg.name], &[agg.name | &1])
%{request | fields: fields}
calc = Ash.Resource.Info.public_calculation(resource, key) ->
fields = Map.update(request.fields, resource, [calc.name], &[calc.name | &1])
%{request | fields: fields}
true ->
add_error(
request,
InvalidField.exception(type: type, parameter?: parameter?, field: key),
request.route.type
)
end
end)
end
defp parse_filter(%{query_params: %{"filter" => filter}} = request)
when is_map(filter) do
if request.action.type == :read && request.route.derive_filter? &&
AshJsonApi.Resource.Info.derive_filter?(request.resource) do
%{request | filter: filter}
else
%{request | arguments: Map.put(request.arguments, :filter, filter)}
end
end
defp parse_filter(%{query_params: %{"filter" => filter}} = request) do
if request.action.type == :read && request.route.derive_filter? &&
AshJsonApi.Resource.Info.derive_filter?(request.resource) do
add_error(request, "invalid filter", request.route.type)
else
%{request | arguments: Map.put(request.arguments, :filter, filter)}
end
end
defp parse_filter(request), do: %{request | filter: %{}}
defp parse_sort(%{query_params: %{"sort" => sort_string}, resource: resource} = request)
when is_bitstring(sort_string) do
if request.action.type == :read && request.route.derive_sort? &&
AshJsonApi.Resource.Info.derive_sort?(request.resource) do
sort_string
|> String.split(",")
|> case do
[] ->
request
sort ->
sort
|> Enum.reverse()
|> Enum.reduce(request, fn field, request ->
{order, field_name} = trim_sort_order(field)
cond do
attr = Ash.Resource.Info.public_attribute(resource, field_name) ->
%{request | sort: [{attr.name, order} | request.sort]}
agg = Ash.Resource.Info.public_aggregate(resource, field_name) ->
%{request | sort: [{agg.name, order} | request.sort]}
calc = Ash.Resource.Info.public_calculation(resource, field_name) ->
%{request | sort: [{calc.name, order} | request.sort]}
true ->
add_error(request, "invalid sort #{field}", request.route.type)
end
end)
end
else
%{request | arguments: Map.put(request.arguments, :sort, sort_string)}
end
end
defp parse_sort(%{query_params: %{"sort" => sort}} = request) do
if request.action.type == :read && request.route.derive_sort? &&
AshJsonApi.Resource.Info.derive_sort?(request.resource) do
add_error(request, "invalid sort string", request.route.type)
else
%{request | arguments: Map.put(request.arguments, :sort, sort)}
end
end
defp parse_sort(request), do: %{request | sort: []}
defp trim_sort_order("-" <> field_name) do
{:desc, field_name}
end
defp trim_sort_order(field_name) do
{:asc, field_name}
end
defp parse_includes(%{resource: resource, query_params: query_params} = request) do
includes = Includes.Parser.parse_and_validate_includes(resource, query_params)
case includes do
%{allowed: allowed, disallowed: []} ->
%{request | includes: includes, includes_keyword: includes_to_keyword(request, allowed)}
%{allowed: _allowed, disallowed: disallowed} ->
add_error(request, InvalidIncludes.exception(includes: disallowed), request.route.type)
end
end
defp includes_to_keyword(request, includes) do
includes
|> Enum.reduce([], fn path, acc ->
put_path(acc, path)
end)
|> set_include_queries(
request.fields,
request.field_inputs,
request.filter_included,
request.resource
)
end
defp set_include_queries(includes, fields, field_inputs, filters, resource, path \\ []) do
Enum.map(includes, fn {key, nested} ->
related = public_related(resource, key)
nested = set_include_queries(nested, fields, field_inputs, filters, related, path ++ [key])
related_field_inputs = Map.get(field_inputs, related, %{})
load =
fields
|> Map.get(related)
|> Kernel.||([])
|> Enum.map(fn field ->
case Map.get(related_field_inputs, field) do
nil -> field
value -> {field, value}
end
end)
|> Kernel.++(nested)
new_query =
related
|> Ash.Query.new()
|> Ash.Query.load(load)
filtered_query =
case Map.fetch(filters, path ++ [key]) do
{:ok, filter} ->
Ash.Query.filter(new_query, ^filter)
:error ->
new_query
end
{key, filtered_query}
end)
end
defp put_path(keyword, [key]) do
atom_key = atomize(key)
Keyword.put_new(keyword, atom_key, [])
end
defp put_path(keyword, [key | rest]) do
atom_key = atomize(key)
keyword
|> Keyword.put_new(atom_key, [])
|> Keyword.update!(atom_key, &put_path(&1, rest))
end
defp atomize(atom) when is_atom(atom), do: atom
defp atomize(string) when is_bitstring(string) do
String.to_existing_atom(string)
end
defp parse_query_params(%{route: route} = request) do
route.query_params
|> List.wrap()
|> Enum.reduce(request, fn query_param, request ->
case Map.fetch(request.query_params, to_string(query_param)) do
{:ok, value} -> %{request | arguments: Map.put(request.arguments, query_param, value)}
:error -> request
end
end)
end
defp parse_attributes(
%{route: %{type: :route}, action: action, body: %{"data" => attributes}} = request
)
when is_map(attributes) do
Enum.reduce(attributes, request, fn {key, value}, request ->
if arg =
Enum.find(action.arguments, fn argument ->
to_string(argument.name) == key
end) do
%{request | arguments: Map.put(request.arguments || %{}, arg.name, value)}
else
request
end
end)
end
defp parse_attributes(
%{action: action, body: %{"data" => %{"attributes" => attributes}}} =
request
)
when is_map(attributes) do
Enum.reduce(attributes, request, fn {key, value}, request ->
cond do
arg =
Enum.find(action.arguments, fn argument ->
to_string(argument.name) == key
end) ->
%{request | arguments: Map.put(request.arguments || %{}, arg.name, value)}
name = Enum.find(action.accept, &(to_string(&1) == key)) ->
if action.type == :action do
request
else
%{request | attributes: Map.put(request.attributes || %{}, name, value)}
end
true ->
request
end
end)
end
defp parse_attributes(request), do: %{request | attributes: %{}, arguments: %{}}
defp parse_read_arguments(%{action: %{type: :read} = action} = request) do
action.arguments
|> Enum.filter(& &1.public?)
|> Enum.reduce(request, fn argument, request ->
name = to_string(argument.name)
with :error <- Map.fetch(request.query_params, name),
:error <- Map.fetch(request.path_params, name) do
request
else
{:ok, value} ->
%{request | arguments: Map.put(request.arguments, argument.name, value)}
end
end)
end
defp parse_read_arguments(request), do: request
defp parse_relationships(
%{
body: %{"data" => %{"relationships" => relationships}},
action: action,
route: %{
relationship_arguments: relationship_arguments
}
} = request
)
when is_map(relationships) do
Enum.reduce(relationships, request, fn {name, value}, request ->
with %{"data" => data} when is_map(data) or is_list(data) <- value,
arg when not is_nil(arg) <-
Enum.find(
action.arguments,
&(to_string(&1.name) == name &&
has_relationship_argument?(relationship_arguments, &1.name))
),
{:ok, change_value} <-
relationship_change_value(data) do
case find_relationship_argument(relationship_arguments, arg.name) do
{:id, _arg} ->
%{
request
| arguments: Map.put(request.arguments || %{}, arg.name, change_value["id"])
}
_ ->
%{
request
| arguments: Map.put(request.arguments || %{}, arg.name, change_value)
}
end
else
_ ->
add_error(request, "invalid relationship input: #{name}", request.route.type)
end
end)
end
defp parse_relationships(request), do: request
defp find_relationship_argument(relationship_arguments, name) do
Enum.find(relationship_arguments, fn
{:id, ^name} -> true
^name -> true
_ -> false
end)
end
defp has_relationship_argument?(relationship_arguments, name) do
Enum.any?(relationship_arguments, fn
{:id, ^name} -> true
^name -> true
_ -> false
end)
end
defp parse_resource_identifiers(%{body: %{"data" => data}} = request)
when is_list(data) do
identifiers =
for %{"id" => id, "type" => _type} = identifier <- data do
case Map.fetch(identifier, "meta") do
{:ok, meta} -> {%{id: id}, meta}
_ -> %{id: id}
end
end
%{request | resource_identifiers: identifiers}
end
defp parse_resource_identifiers(%{body: %{"data" => data}} = request)
when is_nil(data) do
request
end
defp parse_resource_identifiers(%{body: %{"data" => %{"id" => id, "type" => _type}}} = request) do
%{request | resource_identifiers: %{id: id}}
end
defp parse_resource_identifiers(request) do
%{request | resource_identifiers: nil}
end
defp relationship_change_value(value)
when is_list(value) do
value
|> Stream.map(&relationship_change_value(&1))
|> Enum.reduce({:ok, []}, fn
{:ok, change}, {:ok, changes} ->
{:ok, [change | changes]}
{:error, change}, _ ->
{:error, change}
_, {:error, change} ->
{:error, change}
end)
|> case do
{:ok, changes} ->
{:ok, Enum.reverse(changes)}
{:error, error} ->
{:error, error}
end
end
defp relationship_change_value(%{"id" => id} = value) do
case Map.fetch(value, "meta") do
{:ok, meta} -> {:ok, Map.put(meta, "id", id)}
_ -> {:ok, %{"id" => id}}
end
end
defp relationship_change_value(value) when value in [nil, %{}] do
{:ok, nil}
end
defp relationship_change_value(_) do
:error
end
defp url(conn) do
Conn.request_url(conn)
end
end