Packages

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

Current section

12 Versions

Jump to

Compare versions

6 files changed
+302 additions
-13 deletions
  @@ -157,14 +157,14 @@ defmodule UserController do
157 157
158 158 def show(conn, %{"id" => id} = params) do
159 159 user = Foo.Users.get_user!(id)
160 - include = Carve.fetch_include(params)
160 + include = Carve.parse_include(params)
161 161
162 162 render(conn, :show, %{ result: user, include: include })
163 163 end
164 164
165 165 def index(conn, params) do
166 166 users = Foo.Users.list_users()
167 - include = Carve.fetch_include(params)
167 + include = Carve.parse_include(params)
168 168
169 169 render(conn, :index, %{ result: users, include: include })
170 170 end
  @@ -185,6 +185,146 @@ This example also shows reading & parsing the `include` parameter, which can be
185 185 * Carve calls the `get_by_id` (`get` macro expanded) and `prepare_for_view` (`view` macro expanded) functions for each link
186 186 * The final expanded list of links get flattened & cleaned, returned to user with the main result: `{ result: {} || [], links: [] }`
187 187
188 + ## Links
189 +
190 + 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:
191 +
192 + ```elixir
193 + defmodule UserJSON do
194 + use Carve.View, :user
195 +
196 + links fn user ->
197 + %{
198 + TeamJSON => user.team_id,
199 + CompanyJSON => user.company_id
200 + }
201 + end
202 + end
203 + ```
204 +
205 + 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 +
207 +
208 +
209 + 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 +
211 + ```elixir
212 + defmodule UserJSON do
213 + use Carve.View, :user
214 +
215 + # Declare which other resources this view links to
216 + links fn user ->
217 + %{
218 + TeamJSON => user.team_id, # User's team - needed to render user profile
219 + CompanyJSON => user.company_id # User's company - needed for permissions
220 + }
221 + end
222 + end
223 + ```
224 +
225 + ### Include Parameter
226 +
227 + Carve allows selective loading so the API client can optimize the response size & number of DB queries.
228 +
229 + You can enable it in the controller:
230 +
231 + ```ex
232 + def show(conn, params) do
233 + user = Users.get!(params["id"])
234 +
235 + # Parses ?include=team,post into [:team, :post]
236 + include = Carve.parse_include(params)
237 +
238 + render(conn, :show, %{
239 + result: user,
240 + include: include
241 + })
242 + end
243 + ```
244 +
245 + The client now can specify what links should be included in the API.
246 +
247 + ```
248 + GET /api/users/123 # Include all links
249 + GET /api/users/123?include= # Include no links
250 + GET /api/users/123?include=team # Include only team links
251 + GET /api/users/123?include=team,post # Include team and post links
252 + ```
253 +
254 + Example response with `?include=team`:
255 +
256 + ```json
257 + {
258 + "result": {
259 + "id": "D3Wcorr0oa",
260 + "type": "user",
261 + "data": {
262 + "id": "D3Wcorr0oa",
263 + "name": "John Doe",
264 + "team_id": "Xk9Lp2Rr4m"
265 + }
266 + },
267 + "links": [
268 + {
269 + "id": "Xk9Lp2Rr4m",
270 + "type": "team",
271 + "data": {
272 + "id": "Xk9Lp2Rr4m",
273 + "name": "Engineering Team"
274 + }
275 + }
276 + ]
277 + }
278 + ```
279 +
280 + ## Lazy Links
281 +
282 + When using `links`, even if a link type is filtered out via `?include=`, database queries are still executed for all linked resources.
283 +
284 + For expensive queries, you can use `lazy_links` to execute them only when explicitly requested:
285 +
286 + ```elixir
287 + defmodule MyJSON do
288 + use Carve.View, :resource
289 +
290 + lazy_links fn resource ->
291 + %{
292 + comments: fn -> {CommentJSON, Comments.by_user_id(user.id)} end
293 + }
294 + end
295 + end
296 + ```
297 +
298 + The lazy function is evaluated only when requested in the include param:
299 +
300 + ```
301 + GET /api/users/123 # No comments query executed
302 + GET /api/users/123?include=comments # Query executed for fetching comments
303 + ```
304 +
305 + Both return same JSON format, just with different loading behavior. The function should return a tuple of `{ViewModule, id_or_ids}`.
306 +
307 + ### Large datasets
308 +
309 + For relationships that could return large datasets, create dedicated endpoints instead of links:
310 +
311 + ```elixir
312 + # âś… Good: /api/users/123 returns user with essential context
313 + links fn user ->
314 + %{TeamJSON => user.team_id}
315 + end
316 +
317 + # âś… Good: Get user's comments via dedicated endpoint
318 + GET /api/users/123/comments?page=1
319 +
320 + # ❌ Bad: Don't use links for large collections
321 + links fn user ->
322 + %{
323 + CommentsJSON => Comments.by_user_id(user.id) # Could be thousands
324 + }
325 + end
326 + ```
327 +
188 328 ## API
189 329
190 330 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.1.4">>}.
3 + {<<"version">>,<<"0.2.0">>}.
4 4 {<<"description">>,
5 5 <<"DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically.">>}.
6 6 {<<"elixir">>,<<"~> 1.12">>}.
  @@ -164,7 +164,7 @@ defmodule Carve do
164 164 end
165 165
166 166 @doc """
167 - Fetches and parses the include parameter from the params map.
167 + Parses the include parameter from the query string parameters.
168 168
169 169 This function determines if the include parameter was specified and parses it accordingly.
170 170
  @@ -180,16 +180,16 @@ defmodule Carve do
180 180
181 181 ## Examples
182 182
183 - iex> Carve.fetch_include(%{"include" => "foo,bar"})
183 + iex> Carve.parse_include(%{"include" => "foo,bar"})
184 184 [:foo, :bar]
185 185
186 - iex> Carve.fetch_include(%{"include" => ""})
186 + iex> Carve.parse_include(%{"include" => ""})
187 187 []
188 188
189 - iex> Carve.fetch_include(%{})
189 + iex> Carve.parse_include(%{})
190 190 nil
191 191 """
192 - def fetch_include(params) do
192 + def parse_include(params) do
193 193 if include_param_specified?(params) do
194 194 case params["include"] do
195 195 nil -> nil
  @@ -111,6 +111,45 @@ defmodule Carve.Links do
111 111 end
112 112 end
113 113
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)
120 + end
121 +
122 + def get_links_by_data(_module, data, _visited, _whitelist, _process_links_fn) when not is_map(data) do
123 + []
124 + end
125 +
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)
133 +
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
151 + end
152 +
114 153 @doc """
115 154 Filters links based on the provided whitelist.
  @@ -106,9 +106,9 @@ 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 +
109 111 quote do
110 -
111 -
112 112 # New function: index with include parameter
113 113 def index(%{result: data, include: include}) when is_list(data) do
114 114 results = Enum.map(data, &prepare_for_view/1)
  @@ -117,23 +117,46 @@ defmodule Carve.View do
117 117 else
118 118 []
119 119 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 +
120 132 %{
121 133 result: results,
122 - links: links
134 + links: links ++ lazy_links
123 135 }
124 136 end
125 137
126 138 # New function: show with include parameter
127 139 def show(%{result: data, include: include}) do
128 140 result = prepare_for_view(data)
141 +
129 142 links = if unquote(has_process_links) do
130 143 Carve.Links.get_links_by_data(__MODULE__, data, %{}, include)
131 144 else
132 145 []
133 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 +
134 157 %{
135 158 result: result,
136 - links: links
159 + links: links ++ lazy_links
137 160 }
138 161 end
139 162
  @@ -196,6 +219,11 @@ defmodule Carve.View do
196 219 unless unquote(has_process_links) do
197 220 def process_links(_), do: %{}
198 221 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
199 227 end
200 228 end
201 229
  @@ -225,6 +253,88 @@ defmodule Carve.View do
225 253 end
226 254 end
227 255
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 +
228 338 @doc """
229 339 Defines how to render the view for the current entity.
Loading more files…