Packages

DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically.

Current section

Files

Jump to
carve lib view.ex
Raw

lib/view.ex

defmodule Carve.View do
@moduledoc """
Carve.View provides a DSL for quickly building JSON API views in Phoenix applications.
It automatically creates `show` and `index` functions for Phoenix controllers,
handles ID hashing, and manages links between different entities.
## Usage
In your Phoenix JSON view module:
defmodule MyApp.UserJSON do
use Carve.View, :user
links fn user ->
%{
MyApp.TeamJSON => user.team_id,
MyApp.ProfileJSON => user.profile_id
}
end
get fn id ->
MyApp.Users.get_by_id!(id)
end
view fn user ->
%{
id: hash(user.id),
name: user.name,
team_id: MyApp.TeamJSON.hash(user.team_id),
profile_id: MyApp.ProfileJSON.hash(user.profile_id)
}
end
end
This will automatically create `show/1` and `index/1` functions that can be used
in your Phoenix controllers, handling both data rendering and link generation.
## Caching Expensive Computations
When both `links` and `view` need the same expensive data, use the `cache` macro
to compute it once and pass it to both:
defmodule MyApp.UserJSON do
use Carve.View, :user
get fn id -> MyApp.Users.get!(id) end
cache fn user ->
%{plan: MyApp.Plans.get_active_plan(user.id)}
end
links fn user, cached ->
%{MyApp.PlanJSON => cached.plan.id}
end
view fn user, cached ->
%{
id: hash(user.id),
plan_id: MyApp.PlanJSON.hash(cached.plan.id)
}
end
end
The cached result is also shared across entities within the same request,
so if the same user is linked from multiple parents, the cache function
runs only once.
## Generated Functions
The `use Carve.View, :type` macro generates several functions at compile time:
- `index/1`: Handles rendering a list of entities with their links.
- `show/1`: Handles rendering a single entity with its links.
- `hash/1`: Alias of encode_id.
- `encode_id/1`: Encodes an ID using the view type as a salt.
- `decode_id/1`: Decodes an ID using the view type as a salt.
- `type_name/0`: Returns the type of the view.
- `declare_links/1`: Processes links for an entity (if `links` macro is used).
Here's an example of what these functions might look like at runtime:
def index(%{result: data}) when is_list(data) do
results = Enum.map(data, &prepare_for_view/1)
links = Carve.Links.get_links_by_data(__MODULE__, data)
%{result: results, links: links}
end
def show(%{result: data}) do
result = prepare_for_view(data)
links = Carve.Links.get_links_by_data(__MODULE__, data)
%{result: result, links: links}
end
def hash(id) when is_integer(id), do: Carve.HashIds.encode(:user, id)
def hash(%{id: id}), do: hash(id)
def type_name, do: :user
def declare_links(data) do
%{
MyApp.TeamJSON => data.team_id,
MyApp.ProfileJSON => data.profile_id
}
end
These generated functions work together to provide a seamless API for rendering
JSON views with proper linking and ID hashing.
"""
@doc """
Sets up the view module with the given type.
This macro is called when you use `use Carve.View, :type` in your module.
It imports Carve.View functions and sets up the necessary module attributes.
## Parameters
- `type`: An atom representing the type of the view (e.g., :user, :post).
## Example
defmodule MyApp.UserJSON do
use Carve.View, :user
# ... rest of the module
end
"""
defmacro __using__(type) do
quote do
@carve_type unquote(type)
import Carve.View
@before_compile Carve.View
end
end
@doc false
defmacro __before_compile__(env) do
has_declare_links =
Module.defines?(env.module, {:declare_links, 1}) ||
Module.defines?(env.module, {:declare_links, 2})
has_cache = Module.defines?(env.module, {:__cache__, 1})
has_cache_many = Module.defines?(env.module, {:__cache_many__, 1})
has_2arity_view = Module.defines?(env.module, {:prepare_for_view, 2})
has_2arity_links = Module.defines?(env.module, {:declare_links, 2})
has_1arity_links = Module.defines?(env.module, {:declare_links, 1})
if has_cache and has_cache_many do
raise CompileError,
file: env.file,
line: env.line,
description:
"#{inspect(env.module)} defines both cache and cache_many; " <>
"they are two calling conventions for the same slot — define only one"
end
# Generate fallback for __cache__ if not defined
cache_fallback =
cond do
has_cache ->
nil
has_cache_many ->
# Per-entity fallback for paths that don't batch: a batch of one.
quote do
def __cache__(data) do
Map.get(__cache_many__([data]), Carve.Batch.entity_id(data))
end
end
true ->
quote do
def __cache__(_data), do: %{}
end
end
# Generate 2-arity fallback for prepare_for_view
view_fallback =
unless has_2arity_view do
quote do
def prepare_for_view(data, _cached), do: prepare_for_view(data)
end
end
# Generate 2-arity fallback for declare_links
links_fallback =
cond do
has_2arity_links ->
nil
has_1arity_links ->
quote do
def declare_links(data, _cached), do: declare_links(data)
end
true ->
nil
end
# Default declare_links if no links macro was used
declare_links_default =
unless has_declare_links do
quote do
def declare_links(_), do: %{}
def declare_links(_, _), do: %{}
end
end
quote do
# Wrapper for show that manages cache context
def show(assigns) do
cache_key = Carve.Cache.get_or_create_context()
try do
do_show(assigns, cache_key)
after
Carve.Cache.clear_context()
end
end
# Wrapper for index that manages cache context
def index(assigns) do
cache_key = Carve.Cache.get_or_create_context()
try do
do_index(assigns, cache_key)
after
Carve.Cache.clear_context()
end
end
# Internal show implementation
defp do_show(%{result: data, include: include}, cache_key) do
cached = Map.get(Carve.Batch.warm(__MODULE__, [data], cache_key), data.id)
result = prepare_for_view(data, cached)
links =
if unquote(has_declare_links) do
Carve.Links.get_links_by_data(__MODULE__, data, %{}, include, cache_key)
else
[]
end
%{result: result, links: links}
end
defp do_show(%{result: data}, cache_key) do
cached = Map.get(Carve.Batch.warm(__MODULE__, [data], cache_key), data.id)
result = prepare_for_view(data, cached)
links =
if unquote(has_declare_links) do
Carve.Links.get_links_by_data(__MODULE__, data, %{}, nil, cache_key)
else
[]
end
%{result: result, links: links}
end
# Internal index implementation
defp do_index(%{result: data, include: include}, cache_key) when is_list(data) do
cached_by_id = Carve.Batch.warm(__MODULE__, data, cache_key)
results =
Enum.map(data, fn item ->
prepare_for_view(item, Map.get(cached_by_id, item.id))
end)
links =
if unquote(has_declare_links) do
Carve.Links.get_links_by_data(__MODULE__, data, %{}, include, cache_key)
else
[]
end
%{result: results, links: links}
end
defp do_index(%{result: data}, cache_key) when is_list(data) do
cached_by_id = Carve.Batch.warm(__MODULE__, data, cache_key)
results =
Enum.map(data, fn item ->
prepare_for_view(item, Map.get(cached_by_id, item.id))
end)
links =
if unquote(has_declare_links) do
Carve.Links.get_links_by_data(__MODULE__, data, %{}, nil, cache_key)
else
[]
end
%{result: results, links: links}
end
# Keep all other generated functions the same
def hash(id) when is_integer(id) do
Carve.HashIds.encode(@carve_type, id)
end
def hash(%{id: id}) do
hash(id)
end
def encode_id(id) when is_integer(id) do
Carve.HashIds.encode(@carve_type, id)
end
def decode_id(hashed_id) when is_binary(hashed_id) do
case Carve.HashIds.decode(@carve_type, hashed_id) do
{:ok, id} -> {:ok, id}
{:error, reason} -> {:error, reason}
end
end
def decode_id!(hashed_id) when is_binary(hashed_id) do
case decode_id(hashed_id) do
{:ok, id} -> id
{:error, reason} -> raise "Failed to decode ID: #{reason}"
end
end
def type_name, do: @carve_type
unquote(cache_fallback)
unquote(view_fallback)
unquote(links_fallback)
unquote(declare_links_default)
end
end
@doc """
Caches expensive computations that are shared between `links` and `view`.
The function receives the entity data and should return a map of precomputed values.
This map is then passed as the second argument to `links` and `view` functions.
Results are cached per entity (by module + id) within a single request.
## Example
cache fn user ->
%{
plan: Plans.get_active_user_plan(user.id),
team: Teams.get_team(user.team_id)
}
end
links fn user, cached ->
%{PlanJSON => cached.plan.id}
end
view fn user, cached ->
%{id: hash(user.id), plan_id: PlanJSON.hash(cached.plan.id)}
end
"""
defmacro cache(func) do
quote do
def __cache__(data) do
unquote(func).(data)
end
end
end
@doc """
Batch sibling of `cache`: computes the cached data for every entity of a
render in one call instead of once per entity, so a query inside it runs
once per render instead of once per row.
The function receives the list of entities about to be rendered and must
return a map keyed by entity id. Each entity's value is passed to `links`
and `view` as their second argument, exactly like `cache`. Ids missing
from the returned map are cached as nil, and the view handles absence per
entity. Defining both `cache` and `cache_many` in one view is a
compile-time error.
## Example
cache_many fn posts ->
counts = Comments.count_for(Enum.map(posts, & &1.id))
Map.new(posts, fn post -> {post.id, %{comment_count: counts[post.id]}} end)
end
view fn post, cached ->
%{id: hash(post.id), comment_count: cached.comment_count}
end
"""
defmacro cache_many(func) do
quote do
def __cache_many__(entities) when is_list(entities) do
unquote(func).(entities)
end
end
end
@doc """
Defines the links for the current view.
This macro allows you to specify how to generate links for the current entity.
Accepts a 1-arity function, or a 2-arity function when used with `cache`.
## Parameters
- `func`: A function that takes the entity data (and optionally cached data)
and returns a map of links.
## Example
links fn user ->
%{
MyApp.TeamJSON => user.team_id,
MyApp.ProfileJSON => user.profile_id
}
end
"""
defmacro links(func) do
case detect_fn_arity(func) do
2 ->
quote do
def declare_links(data, cached) do
unquote(func).(data, cached)
end
end
_ ->
quote do
def declare_links(data) do
unquote(func).(data)
end
end
end
end
@doc """
Defines how to render the view for the current entity.
This macro specifies how to format the entity data for JSON output.
Accepts a 1-arity function, or a 2-arity function when used with `cache`.
The `id` returned by the function is also used as the entity's envelope id,
so a view can expose a custom identifier (e.g. a slug) instead of the
hashed id. When the function returns no `id`, the envelope falls back to
`hash(data.id)`.
## Parameters
- `func`: A function that takes the entity data (and optionally cached data)
and returns a map for JSON rendering.
## Example
view fn user ->
%{
id: hash(user.id),
name: user.name,
team_id: MyApp.TeamJSON.hash(user.team_id)
}
end
"""
defmacro view(func) do
case detect_fn_arity(func) do
2 ->
quote do
def prepare_for_view(data, cached) do
view = unquote(func).(data, cached)
%{
id: Map.get(view, :id) || hash(data.id),
type: type_name(),
data: view
}
end
end
_ ->
quote do
def prepare_for_view(data) do
view = unquote(func).(data)
%{
id: Map.get(view, :id) || hash(data.id),
type: type_name(),
data: view
}
end
end
end
end
@doc """
Defines how to retrieve an entity by its ID.
This macro specifies a function to fetch an entity given its ID.
## Parameters
- `func`: A function that takes an ID and returns the corresponding entity.
## Example
get fn id ->
MyApp.Users.get_by_id!(id)
end
"""
defmacro get(func) do
quote do
def get_by_id(id) do
unquote(func).(id)
end
end
end
@doc """
Generates a runtime `case` statement to return a loaded Ecto association or fall back to its ID.
"""
defmacro data_or_id(association, id) do
quote do
case unquote(association) do
%Ecto.Association.NotLoaded{} -> unquote(id)
loaded -> loaded
end
end
end
# Detect the arity of a literal fn from its AST
defp detect_fn_arity({:fn, _, [{:->, _, [args, _]} | _]}) when is_list(args), do: length(args)
defp detect_fn_arity(_), do: 1
end