Current section
12 Versions
Jump to
Current section
12 Versions
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…