defmodule JSONAPIPlug.Resource do
@moduledoc """
A Resource is simply a module that describes how to render your data as JSON:API resources.
You can implement a resource by "use-ing" the `JSONAPIPlug.Resource` module, which is recommeded, or
by adopting the `JSONAPIPlug.Resource` behaviour and implementing all of the callback functions:
defmodule MyApp.UsersResource do
use JSONAPIPlug.Resource,
type: "user",
attributes: [:id, :username]
end
See `t:options/0` for all available options you can pass to `use JSONAPIPlug.Resource`.
You can now call `UsersResource.render("show.json", %{data: user})` or `Resource.render(UsersResource, conn, user)`
to render a valid JSON:API document from your data. If you use phoenix, you can use:
render(conn, "show.json", %{data: user})
in your controller functions to render the document in the same way.
## Attributes
By default, the resulting JSON document consists of resources taken from your data.
Only resource attributes defined on the resource will be (de)serialized. You can customize
how attributes are handled by passing a keyword list of options:
defmodule MyApp.UsersResource do
use JSONAPIPlug.Resource,
type: "user",
attributes: [
username: nil,
fullname: [deserialize: false, serialize: &fullname/2]
]
defp fullname(resource, conn), do: "\#{resouce.first_name} \#{resource.last_name}"
end
In this example we are defining a computed attribute by passing the `serialize` option a function reference.
Serialization functions take `resource` and `conn` as arguments and return the attribute value to be serialized.
The `deserialize` option set to `false` makes sure the attribute is not deserialized when receiving a request.
## Relationships
Relationships are defined by passing the `relationships` option to `use JSONAPIPlug.Resource`.
defmodule MyApp.PostResource do
use JSONAPIPlug.Resource,
type: "post",
attributes: [:text, :body]
relationships: [
author: [resource: MyApp.UsersResource],
comments: [many: true, resource: MyApp.CommentsResource]
]
end
defmodule MyApp.CommentsResource do
alias MyApp.UsersResource
use JSONAPIPlug.Resource,
type: "comment",
attributes: [:text]
relationships: [post: [resource: MyApp.PostResource]]
end
When requesting `GET /posts?include=author`, if the author key is present on the data you pass from the
controller it will appear in the `included` section of the JSON:API response.
## Links
When rendering resource links, the default behaviour is to is to derive values for `host`, `port`
and `scheme` from the connection. If you need to use different values for some reason, you can override them
using `JSONAPIPlug.API` configuration options in your api configuration:
config :my_app, MyApp.API, host: "adifferenthost.com"
"""
alias JSONAPIPlug.{API, Document, Document.ResourceObject, Normalizer}
alias Plug.Conn
@attribute_schema [
name: [
doc: "Maps the resource attribute name to the given key.",
type: :atom
],
serialize: [
doc:
"Controls attribute serialization. Can be either a boolean (do/don't serialize) or a function reference returning the attribute value to be serialized for full control.",
type: {:or, [:boolean, {:fun, 2}]},
default: true
],
deserialize: [
doc:
"Controls attribute deserialization. Can be either a boolean (do/don't deserialize) or a function reference returning the attribute value to be deserialized for full control.",
type: {:or, [:boolean, {:fun, 2}]},
default: true
]
]
@relationship_schema [
name: [
doc: "Maps the resource relationship name to the given key.",
type: :atom
],
many: [
doc: "Specifies a to many relationship.",
type: :boolean,
default: false
],
resource: [
doc: "Specifies the resource to be used to serialize the relationship",
type: :atom,
required: true
]
]
@schema NimbleOptions.new!(
attributes: [
doc:
"Resource attributes. This will be used to (de)serialize requests/responses:\n\n" <>
NimbleOptions.docs(@attribute_schema, nest_level: 1),
type:
{:or,
[
{:list, :atom},
{:keyword_list, [*: [type: [keyword_list: [keys: @attribute_schema]]]]}
]},
default: []
],
id_attribute: [
doc:
"Attribute on your data to be used as the JSON:API resource id. Defaults to :id",
type: :atom,
default: :id
],
path: [
doc: "A custom path to be used for the resource. Defaults to the type value.",
type: :string
],
relationships: [
doc:
"Resource relationships. This will be used to (de)serialize requests/responses",
type: :keyword_list,
keys: [*: [type: :non_empty_keyword_list, keys: @relationship_schema]],
default: []
],
type: [
doc: "Resource type. To be used as the JSON:API resource type value",
type: :string,
required: true
]
)
@typedoc """
Resource module
A Module adopting the `JSONAPIPlug.Resource` behaviour
"""
@type t :: module()
@typedoc """
Resource options
#{NimbleOptions.docs(@schema)}
"""
@type options :: keyword()
@typedoc """
Resource data
User data representing a single resource
"""
@type resource :: term()
@typedoc """
Resource data
Resource data is either a resource or a list of resources
"""
@type data :: resource() | [resource()]
@typedoc """
Resource meta
A free form map containing metadata to be rendered
"""
@type meta :: Document.meta()
@typedoc """
Resource field name
The name of a Resource field (attribute or relationship)
"""
@type field_name :: atom()
@typedoc """
Attribute options\n#{NimbleOptions.docs(NimbleOptions.new!(@attribute_schema))}
"""
@type attribute_options :: [
name: field_name(),
serialize: boolean() | (resource(), Conn.t() -> term()),
deserialize: boolean() | (resource(), Conn.t() -> term())
]
@typedoc """
Resource attributes
A keyword list composed of attribute names and their options
"""
@type attributes :: [field_name()] | [{field_name(), attribute_options()}]
@typedoc """
Relationship options
#{NimbleOptions.docs(NimbleOptions.new!(@relationship_schema))}
"""
@type relationship_options :: [many: boolean(), name: field_name(), resource: t()]
@typedoc """
Resource attributes
A keyword list composed of relationship names and their options
"""
@type relationships :: [{field_name(), relationship_options()}]
@type field ::
field_name() | {field_name(), attribute_options() | relationship_options()}
@doc """
Resource Id Attribute
Returns the attribute used to fetch resource ids for resources by the resource.
"""
@callback id_attribute :: field_name()
@doc """
Resource attributes
Returns the keyword list of resource attributes for the resource.
"""
@callback attributes :: attributes()
@doc """
Resource links
Returns the resource links to be returned for resources by the resource.
"""
@callback links(resource(), Conn.t() | nil) :: Document.links()
@doc """
Resource normalizer
Returns the resource normalizer for resources by the resource.
"""
@callback normalizer :: Normalizer.t()
@doc """
Resource meta
Returns the resource meta to be returned for resources by the resource.
"""
@callback meta(resource(), Conn.t() | nil) :: Document.meta()
@doc """
Resource path
Returns the path to prepend to resources for the resource.
"""
@callback path :: String.t() | nil
@doc """
Resource relationships
Returns the keyword list of resource relationships for the resource.
"""
@callback relationships :: relationships()
@doc """
Resource Type
Returns the Resource Type of resources for the resource.
"""
@callback type :: ResourceObject.type()
defmacro __using__(options \\ []) do
options =
options
|> Macro.prewalk(&Macro.expand(&1, __CALLER__))
|> NimbleOptions.validate!(@schema)
attributes = Keyword.fetch!(options, :attributes)
id_attribute = Keyword.fetch!(options, :id_attribute)
normalizer = Keyword.get(options, :normalizer)
path = Keyword.get(options, :path)
relationships = Keyword.fetch!(options, :relationships)
type = Keyword.fetch!(options, :type)
if field =
Stream.concat(attributes, relationships)
|> Enum.find(&(JSONAPIPlug.Resource.field_name(&1) in [:id, :type])) do
name = JSONAPIPlug.Resource.field_name(field)
resource = Module.split(__CALLER__.module) |> List.last()
raise "Illegal field name '#{name}' for resource #{resource}. See https://jsonapi.org/format/#document-resource-object-fields for more information."
end
quote do
@behaviour JSONAPIPlug.Resource
@impl JSONAPIPlug.Resource
def id_attribute, do: unquote(id_attribute)
@impl JSONAPIPlug.Resource
def attributes, do: unquote(attributes)
@impl JSONAPIPlug.Resource
def links(_resource, _conn), do: %{}
@impl JSONAPIPlug.Resource
def meta(_resource, _conn), do: %{}
@impl JSONAPIPlug.Resource
def path, do: unquote(path)
@impl JSONAPIPlug.Resource
def normalizer, do: unquote(normalizer)
@impl JSONAPIPlug.Resource
def relationships, do: unquote(relationships)
@impl JSONAPIPlug.Resource
def type, do: unquote(type)
defoverridable JSONAPIPlug.Resource
if Code.ensure_loaded?(Phoenix) do
@doc """
JSONAPIPlug generated resource render function
This render function is autogenerated by JSONAPIPlug because it detected Phoenix
to be present in your project. It allows you to use the `JSONAPIPlug.Resource` as a
standard phoenix resource by calling `Phoenix.Controller.render/2` with your assigns:
...
conn
|> put_resource(MyApp.PostResource)
|> render("update.json", %{data: post})
...
instead of calling `JSONAPIPlug.Resource.render/5` directly in your controllers.
It takes the action (one of "create.json", "index.json", "show.json", "update.json") and
the assings as a keyword list or map with atom keys.
"""
@spec render(action :: String.t(), assigns :: keyword() | %{atom() => term()}) ::
Document.t() | no_return()
def render(action, assigns)
when action in ["create.json", "index.json", "show.json", "update.json"] do
JSONAPIPlug.Resource.render(
__MODULE__,
assigns[:conn],
assigns[:data],
assigns[:meta],
assigns[:options]
)
end
def render(action, _assigns) do
raise "invalid action #{action}, use one of create.json, index.json, show.json, update.json"
end
end
end
end
@doc """
Field option
Returns the name of the attribute or relationship for the field definition.
"""
@spec field_name(field()) :: field_name()
def field_name(field) when is_atom(field), do: field
def field_name({name, nil}), do: name
def field_name({name, options}) when is_list(options), do: name
def field_name(field) do
raise "invalid field definition: #{inspect(field)}"
end
@doc """
Field option
Returns the value of the attribute or relationship option for the field definition.
"""
@spec field_option(field(), atom()) :: term()
def field_option(name, _option) when is_atom(name), do: nil
def field_option({_name, nil}, _option), do: nil
def field_option({_name, options}, option) when is_list(options),
do: Keyword.get(options, option)
def field_option(field, _option, _default) do
raise "invalid field definition: #{inspect(field)}"
end
@doc """
Related Resource based on JSON:API type
Returns the resource used to handle relationships of the requested type by the passed resource.
"""
@spec for_related_type(t(), ResourceObject.type()) :: t() | nil
def for_related_type(resource, type) do
Enum.find_value(resource.relationships(), fn {_relationship, options} ->
relationship_resource = Keyword.fetch!(options, :resource)
if relationship_resource.type() == type do
relationship_resource
else
nil
end
end)
end
@doc """
Render JSON:API response
Renders the JSON:API response for the specified Resource.
"""
@spec render(t(), Conn.t(), data() | nil, Document.meta() | nil, options()) ::
Document.t() | no_return()
def render(
resource,
conn,
data \\ nil,
meta \\ nil,
options \\ []
) do
resource
|> Normalizer.normalize(conn, data, meta, options)
|> Document.serialize()
end
@doc """
Generate relationships link
Generates the relationships link for a resource.
"""
@spec url_for_relationship(t(), resource(), Conn.t() | nil, ResourceObject.type()) :: String.t()
def url_for_relationship(resource, data, conn, relationship_type) do
Enum.join([url_for(resource, data, conn), "relationships", relationship_type], "/")
end
@doc """
Generates the resource link
Generates the resource link for a resource.
"""
@spec url_for(t(), data(), Conn.t() | nil) :: String.t()
def url_for(resource, data, conn) when is_nil(resource) or is_list(data) do
conn
|> render_uri([resource.path() || resource.type()])
|> to_string()
end
def url_for(
resource,
data,
%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn
) do
normalizer = resource.normalizer() || API.get_config(jsonapi_plug.api, [:normalizer])
conn
|> render_uri([
resource.path() || resource.type(),
normalizer.normalize_attribute(data, resource.id_attribute())
])
|> to_string()
end
defp render_uri(%Conn{} = conn, path) do
%URI{
scheme: scheme(conn),
host: host(conn),
path: Enum.join([namespace(conn) | path], "/"),
port: port(conn)
}
end
defp render_uri(_conn, path), do: %URI{path: "/" <> Enum.join(path, "/")}
defp scheme(%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}, scheme: scheme}),
do: to_string(API.get_config(jsonapi_plug.api, [:scheme], scheme))
defp scheme(_conn), do: nil
defp host(%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}, host: host}),
do: API.get_config(jsonapi_plug.api, [:host], host)
defp host(_conn), do: nil
defp namespace(%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}}) do
case API.get_config(jsonapi_plug.api, [:namespace]) do
nil -> ""
namespace -> "/" <> namespace
end
end
defp namespace(_conn), do: ""
defp port(%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}, port: port} = conn) do
case API.get_config(jsonapi_plug.api, [:port], port) do
nil -> nil
port -> if port == URI.default_port(scheme(conn)), do: nil, else: port
end
end
defp port(_conn), do: nil
end