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
+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…