Packages

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

Current section

12 Versions

Jump to

Compare versions

5 files changed
+162 additions
-247 deletions
  @@ -84,7 +84,7 @@ defmodule UserJSON do
84 84 links fn user ->
85 85 %{
86 86 FooWeb.TeamJSON => user.team_id, # You can also pass list of ids or the Ecto record(s)
87 - FooWeb. ProfileJSON => user.profile_id
87 + FooWeb.ProfileJSON => user.profile_id
88 88 }
89 89 end
90 90
  @@ -177,13 +177,6 @@ This example also shows reading & parsing the `include` parameter, which can be
177 177 * Empty list (`GET /api/users?included=`): No link types are included.
178 178 * Custom types: (`GET /api/users/123?include=team,profile`): Include comma-separated link types only.
179 179
180 - ## How does it work?
181 -
182 - * Carve macros create view functions `index(%{ result: users })` and `show(%{ result: user })`
183 - * Controller calls these view functions
184 - * Carve pulls the list of links for given data (list or single record)
185 - * Carve calls the `get_by_id` (`get` macro expanded) and `prepare_for_view` (`view` macro expanded) functions for each link
186 - * The final expanded list of links get flattened & cleaned, returned to user with the main result: `{ result: {} || [], links: [] }`
187 180
188 181 ## Links
189 182
  @@ -204,8 +197,6 @@ end
204 197
205 198 Now, a request to `/api/users/123` automatically includes linked team and company in a single response. Client gets all data needed without extra requests.
206 199
207 -
208 -
209 200 Unlike GraphQL which requires defining a schema and writing resolvers for each field, Carve allows you to define links between resources directly in the view. When a user fetches a resource, all necessary context is automatically included in the response:
210 201
211 202 ```elixir
  @@ -277,19 +268,20 @@ Example response with `?include=team`:
277 268 }
278 269 ```
279 270
280 - ## Lazy Links
271 + ### Lazy Links
281 272
282 273 When using `links`, even if a link type is filtered out via `?include=`, database queries are still executed for all linked resources.
283 274
284 - For expensive queries, you can use `lazy_links` to execute them only when explicitly requested:
275 + For expensive queries, you can simply declare lazy links;
285 276
286 277 ```elixir
287 - defmodule MyJSON do
288 - use Carve.View, :resource
278 + defmodule UserJSON do
279 + use Carve.View, :user
289 280
290 - lazy_links fn resource ->
281 + links fn user ->
291 282 %{
292 - comments: fn -> {CommentJSON, Comments.by_user_id(user.id)} end
283 + TeamJSON => user.team_id, # Included by default
284 + CommentJSON => fn -> Comments.by_user_id(user.id) end # Called & included only if specified explicitly
293 285 }
294 286 end
295 287 end
  @@ -325,6 +317,15 @@ links fn user ->
325 317 end
326 318 ```
327 319
320 + ## How does it work?
321 +
322 + * Carve macros create view functions `index(%{ result: users })` and `show(%{ result: user })`
323 + * Controller calls these view functions
324 + * Carve pulls the list of links for given data (list or single record)
325 + * Carve calls the `get_by_id` (`get` macro expanded) and `prepare_for_view` (`view` macro expanded) functions for each link
326 + * The final expanded list of links get flattened & cleaned, returned to user with the main result: `{ result: {} || [], links: [] }`
327 +
328 +
328 329 ## API
329 330
330 331 More detailed API docs are available at [https://hexdocs.pm/carve/Carve.html](https://hexdocs.pm/carve/Carve.html)
  @@ -1,6 +1,6 @@
1 1 {<<"links">>,[{<<"GitHub">>,<<"https://github.com/azer/carve">>}]}.
2 2 {<<"name">>,<<"carve">>}.
3 - {<<"version">>,<<"0.2.0">>}.
3 + {<<"version">>,<<"0.2.1">>}.
4 4 {<<"description">>,
5 5 <<"DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically.">>}.
6 6 {<<"elixir">>,<<"~> 1.12">>}.
  @@ -32,14 +32,15 @@ defmodule Carve.Links do
32 32
33 33 ## Examples
34 34
35 - iex> Carve.Links.get_links_by_id(UserJSON, 1)
36 - [%{type: :team, id: "abc123", data: %{...}}, %{type: :profile, id: "def456", data: %{...}}]
35 + iex> Carve.Links.get_links_by_id(UserJSON, 1)
36 + [%{type: :team, id: "abc123", data: %{...}}, %{type: :profile, id: "def456", data: %{...}}]
37 37
38 - iex> Carve.Links.get_links_by_id(UserJSON, [1, 2, 3], %{}, [:team])
39 - [%{type: :team, id: "abc123", data: %{...}}]
38 + iex> Carve.Links.get_links_by_id(UserJSON, [1, 2, 3], %{}, [:team])
39 + [%{type: :team, id: "abc123", data: %{...}}]
40 40 """
41 41 def get_links_by_id(module, id, visited \\ %{}, whitelist \\ nil)
42 42 def get_links_by_id(_module, nil, _visited, _whitelist), do: []
43 +
43 44 def get_links_by_id(module, id, visited, whitelist) when not is_list(id) do
44 45 case Map.get(visited, {module, id}) do
45 46 nil ->
  @@ -47,9 +48,12 @@ defmodule Carve.Links do
47 48 nil -> []
48 49 data -> get_links_by_data(module, data, visited, whitelist) |> prepare_result(whitelist)
49 50 end
50 - _ -> []
51 +
52 + _ ->
53 + []
51 54 end
52 55 end
56 +
53 57 def get_links_by_id(module, ids, visited, whitelist) when is_list(ids) do
54 58 Enum.flat_map(ids, &get_links_by_id(module, &1, visited, whitelist))
55 59 |> prepare_result(whitelist)
  @@ -74,80 +78,88 @@ defmodule Carve.Links do
74 78
75 79 ## Examples
76 80
77 - iex> user = %{id: 1, name: "John Doe", team_id: 2}
78 - iex> Carve.Links.get_links_by_data(UserJSON, user)
79 - [%{type: :team, id: "abc123", data: %{...}}, %{type: :profile, id: "def456", data: %{...}}]
81 + iex> user = %{id: 1, name: "John Doe", team_id: 2}
82 + iex> Carve.Links.get_links_by_data(UserJSON, user)
83 + [%{type: :team, id: "abc123", data: %{...}}, %{type: :profile, id: "def456", data: %{...}}]
80 84
81 - iex> user = %{id: 1, name: "John Doe", team_id: 2}
82 - iex> Carve.Links.get_links_by_data(UserJSON, user, %{}, [:team])
83 - [%{type: :team, id: "abc123", data: %{...}}]
85 + iex> user = %{id: 1, name: "John Doe", team_id: 2}
86 + iex> Carve.Links.get_links_by_data(UserJSON, user, %{}, [:team])
87 + [%{type: :team, id: "abc123", data: %{...}}]
84 88 """
89 +
90 + # Get links for an entity by its data, with support for collections and filtering
85 91 def get_links_by_data(module, data, visited \\ %{}, whitelist \\ nil)
92 +
93 + # Return empty list for nil data
86 94 def get_links_by_data(_module, nil, _visited, _whitelist), do: []
95 +
96 + # Handle collections by mapping over each item
87 97 def get_links_by_data(module, data_list, visited, whitelist) when is_list(data_list) do
88 98 Enum.flat_map(data_list, &get_links_by_data(module, &1, visited, whitelist))
89 99 |> prepare_result(whitelist)
90 100 end
91 - def get_links_by_data(_module, data, _visited, _whitelist) when not is_map(data) do
92 - []
93 - end
101 +
102 + # Return empty list for non-map data
103 + def get_links_by_data(_module, data, _visited, _whitelist) when not is_map(data), do: []
104 +
105 + # Process single entity's links, with circular reference prevention
94 106 def get_links_by_data(module, data, visited, whitelist) when not is_list(data) do
95 107 case fetch_id(data) do
96 108 {:ok, id} ->
97 109 if Map.get(visited, {module, id}) do
98 110 []
99 111 else
112 + # Mark as visited and process links
100 113 visited = Map.put(visited, {module, id}, true)
101 - module.process_links(data)
102 - |> filter_links(whitelist)
103 - |> Enum.flat_map(fn {link_module, link_ids} ->
104 - Enum.map(normalize_link_ids(link_ids), fn link_id ->
105 - process_single_link(link_module, link_id, visited)
106 - end)
114 +
115 + # Get raw links and apply whitelist filtering/evaluation
116 + links =
117 + module.process_links(data)
118 + |> filter_and_evaluate_links(whitelist)
119 +
120 + links =
121 + links
122 + |> Enum.flat_map(fn {link_module, link_ids} ->
123 + link_ids
124 + |> normalize_link_ids()
125 + |> Enum.map(fn link_id ->
126 + process_single_link(link_module, link_id, visited)
127 + end)
107 128 end)
108 - |> prepare_result(whitelist)
129 +
130 + prepare_result(links, whitelist)
109 131 end
110 - :error -> []
132 +
133 + :error ->
134 + []
111 135 end
112 136 end
113 137
114 - #def get_links_by_data(module, data, visited \\ %{}, whitelist \\ nil, process_links_fn \\ nil)
115 - def get_links_by_data(_module, nil, _visited, _whitelist, _process_links_fn), do: []
116 -
117 - def get_links_by_data(module, data_list, visited, whitelist, process_links_fn) when is_list(data_list) do
118 - Enum.flat_map(data_list, &get_links_by_data(module, &1, visited, whitelist, process_links_fn))
119 - |> prepare_result(whitelist)
138 + defp filter_and_evaluate_links(links, nil) do
139 + # Without whitelist - evaluate regular links only (no lazy)
140 + links
141 + |> Enum.filter(fn {_, value} -> not is_function(value) end)
142 + |> Enum.into(%{})
120 143 end
121 144
122 - def get_links_by_data(_module, data, _visited, _whitelist, _process_links_fn) when not is_map(data) do
123 - []
145 + defp filter_and_evaluate_links(_links, []) do
146 + # Empty whitelist - evaluate nothing
147 + %{}
124 148 end
125 149
126 - def get_links_by_data(module, data, visited, whitelist, process_links_fn) when not is_list(data) do
127 - case fetch_id(data) do
128 - {:ok, id} ->
129 - if Map.get(visited, {module, id}) do
130 - []
131 - else
132 - visited = Map.put(visited, {module, id}, true)
150 + defp filter_and_evaluate_links(links, whitelist) when is_list(whitelist) do
151 + links
152 + |> Enum.filter(fn {module, _} -> module.type_name() in whitelist end)
153 + |> Enum.map(fn {module, value} ->
154 + evaluated =
155 + case value do
156 + fun when is_function(fun, 0) -> fun.()
157 + other -> other
158 + end
133 159
134 - # Use provided process function or fall back to module's process_links
135 - links = case process_links_fn do
136 - nil -> module.process_links(data)
137 - func -> func.(data)
138 - end
139 -
140 - links
141 - |> filter_links(whitelist)
142 - |> Enum.flat_map(fn {link_module, link_ids} ->
143 - Enum.map(normalize_link_ids(link_ids), fn link_id ->
144 - process_single_link(link_module, link_id, visited)
145 - end)
146 - end)
147 - |> prepare_result(whitelist)
148 - end
149 - :error -> []
150 - end
160 + {module, evaluated}
161 + end)
162 + |> Enum.into(%{})
151 163 end
152 164
153 165 @doc """
  @@ -164,20 +176,18 @@ defmodule Carve.Links do
164 176
165 177 ## Examples
166 178
167 - iex> links = %{UserJSON => [1, 2], PostJSON => [3, 4]}
168 - iex> Carve.Links.filter_links(links, [:user])
169 - %{UserJSON => [1, 2]}
179 + iex> links = %{UserJSON => [1, 2], PostJSON => [3, 4]}
180 + iex> Carve.Links.filter_links(links, [:user])
181 + %{UserJSON => [1, 2]}
170 182 """
171 - def filter_links(links, nil), do: links
172 - def filter_links(links, whitelist) do
173 - Enum.filter(links, fn {module, _} ->
174 - module.type_name() in whitelist
175 - end)
183 + defp filter_links(links, nil), do: links
184 +
185 + defp filter_links(links, whitelist) do
186 + links
187 + |> Enum.filter(fn {module, _} -> module.type_name() in whitelist end)
176 188 |> Enum.into(%{})
177 189 end
178 190
179 - # ... (rest of the module remains the same)
180 -
181 191 @doc """
182 192 Prepares the final result set by flattening, removing nil values, eliminating duplicates,
183 193 and applying the whitelist filter if provided.
  @@ -193,9 +203,9 @@ defmodule Carve.Links do
193 203
194 204 ## Examples
195 205
196 - iex> result = [[%{type: :team, id: "abc"}, nil], [%{type: :profile, id: "def"}], %{type: :team, id: "abc"}]
197 - iex> Carve.Links.prepare_result(result, [:team])
198 - [%{type: :team, id: "abc"}]
206 + iex> result = [[%{type: :team, id: "abc"}, nil], [%{type: :profile, id: "def"}], %{type: :team, id: "abc"}]
207 + iex> Carve.Links.prepare_result(result, [:team])
208 + [%{type: :team, id: "abc"}]
199 209 """
200 210 def prepare_result(result, whitelist \\ nil) do
201 211 result
  @@ -205,7 +215,9 @@ defmodule Carve.Links do
205 215 |> filter_result(whitelist)
206 216 end
207 217
218 + defp filter_result(result, []), do: []
208 219 defp filter_result(result, nil), do: result
220 +
209 221 defp filter_result(result, whitelist) do
210 222 Enum.filter(result, fn %{type: type} -> type in whitelist end)
211 223 end
  @@ -230,22 +242,31 @@ defmodule Carve.Links do
230 242 case Map.get(visited, {module, id}) do
231 243 nil ->
232 244 case module.get_by_id(id) do
233 - nil -> nil
245 + nil ->
246 + nil
247 +
234 248 data ->
235 249 module.prepare_for_view(data)
236 250 end
237 - _ -> nil
251 +
252 + _ ->
253 + nil
238 254 end
239 255 end
256 +
240 257 defp process_single_link(module, data, visited) do
241 258 case fetch_id(data) do
242 259 {:ok, id} ->
243 260 case Map.get(visited, {module, id}) do
244 261 nil ->
245 262 module.prepare_for_view(data)
246 - _ -> nil
263 +
264 + _ ->
265 + nil
247 266 end
248 - :error -> nil
267 +
268 + :error ->
269 + nil
249 270 end
250 271 end
251 272
  @@ -262,11 +283,11 @@ defmodule Carve.Links do
262 283
263 284 ## Examples
264 285
265 - iex> Carve.Links.normalize_link_ids(1)
266 - [1]
286 + iex> Carve.Links.normalize_link_ids(1)
287 + [1]
267 288
268 - iex> Carve.Links.normalize_link_ids([1, 2, 3])
269 - [1, 2, 3]
289 + iex> Carve.Links.normalize_link_ids([1, 2, 3])
290 + [1, 2, 3]
270 291 """
271 292 defp normalize_link_ids(link_ids) when is_list(link_ids), do: link_ids
272 293 defp normalize_link_ids(link_id), do: [link_id]
  @@ -286,22 +307,26 @@ defmodule Carve.Links do
286 307
287 308 ## Examples
288 309
289 - iex> Carve.Links.fetch_id(%{id: 1})
290 - {:ok, 1}
310 + iex> Carve.Links.fetch_id(%{id: 1})
311 + {:ok, 1}
291 312
292 - iex> Carve.Links.fetch_id(%{"id" => 2})
293 - {:ok, 2}
313 + iex> Carve.Links.fetch_id(%{"id" => 2})
314 + {:ok, 2}
294 315
295 - iex> Carve.Links.fetch_id(%{foo: "bar"})
296 - {:ok, {:foo, "bar"}}
316 + iex> Carve.Links.fetch_id(%{foo: "bar"})
317 + {:ok, {:foo, "bar"}}
297 318
298 - iex> Carve.Links.fetch_id("not a map")
299 - :error
319 + iex> Carve.Links.fetch_id("not a map")
320 + :error
300 321 """
301 322 defp fetch_id(data) when is_map(data) do
302 323 cond do
303 - Map.has_key?(data, :id) -> {:ok, data.id}
304 - Map.has_key?(data, "id") -> {:ok, data["id"]}
324 + Map.has_key?(data, :id) ->
325 + {:ok, data.id}
326 +
327 + Map.has_key?(data, "id") ->
328 + {:ok, data["id"]}
329 +
305 330 true ->
306 331 case Enum.at(data, 0) do
307 332 {key, value} -> {:ok, {key, value}}
  @@ -309,6 +334,7 @@ defmodule Carve.Links do
309 334 end
310 335 end
311 336 end
337 +
312 338 defp fetch_id(id) when is_integer(id) or is_binary(id), do: {:ok, id}
313 339 defp fetch_id(_), do: :error
314 340 end
  @@ -106,79 +106,54 @@ defmodule Carve.View do
106 106 @doc false
107 107 defmacro __before_compile__(env) do
108 108 has_process_links = Module.defines?(env.module, {:process_links, 1})
109 - has_lazy_process_links = Module.defines?(env.module, {:process_lazy_links, 2})
110 -
111 109 quote do
112 - # New function: index with include parameter
113 - def index(%{result: data, include: include}) when is_list(data) do
110 +
111 + def index(%{result: data, include: include}) when is_list(data) do
114 112 results = Enum.map(data, &prepare_for_view/1)
115 113 links = if unquote(has_process_links) do
116 114 Carve.Links.get_links_by_data(__MODULE__, data, %{}, include)
117 115 else
118 116 []
119 117 end
120 -
121 - lazy_links = if unquote(has_lazy_process_links) and include != nil do
122 - # For each data item, get its lazy links
123 - Enum.flat_map(data, fn item ->
124 - Enum.flat_map(include, fn type ->
125 - process_lazy_links(item, type)
126 - end)
127 - end)
128 - else
129 - []
130 - end
131 -
132 - %{
133 - result: results,
134 - links: links ++ lazy_links
135 - }
136 - end
137 -
138 - # New function: show with include parameter
139 - def show(%{result: data, include: include}) do
140 - result = prepare_for_view(data)
141 -
142 - links = if unquote(has_process_links) do
143 - Carve.Links.get_links_by_data(__MODULE__, data, %{}, include)
144 - else
145 - []
146 - end
147 -
148 - lazy_links = if unquote(has_lazy_process_links) and include != nil do
149 - # Since data is a single item, just map over includes
150 - Enum.flat_map(include, fn type ->
151 - process_lazy_links(data, type)
152 - end)
153 - else
154 - []
155 - end
156 -
157 - %{
158 - result: result,
159 - links: links ++ lazy_links
160 - }
161 - end
162 -
163 - # Render a list of entities with their links
164 - def index(%{result: data}) when is_list(data) do
165 - results = Enum.map(data, &prepare_for_view/1)
166 - links = if unquote(has_process_links) do
167 - Carve.Links.get_links_by_data(__MODULE__, data)
168 - else
169 - []
170 - end
171 118 %{
172 119 result: results,
173 120 links: links
174 121 }
175 122 end
176 123
177 - # Render a single entity with its links
124 + # Without include - include everything
125 + def index(%{result: data}) when is_list(data) do
126 + results = Enum.map(data, &prepare_for_view/1)
127 + links = if unquote(has_process_links) do
128 + Carve.Links.get_links_by_data(__MODULE__, data, %{}, nil) # Pass nil to include all
129 + else
130 + []
131 + end
132 + %{
133 + result: results,
134 + links: links
135 + }
136 + end
137 +
138 + # With include parameter - use whitelist
139 + def show(%{result: data, include: include}) do
140 + result = prepare_for_view(data)
141 + links = if unquote(has_process_links) do
142 + Carve.Links.get_links_by_data(__MODULE__, data, %{}, include)
143 + else
144 + []
145 + end
146 + %{
147 + result: result,
148 + links: links
149 + }
150 + end
151 +
152 + # Without include - include everything
178 153 def show(%{result: data}) do
179 154 result = prepare_for_view(data)
180 155 links = if unquote(has_process_links) do
181 - Carve.Links.get_links_by_data(__MODULE__, data)
156 + Carve.Links.get_links_by_data(__MODULE__, data, %{}, nil) # Pass nil to include all
182 157 else
183 158 []
184 159 end
  @@ -219,11 +194,6 @@ defmodule Carve.View do
219 194 unless unquote(has_process_links) do
220 195 def process_links(_), do: %{}
221 196 end
222 -
223 - # Add default implementation for process_lazy_links
224 - unless unquote(has_lazy_process_links) do
225 - def process_lazy_links(_, _), do: []
226 - end
227 197 end
228 198 end
229 199
  @@ -253,88 +223,6 @@ defmodule Carve.View do
253 223 end
254 224 end
255 225
256 - @doc """
257 - Defines lazy-loaded links for the current view.
258 -
259 - This macro allows you to specify links that are only loaded when explicitly requested
260 - through the include parameter. Useful for expensive or optional relationships.
261 -
262 - ## Parameters
263 -
264 - - `func`: A function that takes an entity and returns a map of lazy link functions.
265 - Each function returns a tuple of {module, data_or_ids} when evaluated.
266 -
267 - ## Example
268 -
269 - lazy_links fn user ->
270 - %{
271 - posts: fn -> {PostJSON, Posts.get_by_user(user.id)} end,
272 - comments: fn -> {CommentJSON, Comments.get_by_user(user.id)} end
273 - }
274 - end
275 -
276 - The lazy links can then be included in API responses:
277 -
278 - # Includes no lazy links
279 - GET /api/users/123
280 -
281 - # Includes only posts
282 - GET /api/users/123?include=posts
283 -
284 - # Includes both posts and comments
285 - GET /api/users/123?include=posts,comments
286 -
287 - This creates a `process_lazy_links/2` function that handles:
288 - - Single IDs or structs
289 - - Lists of IDs or structs
290 - - Proper type/id/data formatting for consistency
291 -
292 - The returned links match the format of normal links:
293 - ```elixir
294 - %{
295 - type: :post,
296 - id: "hashed_id",
297 - data: %{...}
298 - }
299 - ```
300 - """
301 - defmacro lazy_links(func) do
302 - quote do
303 - def process_lazy_links(data, type) do
304 - lazy_links = unquote(func).(data)
305 - case Map.get(lazy_links, type) do
306 - nil -> []
307 - link_fn ->
308 - {module, items} = link_fn.()
309 -
310 - # Handle both single items and lists
311 - items_list = List.wrap(items)
312 -
313 - # Map each item to proper format
314 - Enum.map(items_list, fn item ->
315 - case item do
316 - # If it's just an ID
317 - id when is_integer(id) ->
318 - fetched = module.get_by_id(id)
319 - %{
320 - type: module.type_name(),
321 - id: module.hash(id),
322 - data: module.prepare_for_view(fetched)
323 - }
324 - # If it's already a struct/map
325 - item ->
326 - %{
327 - type: module.type_name(),
328 - id: module.hash(item.id),
329 - data: module.prepare_for_view(item)
330 - }
331 - end
332 - end)
333 - end
334 - end
335 - end
336 - end
337 -
338 226 @doc """
339 227 Defines how to render the view for the current entity.
  @@ -4,7 +4,7 @@ defmodule Carve.MixProject do
4 4 def project do
5 5 [
6 6 app: :carve,
7 - version: "0.2.0",
7 + version: "0.2.1",
8 8 elixir: "~> 1.12",
9 9 start_permanent: Mix.env() == :prod,
10 10 deps: deps(),