Skip to content

Commit 0ea2996

Browse files
authored
feat: add polymorphic resource support (#359)
* add backwards compatible polymorphic resource support * polymorphic_relationships * remove polymorphic_resource type, rename with suffix ? to indicate it is a boolean * match type spec; refactor types * add as callbacks * fix names * fix credo linting * update docs on how to use polymorphic resources * no need for these to be callbacks
1 parent a9334ce commit 0ea2996

File tree

4 files changed

+265
-34
lines changed

4 files changed

+265
-34
lines changed

lib/jsonapi/serializer.ex

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,11 @@ defmodule JSONAPI.Serializer do
5858
end
5959

6060
def encode_data(view, data, conn, query_includes, options) do
61-
valid_includes = get_includes(view, query_includes)
61+
valid_includes = get_includes(view, query_includes, data)
6262

6363
encoded_data = %{
6464
id: view.id(data),
65-
type: view.type(),
65+
type: view.resource_type(data),
6666
attributes: transform_fields(view.attributes(data, conn)),
6767
relationships: %{}
6868
}
@@ -80,7 +80,8 @@ defmodule JSONAPI.Serializer do
8080

8181
@spec encode_relationships(Conn.t(), document(), tuple(), list()) :: tuple()
8282
def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do
83-
view.relationships()
83+
data
84+
|> view.resource_relationships()
8485
|> Enum.filter(&assoc_loaded?(Map.get(data, get_data_key(&1))))
8586
|> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options))
8687
end
@@ -255,7 +256,7 @@ defmodule JSONAPI.Serializer do
255256

256257
def encode_rel_data(view, data) do
257258
%{
258-
type: view.type(),
259+
type: view.resource_type(data),
259260
id: view.id(data)
260261
}
261262
end
@@ -273,13 +274,13 @@ defmodule JSONAPI.Serializer do
273274
defp assoc_loaded?(%{__struct__: Ecto.Association.NotLoaded}), do: false
274275
defp assoc_loaded?(_association), do: true
275276

276-
defp get_includes(view, query_includes) do
277-
includes = get_default_includes(view) ++ get_query_includes(view, query_includes)
277+
defp get_includes(view, query_includes, data) do
278+
includes = get_default_includes(view, data) ++ get_query_includes(view, query_includes, data)
278279
Enum.uniq(includes)
279280
end
280281

281-
defp get_default_includes(view) do
282-
rels = view.relationships()
282+
defp get_default_includes(view, data) do
283+
rels = view.resource_relationships(data)
283284

284285
Enum.filter(rels, &include_rel_by_default/1)
285286
end
@@ -290,8 +291,8 @@ defmodule JSONAPI.Serializer do
290291
include_by_default
291292
end
292293

293-
defp get_query_includes(view, query_includes) do
294-
rels = view.relationships()
294+
defp get_query_includes(view, query_includes, data) do
295+
rels = view.resource_relationships(data)
295296

296297
query_includes
297298
|> Enum.map(fn

lib/jsonapi/view.ex

Lines changed: 159 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,82 @@ defmodule JSONAPI.View do
114114
and then use the `[user: {UserView, :include}]` syntax in your `includes` function. This tells
115115
the serializer to *always* include if its loaded.
116116
117+
## Polymorphic Resources
118+
119+
Polymorphic resources allow you to serialize different types of data with the same view module.
120+
This is useful when you have a collection of resources that share some common attributes but
121+
have different types, fields, or relationships based on the specific data being serialized.
122+
123+
To enable polymorphic resources, set `polymorphic_resource?: true` when using the JSONAPI.View:
124+
125+
defmodule MediaView do
126+
use JSONAPI.View, polymorphic_resource?: true
127+
128+
def polymorphic_type(%{type: "image"}), do: "image"
129+
def polymorphic_type(%{type: "video"}), do: "video"
130+
def polymorphic_type(%{type: "audio"}), do: "audio"
131+
132+
def polymorphic_fields(%{type: "image"}), do: [:id, :url, :width, :height, :alt_text]
133+
def polymorphic_fields(%{type: "video"}), do: [:id, :url, :duration, :thumbnail]
134+
def polymorphic_fields(%{type: "audio"}), do: [:id, :url, :duration, :bitrate]
135+
136+
def polymorphic_relationships(%{type: "image"}), do: [album: AlbumView]
137+
def polymorphic_relationships(%{type: "video"}), do: [playlist: PlaylistView, author: UserView]
138+
def polymorphic_relationships(%{type: "audio"}), do: [album: AlbumView, artist: ArtistView]
139+
end
140+
141+
### Required Callbacks for Polymorphic Resources
142+
143+
When using polymorphic resources, you must implement these callbacks instead of their non-polymorphic counterparts:
144+
145+
- `polymorphic_type/1` - Returns the JSONAPI type string based on the data
146+
- `polymorphic_fields/1` - Returns the list of fields to serialize based on the data
147+
148+
### Optional Callbacks for Polymorphic Resources
149+
150+
- `polymorphic_relationships/1` - Returns relationships specific to the data type (defaults to empty list)
151+
152+
### Example Usage
153+
154+
With the above `MediaView`, you can serialize different media types:
155+
156+
# Image data
157+
image = %{id: 1, type: "image", url: "/image.jpg", width: 800, height: 600, alt_text: "A photo"}
158+
MediaView.show(image, conn)
159+
# => %{data: %{id: "1", type: "image", attributes: %{url: "/image.jpg", width: 800, height: 600, alt_text: "A photo"}}}
160+
161+
# Video data
162+
video = %{id: 2, type: "video", url: "/video.mp4", duration: 120, thumbnail: "/thumb.jpg"}
163+
MediaView.show(video, conn)
164+
# => %{data: %{id: "2", type: "video", attributes: %{url: "/video.mp4", duration: 120, thumbnail: "/thumb.jpg"}}}
165+
166+
### Custom Field Functions
167+
168+
You can still define custom field functions that work across all polymorphic types:
169+
170+
defmodule MediaView do
171+
use JSONAPI.View, polymorphic_resource?: true
172+
173+
def file_size(data, _conn) do
174+
# Custom logic to calculate file size
175+
calculate_file_size(data.url)
176+
end
177+
178+
def polymorphic_fields(%{type: "image"}), do: [:id, :url, :file_size, :width, :height]
179+
def polymorphic_fields(%{type: "video"}), do: [:id, :url, :file_size, :duration]
180+
# ... other polymorphic implementations
181+
end
182+
183+
### Notes
184+
185+
- When `polymorphic_resource?: true` is set, the regular `type/0`, `fields/0`, and `relationships/0`
186+
functions are not used and will return default values (nil or empty list)
187+
- The polymorphic callbacks receive the actual data as their first argument, allowing you to
188+
determine the appropriate type, fields, and relationships dynamically
189+
- All other view functionality (links, meta, hidden fields, etc.) works the same way
190+
- **Important**: Polymorphic resources currently do not work for deserializing data from POST
191+
requests yet. They are only supported for serialization (rendering responses)
192+
117193
## Options
118194
* `:host` (binary) - Allows the `host` to be overridden for generated URLs. Defaults to `host` of the supplied `conn`.
119195
@@ -140,10 +216,13 @@ defmodule JSONAPI.View do
140216
@type options :: keyword()
141217
@type resource_id :: String.t()
142218
@type resource_type :: String.t()
219+
@type resource_relationships :: [{atom(), t() | {t(), :include} | {atom(), t()} | {atom(), t(), :include}}]
220+
@type resource_fields :: [field()]
143221

144222
@callback attributes(data(), Conn.t() | nil) :: map()
145223
@callback id(data()) :: resource_id() | nil
146-
@callback fields() :: [field()]
224+
@callback fields() :: resource_fields()
225+
@callback polymorphic_fields(data()) :: resource_fields()
147226
@callback get_field(field(), data(), Conn.t()) :: any()
148227
@callback hidden(data()) :: [field()]
149228
@callback links(data(), Conn.t()) :: links()
@@ -152,10 +231,10 @@ defmodule JSONAPI.View do
152231
@callback pagination_links(data(), Conn.t(), Paginator.page(), Paginator.options()) ::
153232
Paginator.links()
154233
@callback path() :: String.t() | nil
155-
@callback relationships() :: [
156-
{atom(), t() | {t(), :include} | {atom(), t()} | {atom(), t(), :include}}
157-
]
158-
@callback type() :: resource_type()
234+
@callback relationships() :: resource_relationships()
235+
@callback polymorphic_relationships(data()) :: resource_relationships()
236+
@callback type() :: resource_type() | nil
237+
@callback polymorphic_type(data()) :: resource_type() | nil
159238
@callback url_for(data(), Conn.t() | nil) :: String.t()
160239
@callback url_for_pagination(data(), Conn.t(), Paginator.params()) :: String.t()
161240
@callback url_for_rel(term(), String.t(), Conn.t() | nil) :: String.t()
@@ -167,7 +246,8 @@ defmodule JSONAPI.View do
167246
{type, opts} = Keyword.pop(opts, :type)
168247
{namespace, opts} = Keyword.pop(opts, :namespace)
169248
{path, opts} = Keyword.pop(opts, :path)
170-
{paginator, _opts} = Keyword.pop(opts, :paginator)
249+
{paginator, opts} = Keyword.pop(opts, :paginator)
250+
{polymorphic_resource?, _opts} = Keyword.pop(opts, :polymorphic_resource?, false)
171251

172252
quote do
173253
alias JSONAPI.{Serializer, View}
@@ -178,6 +258,7 @@ defmodule JSONAPI.View do
178258
@namespace unquote(namespace)
179259
@path unquote(path)
180260
@paginator unquote(paginator)
261+
@polymorphic_resource? unquote(polymorphic_resource?)
181262

182263
@impl View
183264
def id(nil), do: nil
@@ -205,8 +286,21 @@ defmodule JSONAPI.View do
205286
end)
206287
end
207288

208-
@impl View
209-
def fields, do: raise("Need to implement fields/0")
289+
cond do
290+
!@polymorphic_resource? ->
291+
@impl View
292+
def fields, do: raise("Need to implement fields/0")
293+
294+
@impl View
295+
def polymorphic_fields(_data), do: []
296+
297+
@polymorphic_resource? ->
298+
@impl View
299+
def fields, do: []
300+
301+
@impl View
302+
def polymorphic_fields(_data), do: raise("Need to implement polymorphic_fields/1")
303+
end
210304

211305
@impl View
212306
def hidden(_data), do: []
@@ -242,10 +336,29 @@ defmodule JSONAPI.View do
242336
def relationships, do: []
243337

244338
@impl View
245-
if @resource_type do
246-
def type, do: @resource_type
247-
else
248-
def type, do: raise("Need to implement type/0")
339+
def polymorphic_relationships(_data), do: []
340+
341+
cond do
342+
@resource_type ->
343+
@impl View
344+
def type, do: @resource_type
345+
346+
@impl View
347+
def polymorphic_type(_data), do: nil
348+
349+
!@polymorphic_resource? ->
350+
@impl View
351+
def type, do: raise("Need to implement type/0")
352+
353+
@impl View
354+
def polymorphic_type(_data), do: nil
355+
356+
@polymorphic_resource? ->
357+
@impl View
358+
def type, do: nil
359+
360+
@impl View
361+
def polymorphic_type(_data), do: raise("Need to implement polymorphic_type/1")
249362
end
250363

251364
@impl View
@@ -264,6 +377,30 @@ defmodule JSONAPI.View do
264377
def visible_fields(data, conn),
265378
do: View.visible_fields(__MODULE__, data, conn)
266379

380+
def resource_fields(data) do
381+
if @polymorphic_resource? do
382+
polymorphic_fields(data)
383+
else
384+
fields()
385+
end
386+
end
387+
388+
def resource_type(data) do
389+
if @polymorphic_resource? do
390+
polymorphic_type(data)
391+
else
392+
type()
393+
end
394+
end
395+
396+
def resource_relationships(data) do
397+
if @polymorphic_resource? do
398+
polymorphic_relationships(data)
399+
else
400+
relationships()
401+
end
402+
end
403+
267404
defoverridable View
268405

269406
def index(models, conn, _params, meta \\ nil, options \\ []),
@@ -336,11 +473,11 @@ defmodule JSONAPI.View do
336473

337474
@spec url_for(t(), term(), Conn.t() | nil) :: String.t()
338475
def url_for(view, data, nil = _conn) when is_nil(data) or is_list(data),
339-
do: URI.to_string(%URI{path: Enum.join([view.namespace(), path_for(view)], "/")})
476+
do: URI.to_string(%URI{path: Enum.join([view.namespace(), path_for(view, data)], "/")})
340477

341478
def url_for(view, data, nil = _conn) do
342479
URI.to_string(%URI{
343-
path: Enum.join([view.namespace(), path_for(view), view.id(data)], "/")
480+
path: Enum.join([view.namespace(), path_for(view, data), view.id(data)], "/")
344481
})
345482
end
346483

@@ -349,7 +486,7 @@ defmodule JSONAPI.View do
349486
scheme: scheme(conn),
350487
host: host(conn),
351488
port: port(conn),
352-
path: Enum.join([view.namespace(), path_for(view)], "/")
489+
path: Enum.join([view.namespace(), path_for(view, data)], "/")
353490
})
354491
end
355492

@@ -358,7 +495,7 @@ defmodule JSONAPI.View do
358495
scheme: scheme(conn),
359496
host: host(conn),
360497
port: port(conn),
361-
path: Enum.join([view.namespace(), path_for(view), view.id(data)], "/")
498+
path: Enum.join([view.namespace(), path_for(view, data), view.id(data)], "/")
362499
})
363500
end
364501

@@ -392,8 +529,8 @@ defmodule JSONAPI.View do
392529
def visible_fields(view, data, conn) do
393530
all_fields =
394531
view
395-
|> requested_fields_for_type(conn)
396-
|> net_fields_for_type(view.fields())
532+
|> requested_fields_for_type(data, conn)
533+
|> net_fields_for_type(view.resource_fields(data))
397534

398535
hidden_fields = view.hidden(data)
399536

@@ -420,11 +557,11 @@ defmodule JSONAPI.View do
420557
|> URI.to_string()
421558
end
422559

423-
defp requested_fields_for_type(view, %Conn{assigns: %{jsonapi_query: %{fields: fields}}}) do
424-
fields[view.type()]
560+
defp requested_fields_for_type(view, data, %Conn{assigns: %{jsonapi_query: %{fields: fields}}}) do
561+
fields[view.resource_type(data)]
425562
end
426563

427-
defp requested_fields_for_type(_view, _conn), do: nil
564+
defp requested_fields_for_type(_view, _data, _conn), do: nil
428565

429566
defp host(%Conn{host: host}),
430567
do: Application.get_env(:jsonapi, :host, host)
@@ -438,5 +575,5 @@ defmodule JSONAPI.View do
438575
defp scheme(%Conn{scheme: scheme}),
439576
do: Application.get_env(:jsonapi, :scheme, to_string(scheme))
440577

441-
defp path_for(view), do: view.path() || view.type()
578+
defp path_for(view, data), do: view.path() || view.resource_type(data)
442579
end

0 commit comments

Comments
 (0)