defmodule AshJsonApi.Serializer do @moduledoc false alias Plug.Conn def serialize_to_many_relationship(request, source_record, relationship, records, meta) do links = %{self: at_host(request, request.url)} |> add_related_link(request, source_record, relationship) %{ links: links, data: Enum.map( List.wrap(records), &serialize_relationship_data(&1, source_record, relationship) ), meta: meta } |> Jason.encode!() end def serialize_to_one_relationship(request, source_record, relationship, record) do links = %{self: at_host(request, request.url)} |> add_related_link(request, source_record, relationship) %{ links: links, data: serialize_relationship_data(record, source_record, relationship) } |> Jason.encode!() end @spec serialize_many( AshJsonApi.Request.t(), Ash.Page.page() | list(Ash.Resource.record()), Keyword.t(), map() ) :: String.t() def serialize_many(request, paginator, includes, meta) do links = many_links(request, paginator) meta = add_page_metadata(meta, paginator) data = page_data(paginator, request) json_api = %{version: "1.0"} %{data: data, jsonapi: json_api, links: links} |> add_includes(request, includes) |> add_top_level_meta(meta) |> Jason.encode!() end defp page_data(%struct{} = page, request) when struct in [Ash.Page.Offset, Ash.Page.Keyset] do page_data(page.results, request) end defp page_data(data, request) when is_list(data) do Enum.map(data, &serialize_one_record(request, &1)) end # Adds page level metadata, like total count of records defp add_page_metadata(meta, %struct{} = page) when struct in [Ash.Page.Offset, Ash.Page.Keyset] do page_meta = if page.count do %{page: %{total: page.count}} else %{page: %{}} end Map.merge(meta, page_meta) end # This is added because some tests on `Test.Acceptance.IndexTest` fail # because its passing in a list of resources instead of a paginator defp add_page_metadata(meta, _), do: meta def serialize_one(request, record, includes, meta \\ nil) def serialize_one(request, nil, _, meta) do json_api = %{version: "1.0"} links = one_links(request) %{data: nil, jsonapi: json_api, links: links} |> add_top_level_meta(meta) |> Jason.encode!() end def serialize_one(request, record, includes, meta) do data = serialize_one_record(request, record) json_api = %{version: "1.0"} links = one_links(request) %{data: data, jsonapi: json_api, links: links} |> add_includes(request, includes) |> add_top_level_meta(meta) |> Jason.encode!() end def serialize_errors(request, error_or_errors, meta \\ nil) do json_api = %{version: "1.0"} errors = error_or_errors |> List.wrap() |> Enum.map(&serialize_one_error(&1, request)) %{errors: errors, jsonapi: json_api} |> add_top_level_meta(meta) |> Jason.encode!() end @doc false def serialize_one_error(error, request) do %{} |> add_if_defined(:id, error.id) |> add_if_defined(:status, to_string(error.status_code)) |> add_if_defined(:code, error.code) |> add_if_defined(:title, error.title) |> add_if_defined(:detail, error.detail) |> add_if_defined([:source, :pointer], error.source_pointer) |> add_if_defined([:source, :parameter], error.source_parameter) |> add_if_defined(:meta, parse_error(error.meta)) |> add_about_link(error.about, request) end defp add_about_link(payload, _, nil) do payload end defp add_about_link(payload, about, request) when is_bitstring(about) do url = at_host(request, about) Map.put(payload, :links, %{about: url}) end defp add_about_link(payload, _, _request), do: payload defp add_if_defined(params, _, :undefined) do params end defp add_if_defined(params, [key1, key2], value) do params |> Map.put_new(key1, %{}) |> Map.update!(key1, &Map.put(&1, key2, value)) end defp add_if_defined(params, key, value) do Map.put(params, key, value) end defp serialize_relationship_data(record, source_record, relationship) do %{ id: AshJsonApi.Resource.encode_primary_key(record), type: AshJsonApi.Resource.Info.type(relationship.destination) } |> add_relationship_meta(record, source_record, relationship) end defp add_relationship_meta(payload, _row, _source_record, _relationship) do payload # case relationship.join_attributes do # [] -> # payload # attributes -> # destination_value = Map.get(row, relationship.destination_attribute) # source_record # |> Map.get(relationship.join_relationship) # |> Enum.find(fn join_row -> # Map.get(join_row, relationship.destination_attribute_on_join_resource) == destination_value # end) # |> case do # nil -> # Map.put(payload, :meta, %{}) # join_row -> # Map.put(payload, :meta, Map.take(join_row, attributes)) # end # end end defp add_top_level_meta(payload, meta) when is_map(meta), do: Map.put(payload, :meta, meta) defp add_top_level_meta(payload, _), do: payload defp add_includes(payload, %{includes_keyword: []}, _), do: payload defp add_includes(payload, request, includes) do includes = Enum.map(includes, &serialize_one_record(request, &1, top_level?: false)) Map.put(payload, :included, includes) end defp many_links(request, %{results: _} = paginator) do uri = URI.parse(request.url) query = if uri.query do Conn.Query.decode(uri.query) else %{} end %{ first: first_link(uri, query, paginator), self: many_self_link(uri, query, paginator) } |> add_last_link(uri, query, paginator) |> add_prev_link(uri, query, paginator) |> add_next_link(uri, query, paginator) end defp many_links(%{url: url}, _), do: %{self: url} defp first_link(uri, query, paginator) do paginator = case paginator do %{after: _} -> %{paginator | after: nil, before: nil} %{offset: _} -> %{paginator | offset: nil} end new_query = query |> put_page_params(paginator) |> put_count_param(paginator) |> Conn.Query.encode() uri |> put_query(new_query) |> URI.to_string() |> encode_link() end defp many_self_link(uri, query, paginator) do new_query = query |> put_page_params(paginator) |> put_count_param(paginator) |> Conn.Query.encode() uri |> put_query(new_query) |> URI.to_string() |> encode_link() end defp put_count_param(query, %{count: count}) when is_integer(count) do Map.update(query, "page", %{count: true}, &Map.put(&1, :count, true)) end defp put_count_param(query, _), do: query defp put_page_params(query, %Ash.Page.Offset{} = paginator) do %{limit: limit, offset: offset} = paginator cond do is_nil(limit) and offset in [0, nil] -> query offset in [0, nil] -> Map.put(query, "page", %{ limit: limit }) is_nil(limit) -> Map.put(query, "page", %{ offset: offset }) true -> Map.put(query, "page", %{ limit: limit, offset: offset }) end end defp put_page_params(query, %Ash.Page.Keyset{} = paginator) do %{after: after_cursor, before: before_cursor, limit: limit} = paginator cond do is_nil(limit) -> Map.put(query, "page", %{after: after_cursor}) is_binary(after_cursor) -> Map.put(query, "page", %{after: after_cursor, limit: limit}) is_nil(limit) and is_binary(before_cursor) -> Map.put(query, "page", %{before: before_cursor}) is_binary(before_cursor) -> Map.put(query, "page", %{before: before_cursor, limit: limit}) true -> Map.put(query, "page", %{limit: limit}) end end defp put_page_params(query, _), do: query # Offset pagination defp add_next_link(links, uri, query, %Ash.Page.Offset{} = paginator) do %{results: results, count: count, offset: offset, limit: limit} = paginator cond do not is_nil(count) and not is_nil(offset) and offset + limit >= count -> Map.put(links, :next, nil) not is_nil(count) and is_nil(offset) and limit >= count -> Map.put(links, :next, nil) Enum.count(results) < limit -> Map.put(links, :next, nil) true -> Map.put(links, :next, build_link(uri, query, next_page(paginator))) end end ## Cursor pagination defp add_next_link(links, uri, query, %Ash.Page.Keyset{} = paginator) do case paginator do # This is a query for the first page, no cursors are set %{results: results, after: nil, before: nil, more?: true} -> paginator = %{paginator | after: List.last(results).__metadata__.keyset} Map.put(links, :next, build_link(uri, query, paginator)) # No results at all %{after: nil, before: nil, more?: false} -> Map.put(links, :next, nil) # Pagination forward with after, but no more results %{results: [], after: _, before: nil, more?: false} -> Map.put(links, :next, nil) # Pagination forward with after, there are results # If there is more, add next link, else next link is nil %{results: results, after: _, before: nil, more?: true} -> paginator = %{paginator | after: List.last(results).__metadata__.keyset} Map.put(links, :next, build_link(uri, query, paginator)) # Pagination backward, there are results # Since we are paginating backwards from a result, we assume there will be more %{results: results, after: nil} -> paginator = %{paginator | after: List.last(results).__metadata__.keyset} Map.put( links, :next, build_link(uri, query, paginator) ) _ -> Map.put(links, :next, nil) end end defp next_page(%Ash.Page.Offset{limit: nil} = paginator), do: paginator defp next_page(%Ash.Page.Offset{limit: limit, offset: offset} = paginator), do: %{paginator | offset: limit + (offset || 0)} # Offset pagination defp add_prev_link(links, uri, query, %Ash.Page.Offset{} = paginator) do if paginator.offset in [0, nil] do Map.put(links, :prev, nil) else Map.put(links, :prev, build_link(uri, query, prev_page(paginator))) end end ## Cursor pagination defp add_prev_link(links, uri, query, %Ash.Page.Keyset{} = paginator) do case paginator do # First page in request, there should be no previous links since its fetching the latest data at the point in time %{before: nil, after: nil} -> Map.put(links, :prev, nil) # When paginating with before, and there are no results, prev is nil %{results: [], before: _, after: nil} -> Map.put(links, :prev, nil) # If there are results and paginating with before, # Previous link is set, unless there is no more in that direction %{results: results, more?: true, after: nil} -> paginator = Map.put(paginator, :before, List.first(results).__metadata__.keyset) Map.put(links, :prev, build_link(uri, query, paginator)) # If paginating fowards with after, we set the previous link to the first in the result set %{results: results, before: nil} -> paginator = paginator |> Map.put(:before, List.first(results).__metadata__.keyset) |> Map.put(:after, nil) Map.put(links, :prev, build_link(uri, query, paginator)) _ -> Map.put(links, :prev, nil) end end defp build_link(uri, query, paginator) do new_query = query |> put_page_params(paginator) |> put_count_param(paginator) |> Conn.Query.encode() uri |> put_query(new_query) |> URI.to_string() |> encode_link() end defp prev_page(%Ash.Page.Offset{} = paginator) do offset = if paginator.limit do max(paginator.offset - (paginator.limit || 0), 0) else 0 end %{paginator | offset: offset} end defp add_last_link(links, _uri, _query, %{count: nil}) do links end defp add_last_link(links, uri, query, %Ash.Page.Offset{count: total, limit: limit} = paginator) do new_query = query |> Map.put("page", %{ limit: limit, offset: total - limit }) |> put_count_param(paginator) |> Conn.Query.encode() link = uri |> put_query(new_query) |> URI.to_string() |> encode_link() Map.put(links, "last", link) end defp add_last_link(links, _, _, _), do: links defp one_links(request) do %{ self: encode_link(request.url) } end defp serialize_one_record(request, %resource{} = record, opts \\ []) do %{ id: AshJsonApi.Resource.encode_primary_key(record), type: AshJsonApi.Resource.Info.type(resource), attributes: serialize_attributes(request, record, top_level?: Keyword.get(opts, :top_level?, true)), relationships: serialize_relationships(request, record), links: %{} |> add_one_record_self_link(request, record) } |> add_meta(record) end defp add_one_record_self_link(links, request, %resource{} = record) do resource |> AshJsonApi.Resource.route(request.all_domains, %{type: :get, primary?: true}) |> case do nil -> links %{route: route} -> link = request |> with_path_params(%{"id" => AshJsonApi.Resource.encode_primary_key(record)}) |> at_host(route) Map.put(links, "self", link) end end defp add_meta(json_record, record) do meta = %{} |> add_keyset(record) Map.put(json_record, :meta, meta) end defp add_keyset(meta, %{metadata: %{keyset: keyset}}) do Map.put(meta, :keyset, keyset) end defp add_keyset(meta, _), do: meta defp serialize_relationships(request, %resource{} = record) do resource |> Ash.Resource.Info.public_relationships() |> Enum.into(%{}, fn relationship -> value = %{ links: relationship_links(record, request, relationship), meta: %{} } |> add_linkage(record, relationship) {relationship.name, value} end) end defp relationship_links(record, request, relationship) do %{} |> add_relationship_link(request, record, relationship) |> add_related_link(request, record, relationship) end defp add_relationship_link(links, request, %resource{} = record, relationship) do resource |> AshJsonApi.Resource.route(request.all_domains, %{ relationship: relationship.name, primary?: true, action_type: :relationship }) |> case do nil -> links %{route: route} -> link = request |> with_path_params(%{"id" => AshJsonApi.Resource.encode_primary_key(record)}) |> at_host(route) Map.put(links, "self", link) end end defp add_related_link(links, request, %resource{} = record, relationship) do resource |> AshJsonApi.Resource.route(request.all_domains, %{ relationship: relationship.name, primary?: true, action_type: :get_related }) |> case do nil -> links %{route: route} -> link = request |> with_path_params(%{"id" => AshJsonApi.Resource.encode_primary_key(record)}) |> at_host(route) Map.put(links, "related", link) end end defp add_linkage(payload, record, %{destination: destination, cardinality: :one, name: name}) do case record do %{__linkage__: %{^name => [record | _]}} -> Map.put(payload, :data, %{ id: AshJsonApi.Resource.encode_primary_key(record), type: AshJsonApi.Resource.Info.type(destination) }) %{__linkage__: %{^name => []}} -> Map.put(payload, :data, nil) # There could be another case here if a bug in the system gave us a list # of more than one shouldn't happen though _ -> payload end end defp add_linkage( payload, record, %{destination: destination, cardinality: :many, name: name} = relationship ) do case record do %{__linkage__: %{^name => linkage}} -> type = AshJsonApi.Resource.Info.type(destination) Map.put( payload, :data, linkage |> Enum.map( &(%{id: AshJsonApi.Resource.encode_primary_key(&1), type: type} |> add_relationship_meta(&1, record, relationship)) ) |> Enum.uniq_by(& &1.id) ) _ -> payload end end defp with_path_params(request, params) do Map.update!(request, :path_params, &Map.merge(&1, params)) end defp at_host(request, route) do path = if request.json_api_prefix do if route do Path.join(request.json_api_prefix, route) else request.json_api_prefix end else route || "" end request.url |> URI.parse() |> Map.put(:query, nil) |> Map.put(:path, "/" <> path) |> Map.update!(:path, &replace_path_params(&1, request)) |> URI.to_string() |> encode_link() end defp put_query(uri, query) do if query == "" do Map.put(uri, :query, nil) else Map.put(uri, :query, query) end end defp replace_path_params(path, request) do path |> Path.split() |> Enum.map(fn path_element -> if String.starts_with?(path_element, ":") do Map.get(request.path_params, String.slice(path_element, 1..-1//1)) || "" else path_element end end) |> Path.join() end defp field_type_from_aggregate(resource, agg) do if agg.field do related = Ash.Resource.Info.related(resource, agg.relationship_path) field = Ash.Resource.Info.field(related, agg.field) if field do {field.type, field.constraints} end end end defp serialize_attributes(_, nil, _opts), do: nil defp serialize_attributes(request, records, opts) when is_list(records) do Enum.map(records, &serialize_attributes(request, &1, opts)) end defp serialize_attributes(request, %resource{} = record, opts) do load = Keyword.get(opts, :load, []) load_fields = load |> Enum.map(fn {key, _} -> key key -> key end) fields = if opts[:top_level?] do Map.get(request.fields, resource) || Map.get(request.route, :default_fields) || default_attributes(resource) else Map.get(request.fields, resource) || default_attributes(resource) end |> Enum.concat(load_fields) Enum.reduce(fields, %{}, fn field_name, acc -> field = Ash.Resource.Info.field(resource, field_name) {type, constraints} = case field do %Ash.Resource.Aggregate{} = agg -> case field_type_from_aggregate(resource, agg) do {field_type, field_constraints} -> {:ok, type, constraints} = Ash.Query.Aggregate.kind_to_type(agg.kind, field_type, field_constraints) {type, constraints} _ -> {:ok, type, constraints} = Ash.Query.Aggregate.kind_to_type(agg.kind, nil, nil) {type, constraints} end %relationship{destination: destination} when relationship in [ Ash.Resource.Relationships.HasMany, Ash.Resource.Relationships.ManyToMany ] -> {{:array, destination}, []} %relationship{destination: destination} when relationship in [ Ash.Resource.Relationships.HasOne, Ash.Resource.Relationships.BelongsTo ] -> {destination, []} nil -> {:string, []} attr -> {attr.type, attr.constraints} end cond do AshJsonApi.Resource.only_primary_key?(resource, field_name) && Keyword.get(opts, :skip_only_primary_key?, true) -> acc !field -> acc match?(%Ash.Resource.Relationships.HasMany{}, field) && match?(%Ash.NotLoaded{}, Map.get(record, field.name)) -> acc match?(%Ash.Resource.Relationships.HasOne{}, field) && match?(%Ash.NotLoaded{}, Map.get(record, field.name)) -> acc match?(%Ash.Resource.Relationships.BelongsTo{}, field) && match?(%Ash.NotLoaded{}, Map.get(record, field.name)) -> acc match?(%Ash.Resource.Relationships.ManyToMany{}, field) && match?(%Ash.NotLoaded{}, Map.get(record, field.name)) -> acc match?(%Ash.Resource.Calculation{}, field) && match?(%Ash.NotLoaded{}, Map.get(record, field.name)) -> acc match?(%Ash.Resource.Aggregate{}, field) && match?(%Ash.NotLoaded{}, Map.get(record, field.name)) -> acc true -> new_load = load |> Enum.find_value([], fn {^field_name, value} -> value _ -> nil end) new_opts = opts |> Keyword.put(:load, new_load) value = serialize_value( Map.get(record, field.name), type, constraints, request.domain, new_opts ) if not is_nil(value) or include_nil_values?(request, record) do Map.put(acc, field.name, value) else acc end end end) end @doc false def serialize_value(value, type, constraints, domain, opts \\ []) def serialize_value(nil, _, _, _, _), do: nil def serialize_value(value, {:array, type}, constraints, domain, opts) when is_list(value) do Enum.map(value, &serialize_value(&1, type, constraints[:items] || [], domain, opts)) end def serialize_value(value, Ash.Type.Decimal, _constraints, _domain, _opts) do Decimal.to_string(value) end def serialize_value(value, type, constraints, domain, opts) do {type, constraints} = flatten_new_type(type, constraints || []) opts = [skip_only_primary_key?: false, top_level?: false] |> Keyword.merge(opts) with Ash.Type.Struct <- type, instance_of when not is_nil(instance_of) <- constraints[:instance_of], true <- Ash.Resource.Info.resource?(instance_of) do req = %{fields: %{}, route: %{}, domain: domain} serialize_attributes(req, value, opts) else _ -> if Ash.Resource.Info.resource?(type) do req = %{fields: %{}, route: %{}, domain: domain} serialize_attributes(req, value, opts) else value end end end defp flatten_new_type(type, constraints) do if Ash.Type.NewType.new_type?(type) do new_constraints = Ash.Type.NewType.constraints(type, constraints) new_type = Ash.Type.NewType.subtype_of(type) {new_type, new_constraints} else {type, constraints} end end defp include_nil_values?(request, %resource{} = _record) do # Whether we include nil values in the output depends on the include_nil_values? # setting of the resource, or if it isn't set the include_nil_values? setting of # the API. case AshJsonApi.Resource.Info.include_nil_values?(resource) do nil -> AshJsonApi.Domain.Info.include_nil_values?(request.domain) val -> val end end # make this always false in 2.0 if Application.compile_env(:ash_json_api, :show_public_calculations_when_loaded?, true) do defp default_attributes(resource) do AshJsonApi.Resource.Info.default_fields(resource) || resource |> Ash.Resource.Info.public_attributes() |> Enum.concat(Ash.Resource.Info.public_calculations(resource)) |> Enum.map(& &1.name) end else defp default_attributes(resource) do AshJsonApi.Resource.Info.default_fields(resource) || resource |> Ash.Resource.Info.public_attributes() |> Enum.map(& &1.name) end end defp encode_link(value) do value # value # |> URI.parse() # |> Map.update(:query, nil, fn query -> # URI.encode_www_form(query) # end) # |> URI.to_string() end defp parse_error(%{match: %Regex{} = match} = error) do %{error | match: Regex.source(match)} end defp parse_error(error), do: error end