Current section
12 Versions
Jump to
Current section
12 Versions
Compare versions
5
files changed
+142
additions
-44
deletions
| @@ -1,17 +1,30 @@ | |
| 1 1 | # Carve |
| 2 2 | |
| 3 | - DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically. |
| 3 | + Declare the view & relationships like this: |
| 4 4 | |
| 5 | - Features: |
| 5 | + ```ex |
| 6 | + use Carve.View, :user |
| 6 7 | |
| 7 | - * Rendering structured JSON views automatically |
| 8 | - * Retrieving links between different data types |
| 9 | - * Creating index/show methods for views |
| 10 | - * Hashing IDs with unique & short strings (e.g `123` -> `Xk9Lp2R`) |
| 8 | + view fn user -> |
| 9 | + %{ |
| 10 | + id: hash(user.id), |
| 11 | + name: user.name |
| 12 | + } |
| 13 | + end |
| 11 14 | |
| 12 | - Here's how an endpoint view generated by Carve looks like: |
| 15 | + links fn user -> |
| 16 | + %{ |
| 17 | + TeamJSON => user.team_id, |
| 18 | + ProfileJSON => user.profile_id, |
| 19 | + PostsJSON => fn -> Posts.get_by_user_id(user.id) end |
| 20 | + } |
| 21 | + end |
| 22 | + ``` |
| 23 | + |
| 24 | + Get JSON endpoint views (`index`, `show`) with complete & linked data back in single request: |
| 13 25 | |
| 14 26 | ```js |
| 27 | + // GET /users/xyz?include=profile,team |
| 15 28 | { |
| 16 29 | "result": { |
| 17 30 | "id": "D3Wcorr0oa", |
| @@ -33,6 +46,16 @@ Here's how an endpoint view generated by Carve looks like: | |
| 33 46 | } |
| 34 47 | ``` |
| 35 48 | |
| 49 | + Features: |
| 50 | + |
| 51 | + * Automatic endpoint generation (index/show) |
| 52 | + * Smart data loading (lazy/eager) |
| 53 | + * Relationship resolution |
| 54 | + * Response structuring |
| 55 | + * ID hashing (123 → Xk9Lp2R) |
| 56 | + |
| 57 | + See also: [Bind](https://github.com/azer/bind) |
| 58 | + |
| 36 59 | ## Installation |
| 37 60 | |
| 38 61 | Add `carve` to your list of dependencies in `mix.exs`: |
| @@ -329,3 +352,46 @@ end | |
| 329 352 | ## API |
| 330 353 | |
| 331 354 | More detailed API docs are available at [https://hexdocs.pm/carve/Carve.html](https://hexdocs.pm/carve/Carve.html) |
| 355 | + |
| 356 | + ## Q&A |
| 357 | + |
| 358 | + ### Why? |
| 359 | + |
| 360 | + 1. **Performance vs Flexibility** |
| 361 | + - REST: Simple but requires multiple roundtrips |
| 362 | + - GraphQL: Flexible but complex infrastructure |
| 363 | + - Carve: Single request, zero infrastructure |
| 364 | + |
| 365 | + 2. **Development Speed** |
| 366 | + - Every new feature typically requires: |
| 367 | + - New endpoint handlers |
| 368 | + - New view logic |
| 369 | + - New data fetching code |
| 370 | + - New state management |
| 371 | + - Carve eliminates all of this with one view definition |
| 372 | + |
| 373 | + 3. **Maintainability** |
| 374 | + - As applications grow, data dependencies become complex |
| 375 | + - Changes ripple through multiple endpoints |
| 376 | + - State management becomes increasingly difficult |
| 377 | + - Carve centralizes this complexity in view definitions |
| 378 | + |
| 379 | + ### How does it compare to GraphQL? |
| 380 | + |
| 381 | + GraphQL |
| 382 | + - Requires schema definition (IDL) and resolver functions for each field |
| 383 | + - Client needs to learn query language and construct queries |
| 384 | + - Each query is a unique POST request, making caching challenging |
| 385 | + - N+1 query problems require manual batching/dataloader setup |
| 386 | + - Field-level authorization adds complexity |
| 387 | + - Additional infrastructure needed (query validation, complexity limits, persisted queries) |
| 388 | + |
| 389 | + Carve |
| 390 | + - Simple & small lbirary, uses your existing Phoenix views and controllers |
| 391 | + - Compatible with other libraries like [Bind](https://github.com/azer/bind) |
| 392 | + - REST-like URLs with simple `?include=` parameter |
| 393 | + - Standard GET requests, works with HTTP caching out of the box |
| 394 | + - Automatic batching of related data queries |
| 395 | + - Resource-level authorization using your existing Phoenix plugs |
| 396 | + - Zero additional infrastructure required |
| 397 | + |
| @@ -1,6 +1,6 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/azer/carve">>}]}. |
| 2 2 | {<<"name">>,<<"carve">>}. |
| 3 | - {<<"version">>,<<"0.2.1">>}. |
| 3 | + {<<"version">>,<<"0.3.0">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<"DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically.">>}. |
| 6 6 | {<<"elixir">>,<<"~> 1.12">>}. |
| @@ -114,7 +114,7 @@ defmodule Carve.Links do | |
| 114 114 | |
| 115 115 | # Get raw links and apply whitelist filtering/evaluation |
| 116 116 | links = |
| 117 | - module.process_links(data) |
| 117 | + module.declare_links(data) |
| 118 118 | |> filter_and_evaluate_links(whitelist) |
| 119 119 | |
| 120 120 | links = |
| @@ -123,7 +123,18 @@ defmodule Carve.Links do | |
| 123 123 | link_ids |
| 124 124 | |> normalize_link_ids() |
| 125 125 | |> Enum.map(fn link_id -> |
| 126 | - process_single_link(link_module, link_id, visited) |
| 126 | + link = process_single_link(link_module, link_id, visited) |
| 127 | + children = if Map.get(visited, {link_module, link_id}) do |
| 128 | + [] |
| 129 | + else |
| 130 | + if is_map(link_id) do |
| 131 | + get_links_by_data(link_module, link_id, visited, whitelist) |
| 132 | + else |
| 133 | + get_links_by_id(link_module, link_id, visited, whitelist) |
| 134 | + end |
| 135 | + end |
| 136 | + |
| 137 | + [link | children] |
| 127 138 | end) |
| 128 139 | end) |
| @@ -1,5 +1,5 @@ | |
| 1 1 | defmodule Carve.View do |
| 2 | - @moduledoc """ |
| 2 | + @moduledoc """ |
| 3 3 | Carve.View provides a DSL for quickly building JSON API views in Phoenix applications. |
| 4 4 | |
| 5 5 | It automatically creates `show` and `index` functions for Phoenix controllers, |
| @@ -46,7 +46,7 @@ defmodule Carve.View do | |
| 46 46 | - `encode_id/1`: Encodes an ID using the view type as a salt. |
| 47 47 | - `decode_id/1`: Decodes an ID using the view type as a salt. |
| 48 48 | - `type_name/0`: Returns the type of the view. |
| 49 | - - `process_links/1`: Processes links for an entity (if `links` macro is used). |
| 49 | + - `declare_links/1`: Processes links for an entity (if `links` macro is used). |
| 50 50 | |
| 51 51 | Here's an example of what these functions might look like at runtime: |
| 52 52 | |
| @@ -67,7 +67,7 @@ defmodule Carve.View do | |
| 67 67 | |
| 68 68 | def type_name, do: :user |
| 69 69 | |
| 70 | - def process_links(data) do |
| 70 | + def declare_links(data) do |
| 71 71 | %{ |
| 72 72 | MyApp.TeamJSON => data.team_id, |
| 73 73 | MyApp.ProfileJSON => data.profile_id |
| @@ -105,16 +105,19 @@ defmodule Carve.View do | |
| 105 105 | |
| 106 106 | @doc false |
| 107 107 | defmacro __before_compile__(env) do |
| 108 | - has_process_links = Module.defines?(env.module, {:process_links, 1}) |
| 109 | - quote do |
| 108 | + has_declare_links = Module.defines?(env.module, {:declare_links, 1}) |
| 110 109 | |
| 111 | - def index(%{result: data, include: include}) when is_list(data) do |
| 110 | + quote do |
| 111 | + def index(%{result: data, include: include}) when is_list(data) do |
| 112 112 | results = Enum.map(data, &prepare_for_view/1) |
| 113 | - links = if unquote(has_process_links) do |
| 114 | - Carve.Links.get_links_by_data(__MODULE__, data, %{}, include) |
| 115 | - else |
| 116 | - [] |
| 117 | - end |
| 113 | + |
| 114 | + links = |
| 115 | + if unquote(has_declare_links) do |
| 116 | + Carve.Links.get_links_by_data(__MODULE__, data, %{}, include) |
| 117 | + else |
| 118 | + [] |
| 119 | + end |
| 120 | + |
| 118 121 | %{ |
| 119 122 | result: results, |
| 120 123 | links: links |
| @@ -124,11 +127,15 @@ defmodule Carve.View do | |
| 124 127 | # Without include - include everything |
| 125 128 | def index(%{result: data}) when is_list(data) do |
| 126 129 | 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 |
| 130 | + |
| 131 | + links = |
| 132 | + if unquote(has_declare_links) do |
| 133 | + # Pass nil to include all |
| 134 | + Carve.Links.get_links_by_data(__MODULE__, data, %{}, nil) |
| 135 | + else |
| 136 | + [] |
| 137 | + end |
| 138 | + |
| 132 139 | %{ |
| 133 140 | result: results, |
| 134 141 | links: links |
| @@ -138,11 +145,14 @@ defmodule Carve.View do | |
| 138 145 | # With include parameter - use whitelist |
| 139 146 | def show(%{result: data, include: include}) do |
| 140 147 | 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 |
| 148 | + |
| 149 | + links = |
| 150 | + if unquote(has_declare_links) do |
| 151 | + Carve.Links.get_links_by_data(__MODULE__, data, %{}, include) |
| 152 | + else |
| 153 | + [] |
| 154 | + end |
| 155 | + |
| 146 156 | %{ |
| 147 157 | result: result, |
| 148 158 | links: links |
| @@ -152,11 +162,15 @@ defmodule Carve.View do | |
| 152 162 | # Without include - include everything |
| 153 163 | def show(%{result: data}) do |
| 154 164 | result = prepare_for_view(data) |
| 155 | - links = if unquote(has_process_links) do |
| 156 | - Carve.Links.get_links_by_data(__MODULE__, data, %{}, nil) # Pass nil to include all |
| 157 | - else |
| 158 | - [] |
| 159 | - end |
| 165 | + |
| 166 | + links = |
| 167 | + if unquote(has_declare_links) do |
| 168 | + # Pass nil to include all |
| 169 | + Carve.Links.get_links_by_data(__MODULE__, data, %{}, nil) |
| 170 | + else |
| 171 | + [] |
| 172 | + end |
| 173 | + |
| 160 174 | %{ |
| 161 175 | result: result, |
| 162 176 | links: links |
| @@ -178,8 +192,7 @@ defmodule Carve.View do | |
| 178 192 | Carve.HashIds.encode(@carve_type, id) |
| 179 193 | end |
| 180 194 | |
| 181 | - |
| 182 | - # Decode a hashed ID |
| 195 | + # Decode a hashed ID |
| 183 196 | def decode_id(hashed_id) when is_binary(hashed_id) do |
| 184 197 | case Carve.HashIds.decode(@carve_type, hashed_id) do |
| 185 198 | {:ok, id} -> {:ok, id} |
| @@ -187,12 +200,19 @@ defmodule Carve.View do | |
| 187 200 | end |
| 188 201 | end |
| 189 202 | |
| 203 | + def decode_id!(hashed_id) when is_binary(hashed_id) do |
| 204 | + case decode_id(hashed_id) do |
| 205 | + {:ok, id} -> id |
| 206 | + {:error, reason} -> raise "Failed to decode ID: #{reason}" |
| 207 | + end |
| 208 | + end |
| 209 | + |
| 190 210 | # Return the type of this view |
| 191 211 | def type_name, do: @carve_type |
| 192 212 | |
| 193 | - # Default process_links function if not defined by user |
| 194 | - unless unquote(has_process_links) do |
| 195 | - def process_links(_), do: %{} |
| 213 | + # Default declare_links function if not defined by user |
| 214 | + unless unquote(has_declare_links) do |
| 215 | + def declare_links(_), do: %{} |
| 196 216 | end |
| 197 217 | end |
| 198 218 | end |
| @@ -217,7 +237,7 @@ defmodule Carve.View do | |
| 217 237 | """ |
| 218 238 | defmacro links(func) do |
| 219 239 | quote do |
| 220 | - def process_links(data) do |
| 240 | + def declare_links(data) do |
| 221 241 | unquote(func).(data) |
| 222 242 | end |
| 223 243 | end |
| @@ -246,6 +266,7 @@ defmodule Carve.View do | |
| 246 266 | quote do |
| 247 267 | def prepare_for_view(data) do |
| 248 268 | view = unquote(func).(data) |
| 269 | + |
| 249 270 | %{ |
| 250 271 | id: hash(data.id), |
| 251 272 | type: type_name(), |
| @@ -4,7 +4,7 @@ defmodule Carve.MixProject do | |
| 4 4 | def project do |
| 5 5 | [ |
| 6 6 | app: :carve, |
| 7 | - version: "0.2.1", |
| 7 | + version: "0.3.0", |
| 8 8 | elixir: "~> 1.12", |
| 9 9 | start_permanent: Mix.env() == :prod, |
| 10 10 | deps: deps(), |
| @@ -52,7 +52,7 @@ defmodule Carve.MixProject do | |
| 52 52 | |
| 53 53 | defp package do |
| 54 54 | [ |
| 55 | - maintainers: ["Your Name"], |
| 55 | + maintainers: ["Azer Koculu"], |
| 56 56 | licenses: ["MIT"], |
| 57 57 | links: %{"GitHub" => "https://github.com/azer/carve"} |
| 58 58 | ] |