defprotocol JSONAPIPlug.Resource do
@moduledoc """
You can use any struct as a resource by deriving or directly implementing the `JSONAPIPlug.Resource` protocol:
```elixir
defmodule MyApp.User do
@derive {
JSONAPIPlug.Resource,
type: "user",
attributes: [:name, :surname, :username]
}
defstruct id: nil, name: nil, surname: nil, username: nil
end
```
See `t:options/0` for all available options you can pass to `@derive JSONAPIPlug.Resource`.
## 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:
```elixir
defmodule MyApp.User do
@derive {
JSONAPIPlug.Resource,
type: "user",
attributes: [
username: nil,
fullname: [deserialize: false]
]
}
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`.
```elixir
defmodule MyApp.Post do
@derive {
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
@derive {
JSONAPIPlug.Resource,
type: "comment",
attributes: [:text]
relationships: [post: [resource: MyApp.Post]]
}
defstruct text: nil, post: nil
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:
```elixir
config :my_app, MyApp.API, host: "adifferenthost.com"
```
"""
alias JSONAPIPlug.Document.ResourceObject
@typedoc """
Resource module
A struct implementing the `JSONAPIPlug.Resource` protocol
"""
@type t :: struct()
@typedoc """
Resource options
Available options:
#{NimbleOptions.docs(JSONAPIPlug.resource_options_schema())}
"""
@type options :: keyword()
@typedoc """
Resource field name
The name of a Resource field (attribute or relationship)
"""
@type field_name :: atom()
@doc "Returns the resource attributes"
@spec attributes(t()) :: [field_name()]
def attributes(resource)
@doc """
Resource Id Attribute
Returns the attribute used to fetch resource ids for resources by the
"""
@spec id_attribute(t()) :: field_name()
def id_attribute(resource)
@doc """
Resource field option
Returns the value of the requested field option
"""
@spec field_option(t(), field_name(), atom()) :: term()
def field_option(resource, field_name, option)
@doc """
Resource function to recase fields
Returns the field is the required case
"""
@spec recase_field(t(), field_name(), JSONAPIPlug.case()) :: String.t()
def recase_field(resource, field_name, jsonapi_plug)
@doc """
Resource relationships
Returns the keyword list of resource relationships for the
"""
@spec relationships(t()) :: [field_name()]
def relationships(resource)
@doc """
Resource Type
Returns the Resource Type of resources for the
"""
@spec type(t()) :: ResourceObject.type()
def type(resource)
end
defimpl JSONAPIPlug.Resource, for: Any do
defmacro __deriving__(module, _struct, options) do
options =
options
|> Macro.prewalk(&Macro.expand(&1, __CALLER__))
|> NimbleOptions.validate!(JSONAPIPlug.resource_options_schema())
attributes = generate_attributes(options)
relationships = generate_relationships(options)
check_fields(attributes, relationships, options)
field_option = generate_field_option(options)
recase_field = generate_recase_field(options)
quote do
defimpl JSONAPIPlug.Resource, for: unquote(module) do
def id_attribute(_resource), do: unquote(options[:id_attribute] || :id)
def attributes(_resource), do: unquote(attributes)
unquote(
Enum.reverse([
quote do
def field_option(_resource, _field_name, _field_option), do: nil
end
| field_option
])
)
def path(_resource), do: unquote(options[:path])
unquote(recase_field)
def relationships(_resource), do: unquote(relationships)
def type(_resource), do: unquote(options[:type])
end
end
end
defp generate_attributes(options) do
Enum.map(options[:attributes] || [], fn
{field_name, _field_options} -> field_name
field_name -> field_name
end)
end
defp generate_relationships(options) do
Enum.map(options[:relationships] || [], fn
{field_name, _field_options} -> field_name
field_name -> field_name
end)
end
defp check_fields(attributes, relationships, options) do
for field_name <- attributes do
if field_name in [:id, :type] do
raise "Illegal attribute name '#{field_name}' for resource '#{options[:type]}'. See https://jsonapi.org/format/#document-resource-object-fields for more information."
end
end
for field_name <- relationships do
if field_name in [:id, :type] do
raise "Illegal relationship name '#{field_name}' for resource '#{options[:type]}'. See https://jsonapi.org/format/#document-resource-object-fields for more information."
end
end
end
defp generate_field_option(options) do
Stream.concat(options[:attributes], options[:relationships])
|> Stream.map(fn
{field_name, nil} -> {field_name, []}
{field_name, field_options} -> {field_name, field_options}
field_name -> {field_name, []}
end)
|> Enum.flat_map(fn {field_name, field_options} ->
Enum.map(field_options, fn {field_option, value} ->
quote do
def field_option(_resource, unquote(field_name), unquote(field_option)),
do: unquote(value)
end
end)
end)
end
defp generate_recase_field(options) do
Stream.concat(options[:attributes], options[:relationships])
|> Stream.map(fn
{field_name, _field_options} -> field_name
field_name -> field_name
end)
|> Enum.flat_map(fn field_name ->
Enum.map([:camelize, :dasherize, :underscore], fn field_case ->
quote do
def recase_field(_resource, unquote(field_name), unquote(field_case)),
do: unquote(JSONAPIPlug.recase(field_name, field_case))
end
end)
end)
end
def id_attribute(_resource), do: :id
def attributes(_resource), do: []
def field_name(_resource, field_name), do: field_name
def field_option(_resource, _field_name, _option), do: nil
def path(_resource), do: nil
def recase_field(_resource, field, _case), do: field
def relationships(_resource), do: []
def type(_resource), do: ""
end