diff --git a/lib/jsonapi/serializer.ex b/lib/jsonapi/serializer.ex index 4ff2c5c4..353bdebe 100644 --- a/lib/jsonapi/serializer.ex +++ b/lib/jsonapi/serializer.ex @@ -58,11 +58,11 @@ defmodule JSONAPI.Serializer do end def encode_data(view, data, conn, query_includes, options) do - valid_includes = get_includes(view, query_includes) + valid_includes = get_includes(view, query_includes, data) encoded_data = %{ id: view.id(data), - type: view.type(), + type: view.resource_type(data), attributes: transform_fields(view.attributes(data, conn)), relationships: %{} } @@ -80,7 +80,8 @@ defmodule JSONAPI.Serializer do @spec encode_relationships(Conn.t(), document(), tuple(), list()) :: tuple() def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do - view.relationships() + data + |> view.resource_relationships() |> Enum.filter(&assoc_loaded?(Map.get(data, get_data_key(&1)))) |> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options)) end @@ -255,7 +256,7 @@ defmodule JSONAPI.Serializer do def encode_rel_data(view, data) do %{ - type: view.type(), + type: view.resource_type(data), id: view.id(data) } end @@ -273,13 +274,13 @@ defmodule JSONAPI.Serializer do defp assoc_loaded?(%{__struct__: Ecto.Association.NotLoaded}), do: false defp assoc_loaded?(_association), do: true - defp get_includes(view, query_includes) do - includes = get_default_includes(view) ++ get_query_includes(view, query_includes) + defp get_includes(view, query_includes, data) do + includes = get_default_includes(view, data) ++ get_query_includes(view, query_includes, data) Enum.uniq(includes) end - defp get_default_includes(view) do - rels = view.relationships() + defp get_default_includes(view, data) do + rels = view.resource_relationships(data) Enum.filter(rels, &include_rel_by_default/1) end @@ -290,8 +291,8 @@ defmodule JSONAPI.Serializer do include_by_default end - defp get_query_includes(view, query_includes) do - rels = view.relationships() + defp get_query_includes(view, query_includes, data) do + rels = view.resource_relationships(data) query_includes |> Enum.map(fn diff --git a/lib/jsonapi/view.ex b/lib/jsonapi/view.ex index 4b007ba0..12fd8e73 100644 --- a/lib/jsonapi/view.ex +++ b/lib/jsonapi/view.ex @@ -114,6 +114,82 @@ defmodule JSONAPI.View do and then use the `[user: {UserView, :include}]` syntax in your `includes` function. This tells the serializer to *always* include if its loaded. + ## Polymorphic Resources + + Polymorphic resources allow you to serialize different types of data with the same view module. + This is useful when you have a collection of resources that share some common attributes but + have different types, fields, or relationships based on the specific data being serialized. + + To enable polymorphic resources, set `polymorphic_resource?: true` when using the JSONAPI.View: + + defmodule MediaView do + use JSONAPI.View, polymorphic_resource?: true + + def polymorphic_type(%{type: "image"}), do: "image" + def polymorphic_type(%{type: "video"}), do: "video" + def polymorphic_type(%{type: "audio"}), do: "audio" + + def polymorphic_fields(%{type: "image"}), do: [:id, :url, :width, :height, :alt_text] + def polymorphic_fields(%{type: "video"}), do: [:id, :url, :duration, :thumbnail] + def polymorphic_fields(%{type: "audio"}), do: [:id, :url, :duration, :bitrate] + + def polymorphic_relationships(%{type: "image"}), do: [album: AlbumView] + def polymorphic_relationships(%{type: "video"}), do: [playlist: PlaylistView, author: UserView] + def polymorphic_relationships(%{type: "audio"}), do: [album: AlbumView, artist: ArtistView] + end + + ### Required Callbacks for Polymorphic Resources + + When using polymorphic resources, you must implement these callbacks instead of their non-polymorphic counterparts: + + - `polymorphic_type/1` - Returns the JSONAPI type string based on the data + - `polymorphic_fields/1` - Returns the list of fields to serialize based on the data + + ### Optional Callbacks for Polymorphic Resources + + - `polymorphic_relationships/1` - Returns relationships specific to the data type (defaults to empty list) + + ### Example Usage + + With the above `MediaView`, you can serialize different media types: + + # Image data + image = %{id: 1, type: "image", url: "/image.jpg", width: 800, height: 600, alt_text: "A photo"} + MediaView.show(image, conn) + # => %{data: %{id: "1", type: "image", attributes: %{url: "/image.jpg", width: 800, height: 600, alt_text: "A photo"}}} + + # Video data + video = %{id: 2, type: "video", url: "/video.mp4", duration: 120, thumbnail: "/thumb.jpg"} + MediaView.show(video, conn) + # => %{data: %{id: "2", type: "video", attributes: %{url: "/video.mp4", duration: 120, thumbnail: "/thumb.jpg"}}} + + ### Custom Field Functions + + You can still define custom field functions that work across all polymorphic types: + + defmodule MediaView do + use JSONAPI.View, polymorphic_resource?: true + + def file_size(data, _conn) do + # Custom logic to calculate file size + calculate_file_size(data.url) + end + + def polymorphic_fields(%{type: "image"}), do: [:id, :url, :file_size, :width, :height] + def polymorphic_fields(%{type: "video"}), do: [:id, :url, :file_size, :duration] + # ... other polymorphic implementations + end + + ### Notes + + - When `polymorphic_resource?: true` is set, the regular `type/0`, `fields/0`, and `relationships/0` + functions are not used and will return default values (nil or empty list) + - The polymorphic callbacks receive the actual data as their first argument, allowing you to + determine the appropriate type, fields, and relationships dynamically + - All other view functionality (links, meta, hidden fields, etc.) works the same way + - **Important**: Polymorphic resources currently do not work for deserializing data from POST + requests yet. They are only supported for serialization (rendering responses) + ## Options * `:host` (binary) - Allows the `host` to be overridden for generated URLs. Defaults to `host` of the supplied `conn`. @@ -140,10 +216,13 @@ defmodule JSONAPI.View do @type options :: keyword() @type resource_id :: String.t() @type resource_type :: String.t() + @type resource_relationships :: [{atom(), t() | {t(), :include} | {atom(), t()} | {atom(), t(), :include}}] + @type resource_fields :: [field()] @callback attributes(data(), Conn.t() | nil) :: map() @callback id(data()) :: resource_id() | nil - @callback fields() :: [field()] + @callback fields() :: resource_fields() + @callback polymorphic_fields(data()) :: resource_fields() @callback get_field(field(), data(), Conn.t()) :: any() @callback hidden(data()) :: [field()] @callback links(data(), Conn.t()) :: links() @@ -152,10 +231,10 @@ defmodule JSONAPI.View do @callback pagination_links(data(), Conn.t(), Paginator.page(), Paginator.options()) :: Paginator.links() @callback path() :: String.t() | nil - @callback relationships() :: [ - {atom(), t() | {t(), :include} | {atom(), t()} | {atom(), t(), :include}} - ] - @callback type() :: resource_type() + @callback relationships() :: resource_relationships() + @callback polymorphic_relationships(data()) :: resource_relationships() + @callback type() :: resource_type() | nil + @callback polymorphic_type(data()) :: resource_type() | nil @callback url_for(data(), Conn.t() | nil) :: String.t() @callback url_for_pagination(data(), Conn.t(), Paginator.params()) :: String.t() @callback url_for_rel(term(), String.t(), Conn.t() | nil) :: String.t() @@ -167,7 +246,8 @@ defmodule JSONAPI.View do {type, opts} = Keyword.pop(opts, :type) {namespace, opts} = Keyword.pop(opts, :namespace) {path, opts} = Keyword.pop(opts, :path) - {paginator, _opts} = Keyword.pop(opts, :paginator) + {paginator, opts} = Keyword.pop(opts, :paginator) + {polymorphic_resource?, _opts} = Keyword.pop(opts, :polymorphic_resource?, false) quote do alias JSONAPI.{Serializer, View} @@ -178,6 +258,7 @@ defmodule JSONAPI.View do @namespace unquote(namespace) @path unquote(path) @paginator unquote(paginator) + @polymorphic_resource? unquote(polymorphic_resource?) @impl View def id(nil), do: nil @@ -205,8 +286,21 @@ defmodule JSONAPI.View do end) end - @impl View - def fields, do: raise("Need to implement fields/0") + cond do + !@polymorphic_resource? -> + @impl View + def fields, do: raise("Need to implement fields/0") + + @impl View + def polymorphic_fields(_data), do: [] + + @polymorphic_resource? -> + @impl View + def fields, do: [] + + @impl View + def polymorphic_fields(_data), do: raise("Need to implement polymorphic_fields/1") + end @impl View def hidden(_data), do: [] @@ -242,10 +336,29 @@ defmodule JSONAPI.View do def relationships, do: [] @impl View - if @resource_type do - def type, do: @resource_type - else - def type, do: raise("Need to implement type/0") + def polymorphic_relationships(_data), do: [] + + cond do + @resource_type -> + @impl View + def type, do: @resource_type + + @impl View + def polymorphic_type(_data), do: nil + + !@polymorphic_resource? -> + @impl View + def type, do: raise("Need to implement type/0") + + @impl View + def polymorphic_type(_data), do: nil + + @polymorphic_resource? -> + @impl View + def type, do: nil + + @impl View + def polymorphic_type(_data), do: raise("Need to implement polymorphic_type/1") end @impl View @@ -264,6 +377,30 @@ defmodule JSONAPI.View do def visible_fields(data, conn), do: View.visible_fields(__MODULE__, data, conn) + def resource_fields(data) do + if @polymorphic_resource? do + polymorphic_fields(data) + else + fields() + end + end + + def resource_type(data) do + if @polymorphic_resource? do + polymorphic_type(data) + else + type() + end + end + + def resource_relationships(data) do + if @polymorphic_resource? do + polymorphic_relationships(data) + else + relationships() + end + end + defoverridable View def index(models, conn, _params, meta \\ nil, options \\ []), @@ -336,11 +473,11 @@ defmodule JSONAPI.View do @spec url_for(t(), term(), Conn.t() | nil) :: String.t() def url_for(view, data, nil = _conn) when is_nil(data) or is_list(data), - do: URI.to_string(%URI{path: Enum.join([view.namespace(), path_for(view)], "/")}) + do: URI.to_string(%URI{path: Enum.join([view.namespace(), path_for(view, data)], "/")}) def url_for(view, data, nil = _conn) do URI.to_string(%URI{ - path: Enum.join([view.namespace(), path_for(view), view.id(data)], "/") + path: Enum.join([view.namespace(), path_for(view, data), view.id(data)], "/") }) end @@ -349,7 +486,7 @@ defmodule JSONAPI.View do scheme: scheme(conn), host: host(conn), port: port(conn), - path: Enum.join([view.namespace(), path_for(view)], "/") + path: Enum.join([view.namespace(), path_for(view, data)], "/") }) end @@ -358,7 +495,7 @@ defmodule JSONAPI.View do scheme: scheme(conn), host: host(conn), port: port(conn), - path: Enum.join([view.namespace(), path_for(view), view.id(data)], "/") + path: Enum.join([view.namespace(), path_for(view, data), view.id(data)], "/") }) end @@ -392,8 +529,8 @@ defmodule JSONAPI.View do def visible_fields(view, data, conn) do all_fields = view - |> requested_fields_for_type(conn) - |> net_fields_for_type(view.fields()) + |> requested_fields_for_type(data, conn) + |> net_fields_for_type(view.resource_fields(data)) hidden_fields = view.hidden(data) @@ -420,11 +557,11 @@ defmodule JSONAPI.View do |> URI.to_string() end - defp requested_fields_for_type(view, %Conn{assigns: %{jsonapi_query: %{fields: fields}}}) do - fields[view.type()] + defp requested_fields_for_type(view, data, %Conn{assigns: %{jsonapi_query: %{fields: fields}}}) do + fields[view.resource_type(data)] end - defp requested_fields_for_type(_view, _conn), do: nil + defp requested_fields_for_type(_view, _data, _conn), do: nil defp host(%Conn{host: host}), do: Application.get_env(:jsonapi, :host, host) @@ -438,5 +575,5 @@ defmodule JSONAPI.View do defp scheme(%Conn{scheme: scheme}), do: Application.get_env(:jsonapi, :scheme, to_string(scheme)) - defp path_for(view), do: view.path() || view.type() + defp path_for(view, data), do: view.path() || view.resource_type(data) end diff --git a/test/jsonapi/serializer_test.exs b/test/jsonapi/serializer_test.exs index 54f4d803..c3f28e0a 100644 --- a/test/jsonapi/serializer_test.exs +++ b/test/jsonapi/serializer_test.exs @@ -13,7 +13,8 @@ defmodule JSONAPI.SerializerTest do def relationships do [ author: {JSONAPI.SerializerTest.UserView, :include}, - best_comments: {JSONAPI.SerializerTest.CommentView, :include} + best_comments: {JSONAPI.SerializerTest.CommentView, :include}, + polymorphics: {JSONAPI.SerializerTest.PolymorphicView, :include} ] end end @@ -183,6 +184,38 @@ defmodule JSONAPI.SerializerTest do end end + defmodule PolymorphicDataOne do + defstruct [:id, :some_field] + end + + defmodule PolymorphicDataTwo do + defstruct [:id, :some_other_field] + end + + defmodule PolymorphicView do + use JSONAPI.View, polymorphic_resource?: true + + def polymorphic_type(data) do + case data do + %PolymorphicDataOne{} -> + "polymorphic_data_one" + + %PolymorphicDataTwo{} -> + "polymorphic_data_one" + end + end + + def polymorphic_fields(data) do + case data do + %PolymorphicDataOne{} -> + [:some_field] + + %PolymorphicDataTwo{} -> + [:some_other_field] + end + end + end + setup do Application.put_env(:jsonapi, :field_transformation, :underscore) @@ -219,6 +252,10 @@ defmodule JSONAPI.SerializerTest do best_comments: [ %{id: 5, text: "greatest comment ever", user: %{id: 4, username: "jack"}}, %{id: 6, text: "not so great", user: %{id: 2, username: "jason"}} + ], + polymorphic: [ + %PolymorphicDataOne{id: 1, some_field: "foo"}, + %PolymorphicDataTwo{id: 2, some_other_field: "foo"} ] } @@ -842,7 +879,8 @@ defmodule JSONAPI.SerializerTest do assert configs == [ {:author, :author, JSONAPI.SerializerTest.UserView, true}, - {:best_comments, :best_comments, JSONAPI.SerializerTest.CommentView, true} + {:best_comments, :best_comments, JSONAPI.SerializerTest.CommentView, true}, + {:polymorphics, :polymorphics, JSONAPI.SerializerTest.PolymorphicView, true} ] end diff --git a/test/jsonapi/view_test.exs b/test/jsonapi/view_test.exs index 8ea8af01..85e74c8e 100644 --- a/test/jsonapi/view_test.exs +++ b/test/jsonapi/view_test.exs @@ -55,6 +55,38 @@ defmodule JSONAPI.ViewTest do def get_field(field, _data, _conn), do: "#{field}!" end + defmodule PolymorphicDataOne do + defstruct [:some_field] + end + + defmodule PolymorphicDataTwo do + defstruct [:some_other_field] + end + + defmodule PolymorphicView do + use JSONAPI.View, polymorphic_resource?: true + + def polymorphic_type(data) do + case data do + %PolymorphicDataOne{} -> + "polymorphic_data_one" + + %PolymorphicDataTwo{} -> + "polymorphic_data_one" + end + end + + def polymorphic_fields(data) do + case data do + %PolymorphicDataOne{} -> + [:some_field] + + %PolymorphicDataTwo{} -> + [:some_other_field] + end + end + end + setup do Application.put_env(:jsonapi, :field_transformation, :underscore) Application.put_env(:jsonapi, :namespace, "/other-api") @@ -71,6 +103,20 @@ defmodule JSONAPI.ViewTest do assert PostView.type() == "posts" end + describe "resource_type/1" do + test "equals result of type/0 if resource is not polymorphic" do + assert PostView.type() == PostView.resource_type(%{}) + end + + test "equals result of polymorphic_type/1 if resource is polymorphic" do + assert PolymorphicView.polymorphic_type(%PolymorphicDataOne{}) == + PolymorphicView.resource_type(%PolymorphicDataOne{}) + + assert PolymorphicView.polymorphic_type(%PolymorphicDataTwo{}) == + PolymorphicView.resource_type(%PolymorphicDataTwo{}) + end + end + describe "namespace/0" do setup do Application.put_env(:jsonapi, :namespace, "/cake") @@ -314,4 +360,13 @@ defmodule JSONAPI.ViewTest do static_fun: "static_fun/2" } == DynamicView.attributes(data, conn) end + + test "attributes/2 can return polymorphic fields" do + data = %PolymorphicDataTwo{some_other_field: "foo"} + conn = %Plug.Conn{assigns: %{jsonapi_query: %JSONAPI.Config{}}} + + assert %{ + some_other_field: "foo" + } == PolymorphicView.attributes(data, conn) + end end