Current section
12 Versions
Jump to
Current section
12 Versions
Compare versions
6
files changed
+227
additions
-52
deletions
| @@ -7,11 +7,11 @@ Features: | |
| 7 7 | * Rendering structured JSON views automatically |
| 8 8 | * Retrieving links between different data types |
| 9 9 | * Creating index/show methods for views |
| 10 | - * Encoding and decoding IDs (w/ unique salt per data type) using HashIds |
| 10 | + * Hashing IDs with unique & short strings (e.g `123` -> `Xk9Lp2R`) |
| 11 11 | |
| 12 | - Example endpoint view generated by Carve: |
| 12 | + Here's how an endpoint view generated by Carve looks like: |
| 13 13 | |
| 14 | - ```json |
| 14 | + ```js |
| 15 15 | { |
| 16 16 | "result": { |
| 17 17 | "id": "D3Wcorr0oa", |
| @@ -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 | |
| @@ -147,6 +147,36 @@ Example response Carve will render for this view: | |
| 147 147 | } |
| 148 148 | ``` |
| 149 149 | |
| 150 | + ### Example Controller |
| 151 | + |
| 152 | + Carve provides a flexible way to control which linked data is included in the response. This is achieved through the include parameter. Here's an example of how to use Carve in your Phoenix controllers: |
| 153 | + |
| 154 | + ```ex |
| 155 | + defmodule UserController do |
| 156 | + use FooWeb, :controller |
| 157 | + |
| 158 | + def show(conn, %{"id" => id} = params) do |
| 159 | + user = Foo.Users.get_user!(id) |
| 160 | + include = Carve.fetch_include(params) |
| 161 | + |
| 162 | + render(conn, :show, %{ result: user, include: include }) |
| 163 | + end |
| 164 | + |
| 165 | + def index(conn, params) do |
| 166 | + users = Foo.Users.list_users() |
| 167 | + include = Carve.fetch_include(params) |
| 168 | + |
| 169 | + render(conn, :index, %{ result: users, include: include }) |
| 170 | + end |
| 171 | + end |
| 172 | + ``` |
| 173 | + |
| 174 | + This example also shows reading & parsing the `include` parameter, which can be one of following: |
| 175 | + |
| 176 | + * Not specified (`GET /api/users`): All link types are included. |
| 177 | + * Empty list (`GET /api/users?included=`): No link types are included. |
| 178 | + * Custom types: (`GET /api/users/123?include=team,profile`): Include comma-separated link types only. |
| 179 | + |
| 150 180 | ## How does it work? |
| 151 181 | |
| 152 182 | * Carve macros create view functions `index(%{ result: users })` and `show(%{ result: user })` |
| @@ -154,3 +184,7 @@ Example response Carve will render for this view: | |
| 154 184 | * Carve pulls the list of links for given data (list or single record) |
| 155 185 | * Carve calls the `get_by_id` (`get` macro expanded) and `prepare_for_view` (`view` macro expanded) functions for each link |
| 156 186 | * The final expanded list of links get flattened & cleaned, returned to user with the main result: `{ result: {} || [], links: [] }` |
| 187 | + |
| 188 | + ## API |
| 189 | + |
| 190 | + 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.2">>}. |
| 3 | + {<<"version">>,<<"0.1.3">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<"DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically.">>}. |
| 6 6 | {<<"elixir">>,<<"~> 1.12">>}. |
| @@ -162,4 +162,76 @@ defmodule Carve do | |
| 162 162 | def decode(hash) when is_binary(hash) do |
| 163 163 | Carve.HashIds.decode(hash) |
| 164 164 | end |
| 165 | + |
| 166 | + @doc """ |
| 167 | + Fetches and parses the include parameter from the params map. |
| 168 | + |
| 169 | + This function determines if the include parameter was specified and parses it accordingly. |
| 170 | + |
| 171 | + ## Parameters |
| 172 | + |
| 173 | + - `params`: The full params map from the controller. |
| 174 | + |
| 175 | + ## Returns |
| 176 | + |
| 177 | + - `nil`: If the parameter was not specified (include everything). |
| 178 | + - `[]`: If an empty string was passed. |
| 179 | + - `[atom]`: A list of atoms representing the types to include. |
| 180 | + |
| 181 | + ## Examples |
| 182 | + |
| 183 | + iex> Carve.fetch_include(%{"include" => "foo,bar"}) |
| 184 | + [:foo, :bar] |
| 185 | + |
| 186 | + iex> Carve.fetch_include(%{"include" => ""}) |
| 187 | + [] |
| 188 | + |
| 189 | + iex> Carve.fetch_include(%{}) |
| 190 | + nil |
| 191 | + """ |
| 192 | + def fetch_include(params) do |
| 193 | + if include_param_specified?(params) do |
| 194 | + case params["include"] do |
| 195 | + nil -> nil |
| 196 | + "" -> [] |
| 197 | + string when is_binary(string) -> |
| 198 | + string |
| 199 | + |> String.split(",", trim: true) |
| 200 | + |> Enum.map(&String.trim/1) |
| 201 | + |> Enum.reject(&(&1 == "")) |
| 202 | + |> Enum.uniq() |
| 203 | + |> Enum.map(&String.to_existing_atom/1) |
| 204 | + end |
| 205 | + else |
| 206 | + nil |
| 207 | + end |
| 208 | + end |
| 209 | + |
| 210 | + @doc """ |
| 211 | + Determines if the include parameter was specified in the query string. |
| 212 | + |
| 213 | + ## Parameters |
| 214 | + |
| 215 | + - `params`: The full params map from the controller. |
| 216 | + |
| 217 | + ## Returns |
| 218 | + |
| 219 | + - `true`: If the include parameter was specified (even if empty). |
| 220 | + - `false`: If the include parameter was not specified at all. |
| 221 | + |
| 222 | + ## Examples |
| 223 | + |
| 224 | + iex> Carve.include_param_specified?(%{"include" => "foo,bar"}) |
| 225 | + true |
| 226 | + |
| 227 | + iex> Carve.include_param_specified?(%{"include" => ""}) |
| 228 | + true |
| 229 | + |
| 230 | + iex> Carve.include_param_specified?(%{}) |
| 231 | + false |
| 232 | + |
| 233 | + """ |
| 234 | + def include_param_specified?(params) do |
| 235 | + Map.has_key?(params, "include") |
| 236 | + end |
| 165 237 | end |
| @@ -17,13 +17,14 @@ defmodule Carve.Links do | |
| 17 17 | Retrieves links for a given module and ID or list of IDs. |
| 18 18 | |
| 19 19 | This function handles single IDs, lists of IDs, and prevents circular references |
| 20 | - using a visited map. |
| 20 | + using a visited map. It also supports filtering links based on a whitelist. |
| 21 21 | |
| 22 22 | ## Parameters |
| 23 23 | |
| 24 24 | - `module`: The module to use for fetching and processing links |
| 25 25 | - `id`: A single ID or a list of IDs |
| 26 26 | - `visited`: A map to keep track of visited entities (default: %{}) |
| 27 | + - `whitelist`: An optional list of allowed link types (default: nil) |
| 27 28 | |
| 28 29 | ## Returns |
| 29 30 | |
| @@ -34,37 +35,38 @@ defmodule Carve.Links do | |
| 34 35 | iex> Carve.Links.get_links_by_id(UserJSON, 1) |
| 35 36 | [%{type: :team, id: "abc123", data: %{...}}, %{type: :profile, id: "def456", data: %{...}}] |
| 36 37 | |
| 37 | - iex> Carve.Links.get_links_by_id(UserJSON, [1, 2, 3]) |
| 38 | - [%{type: :team, id: "abc123", data: %{...}}, %{type: :profile, id: "def456", data: %{...}}, ...] |
| 38 | + iex> Carve.Links.get_links_by_id(UserJSON, [1, 2, 3], %{}, [:team]) |
| 39 | + [%{type: :team, id: "abc123", data: %{...}}] |
| 39 40 | """ |
| 40 | - def get_links_by_id(module, id, visited \\ %{}) |
| 41 | - def get_links_by_id(_module, nil, _visited), do: [] |
| 42 | - def get_links_by_id(module, id, visited) when not is_list(id) do |
| 41 | + def get_links_by_id(module, id, visited \\ %{}, whitelist \\ nil) |
| 42 | + def get_links_by_id(_module, nil, _visited, _whitelist), do: [] |
| 43 | + def get_links_by_id(module, id, visited, whitelist) when not is_list(id) do |
| 43 44 | case Map.get(visited, {module, id}) do |
| 44 45 | nil -> |
| 45 46 | case module.get_by_id(id) do |
| 46 47 | nil -> [] |
| 47 | - data -> get_links_by_data(module, data, visited) |> prepare_result() |
| 48 | + data -> get_links_by_data(module, data, visited, whitelist) |> prepare_result(whitelist) |
| 48 49 | end |
| 49 50 | _ -> [] |
| 50 51 | end |
| 51 52 | end |
| 52 | - def get_links_by_id(module, ids, visited) when is_list(ids) do |
| 53 | - Enum.flat_map(ids, &get_links_by_id(module, &1, visited)) |
| 54 | - |> prepare_result() |
| 53 | + def get_links_by_id(module, ids, visited, whitelist) when is_list(ids) do |
| 54 | + Enum.flat_map(ids, &get_links_by_id(module, &1, visited, whitelist)) |
| 55 | + |> prepare_result(whitelist) |
| 55 56 | end |
| 56 57 | |
| 57 58 | @doc """ |
| 58 59 | Retrieves links for a given module and data or list of data. |
| 59 60 | |
| 60 61 | This function handles single data structures, lists of data structures, and |
| 61 | - prevents circular references using a visited map. |
| 62 | + prevents circular references using a visited map. It also supports filtering links based on a whitelist. |
| 62 63 | |
| 63 64 | ## Parameters |
| 64 65 | |
| 65 66 | - `module`: The module to use for fetching and processing links |
| 66 67 | - `data`: A single data structure or a list of data structures |
| 67 68 | - `visited`: A map to keep track of visited entities (default: %{}) |
| 69 | + - `whitelist`: An optional list of allowed link types (default: nil) |
| 68 70 | |
| 69 71 | ## Returns |
| 70 72 | |
| @@ -76,20 +78,20 @@ defmodule Carve.Links do | |
| 76 78 | iex> Carve.Links.get_links_by_data(UserJSON, user) |
| 77 79 | [%{type: :team, id: "abc123", data: %{...}}, %{type: :profile, id: "def456", data: %{...}}] |
| 78 80 | |
| 79 | - iex> users = [%{id: 1, name: "John"}, %{id: 2, name: "Jane"}] |
| 80 | - iex> Carve.Links.get_links_by_data(UserJSON, users) |
| 81 | - [%{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, %{}, [:team]) |
| 83 | + [%{type: :team, id: "abc123", data: %{...}}] |
| 82 84 | """ |
| 83 | - def get_links_by_data(module, data, visited \\ %{}) |
| 84 | - def get_links_by_data(_module, nil, _visited), do: [] |
| 85 | - def get_links_by_data(module, data_list, visited) when is_list(data_list) do |
| 86 | - Enum.flat_map(data_list, &get_links_by_data(module, &1, visited)) |
| 87 | - |> prepare_result() |
| 85 | + def get_links_by_data(module, data, visited \\ %{}, whitelist \\ nil) |
| 86 | + def get_links_by_data(_module, nil, _visited, _whitelist), do: [] |
| 87 | + def get_links_by_data(module, data_list, visited, whitelist) when is_list(data_list) do |
| 88 | + Enum.flat_map(data_list, &get_links_by_data(module, &1, visited, whitelist)) |
| 89 | + |> prepare_result(whitelist) |
| 88 90 | end |
| 89 | - def get_links_by_data(_module, data, _visited) when not is_map(data) do |
| 91 | + def get_links_by_data(_module, data, _visited, _whitelist) when not is_map(data) do |
| 90 92 | [] |
| 91 93 | end |
| 92 | - def get_links_by_data(module, data, visited) when not is_list(data) do |
| 94 | + def get_links_by_data(module, data, visited, whitelist) when not is_list(data) do |
| 93 95 | case fetch_id(data) do |
| 94 96 | {:ok, id} -> |
| 95 97 | if Map.get(visited, {module, id}) do |
| @@ -97,17 +99,78 @@ defmodule Carve.Links do | |
| 97 99 | else |
| 98 100 | visited = Map.put(visited, {module, id}, true) |
| 99 101 | module.process_links(data) |
| 102 | + |> filter_links(whitelist) |
| 100 103 | |> Enum.flat_map(fn {link_module, link_ids} -> |
| 101 104 | Enum.map(normalize_link_ids(link_ids), fn link_id -> |
| 102 105 | process_single_link(link_module, link_id, visited) |
| 103 106 | end) |
| 104 107 | end) |
| 105 | - |> prepare_result() |
| 108 | + |> prepare_result(whitelist) |
| 106 109 | end |
| 107 110 | :error -> [] |
| 108 111 | end |
| 109 112 | end |
| 110 113 | |
| 114 | + @doc """ |
| 115 | + Filters links based on the provided whitelist. |
| 116 | + |
| 117 | + ## Parameters |
| 118 | + |
| 119 | + - `links`: A map of links where keys are modules and values are IDs or lists of IDs |
| 120 | + - `whitelist`: A list of allowed link types (atoms) |
| 121 | + |
| 122 | + ## Returns |
| 123 | + |
| 124 | + A filtered map of links. |
| 125 | + |
| 126 | + ## Examples |
| 127 | + |
| 128 | + iex> links = %{UserJSON => [1, 2], PostJSON => [3, 4]} |
| 129 | + iex> Carve.Links.filter_links(links, [:user]) |
| 130 | + %{UserJSON => [1, 2]} |
| 131 | + """ |
| 132 | + def filter_links(links, nil), do: links |
| 133 | + def filter_links(links, whitelist) do |
| 134 | + Enum.filter(links, fn {module, _} -> |
| 135 | + module.type_name() in whitelist |
| 136 | + end) |
| 137 | + |> Enum.into(%{}) |
| 138 | + end |
| 139 | + |
| 140 | + # ... (rest of the module remains the same) |
| 141 | + |
| 142 | + @doc """ |
| 143 | + Prepares the final result set by flattening, removing nil values, eliminating duplicates, |
| 144 | + and applying the whitelist filter if provided. |
| 145 | + |
| 146 | + ## Parameters |
| 147 | + |
| 148 | + - `result`: A list of link maps |
| 149 | + - `whitelist`: An optional list of allowed link types |
| 150 | + |
| 151 | + ## Returns |
| 152 | + |
| 153 | + A cleaned, uniquified, and optionally filtered list of link maps. |
| 154 | + |
| 155 | + ## Examples |
| 156 | + |
| 157 | + iex> result = [[%{type: :team, id: "abc"}, nil], [%{type: :profile, id: "def"}], %{type: :team, id: "abc"}] |
| 158 | + iex> Carve.Links.prepare_result(result, [:team]) |
| 159 | + [%{type: :team, id: "abc"}] |
| 160 | + """ |
| 161 | + def prepare_result(result, whitelist \\ nil) do |
| 162 | + result |
| 163 | + |> List.flatten() |
| 164 | + |> Enum.reject(&is_nil/1) |
| 165 | + |> Enum.uniq_by(fn %{type: type, id: id} -> {type, id} end) |
| 166 | + |> filter_result(whitelist) |
| 167 | + end |
| 168 | + |
| 169 | + defp filter_result(result, nil), do: result |
| 170 | + defp filter_result(result, whitelist) do |
| 171 | + Enum.filter(result, fn %{type: type} -> type in whitelist end) |
| 172 | + end |
| 173 | + |
| 111 174 | @doc """ |
| 112 175 | Processes a single link, fetching its data and preparing it for output. |
| 113 176 | |
| @@ -147,30 +210,6 @@ defmodule Carve.Links do | |
| 147 210 | end |
| 148 211 | end |
| 149 212 | |
| 150 | - @doc """ |
| 151 | - Prepares the final result set by flattening, removing nil values, and eliminating duplicates. |
| 152 | - |
| 153 | - ## Parameters |
| 154 | - |
| 155 | - - `result`: A list of link maps |
| 156 | - |
| 157 | - ## Returns |
| 158 | - |
| 159 | - A cleaned and uniquified list of link maps. |
| 160 | - |
| 161 | - ## Examples |
| 162 | - |
| 163 | - iex> result = [[%{type: :team, id: "abc"}, nil], [%{type: :profile, id: "def"}], %{type: :team, id: "abc"}] |
| 164 | - iex> Carve.Links.prepare_result(result) |
| 165 | - [%{type: :team, id: "abc"}, %{type: :profile, id: "def"}] |
| 166 | - """ |
| 167 | - def prepare_result(result) do |
| 168 | - result |
| 169 | - |> List.flatten() |
| 170 | - |> Enum.reject(&is_nil/1) |
| 171 | - |> Enum.uniq_by(fn %{type: type, id: id} -> {type, id} end) |
| 172 | - end |
| 173 | - |
| 174 213 | @doc """ |
| 175 214 | Normalizes link IDs to always be a list. |
| @@ -107,6 +107,36 @@ defmodule Carve.View do | |
| 107 107 | defmacro __before_compile__(env) do |
| 108 108 | has_process_links = Module.defines?(env.module, {:process_links, 1}) |
| 109 109 | quote do |
| 110 | + |
| 111 | + |
| 112 | + # New function: index with include parameter |
| 113 | + def index(%{result: data, include: include}) when is_list(data) do |
| 114 | + results = Enum.map(data, &prepare_for_view/1) |
| 115 | + links = if unquote(has_process_links) do |
| 116 | + Carve.Links.get_links_by_data(__MODULE__, data, %{}, include) |
| 117 | + else |
| 118 | + [] |
| 119 | + end |
| 120 | + %{ |
| 121 | + result: results, |
| 122 | + links: links |
| 123 | + } |
| 124 | + end |
| 125 | + |
| 126 | + # New function: show with include parameter |
| 127 | + def show(%{result: data, include: include}) do |
| 128 | + result = prepare_for_view(data) |
| 129 | + links = if unquote(has_process_links) do |
| 130 | + Carve.Links.get_links_by_data(__MODULE__, data, %{}, include) |
| 131 | + else |
| 132 | + [] |
| 133 | + end |
| 134 | + %{ |
| 135 | + result: result, |
| 136 | + links: links |
| 137 | + } |
| 138 | + end |
| 139 | + |
| 110 140 | # Render a list of entities with their links |
| 111 141 | def index(%{result: data}) when is_list(data) do |
| 112 142 | results = Enum.map(data, &prepare_for_view/1) |
Loading more files…