From 0ca13d31468399f324332710a1c2fa4205f34ae7 Mon Sep 17 00:00:00 2001 From: treere Date: Fri, 22 May 2026 19:09:52 +0200 Subject: [PATCH] feat: JSON:API Atomic Operations extension (https://jsonapi.org/ext/atomic) --- config/test.exs | 3 + lib/jsonapi_plug.ex | 56 ++++++ lib/jsonapi_plug/atomic_plug.ex | 177 ++++++++++++++++++ lib/jsonapi_plug/document.ex | 119 +++++++++++- lib/jsonapi_plug/document/atomic_operation.ex | 76 ++++++++ lib/jsonapi_plug/document/operation_ref.ex | 60 ++++++ lib/jsonapi_plug/normalizer.ex | 134 ++++++++++++- lib/jsonapi_plug/phoenix/component.ex | 16 +- lib/jsonapi_plug/plug/atomic_params.ex | 37 ++++ test/jsonapi_plug/atomic_plug_test.exs | 171 +++++++++++++++++ .../document/atomic_operation_test.exs | 101 ++++++++++ .../document/atomic_results_test.exs | 113 +++++++++++ .../document/operation_ref_test.exs | 67 +++++++ test/jsonapi_plug/plug/atomic_params_test.exs | 167 +++++++++++++++++ test/jsonapi_plug/render_atomic_test.exs | 86 +++++++++ test/support/api.ex | 5 + test/support/plugs.ex | 10 + 17 files changed, 1390 insertions(+), 8 deletions(-) create mode 100644 lib/jsonapi_plug/atomic_plug.ex create mode 100644 lib/jsonapi_plug/document/atomic_operation.ex create mode 100644 lib/jsonapi_plug/document/operation_ref.ex create mode 100644 lib/jsonapi_plug/plug/atomic_params.ex create mode 100644 test/jsonapi_plug/atomic_plug_test.exs create mode 100644 test/jsonapi_plug/document/atomic_operation_test.exs create mode 100644 test/jsonapi_plug/document/atomic_results_test.exs create mode 100644 test/jsonapi_plug/document/operation_ref_test.exs create mode 100644 test/jsonapi_plug/plug/atomic_params_test.exs create mode 100644 test/jsonapi_plug/render_atomic_test.exs diff --git a/config/test.exs b/config/test.exs index e5e3c89..29800af 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,6 +1,7 @@ import Config alias JSONAPIPlug.TestSupport.API.{ + AtomicAPI, DasherizingAPI, DefaultAPI, OtherHostAPI, @@ -11,6 +12,8 @@ alias JSONAPIPlug.TestSupport.API.{ alias JSONAPIPlug.TestSupport.Pagination.PageBasedPagination +config :jsonapi_plug, AtomicAPI, extensions: ["https://jsonapi.org/ext/atomic"] + config :jsonapi_plug, DasherizingAPI, case: :dasherize config :jsonapi_plug, DefaultAPI, pagination: PageBasedPagination config :jsonapi_plug, OtherHostAPI, host: "www.otherhost.com" diff --git a/lib/jsonapi_plug.ex b/lib/jsonapi_plug.ex index 894fb47..fe12ea3 100644 --- a/lib/jsonapi_plug.ex +++ b/lib/jsonapi_plug.ex @@ -22,9 +22,11 @@ defmodule JSONAPIPlug do fields: term(), filter: term(), include: term(), + operations: [map()] | nil, page: term(), params: Conn.params(), resource: Resource.t(), + resource_types: %{String.t() => module()} | nil, sort: term() } defstruct allowed_includes: nil, @@ -33,9 +35,11 @@ defmodule JSONAPIPlug do fields: nil, filter: nil, include: nil, + operations: nil, page: nil, params: nil, resource: nil, + resource_types: nil, sort: nil @doc """ @@ -70,6 +74,58 @@ defmodule JSONAPIPlug do |> Document.serialize() end + @doc """ + Render JSON:API Atomic Operations response + + Renders the `atomic:results` document for a batch of atomic operations. + Accepts a list of `{resource_or_nil, meta_or_nil}` result tuples, one per operation. + + Returns `nil` when all results have a nil resource, signalling to the caller + that a `204 No Content` response with no body is appropriate. + + ## Examples + + # In a controller handling POST /operations + results = Enum.map(conn.private.jsonapi_plug.operations, fn op -> + case op.op do + "add" -> {%Post{id: "1", title: "Hello"}, nil} + "remove" -> {nil, nil} + end + end) + + case render_atomic(conn, results) do + nil -> send_resp(conn, 204, "") + document -> json(conn, document) + end + """ + @spec render_atomic( + Conn.t(), + [{Resource.t() | nil, Document.meta() | nil}], + Resource.options() + ) :: Document.t() | nil | no_return() + def render_atomic(conn, results, options \\ []) + + def render_atomic(conn, results, options) when is_list(results) do + all_empty? = + Enum.all?(results, fn {resource, meta} -> is_nil(resource) and is_nil(meta) end) + + if all_empty? do + nil + else + serialized_results = Enum.map(results, &serialize_atomic_result(conn, &1, options)) + Document.serialize(%Document{results: serialized_results}) + end + end + + defp serialize_atomic_result(_conn, {nil, nil}, _options), do: %{} + + defp serialize_atomic_result(_conn, {nil, meta}, _options), do: %{meta: meta} + + defp serialize_atomic_result(conn, {resource, meta}, options) do + %Document{data: resource_object} = Normalizer.normalize(conn, resource, nil, nil, options) + if meta, do: %{data: resource_object, meta: meta}, else: %{data: resource_object} + end + @doc """ Generate relationships link diff --git a/lib/jsonapi_plug/atomic_plug.ex b/lib/jsonapi_plug/atomic_plug.ex new file mode 100644 index 0000000..f94a454 --- /dev/null +++ b/lib/jsonapi_plug/atomic_plug.ex @@ -0,0 +1,177 @@ +defmodule JSONAPIPlug.AtomicPlug do + @moduledoc """ + Plug pipeline for JSON:API Atomic Operations endpoints + + This plug implements the JSON:API Atomic Operations extension + (`https://jsonapi.org/ext/atomic`) request handling pipeline: + + 1. Enforces that the request method is `POST` (returns 405 otherwise). + 2. Validates `Content-Type` and `Accept` headers via `ContentTypeNegotiation`. + 3. Registers the response `Content-Type` hook via `ResponseContentType`. + 4. Parses and normalizes the `atomic:operations` request body via `AtomicParams`. + + ## Usage + + ```elixir + plug JSONAPIPlug.AtomicPlug, + api: MyApp.API, + resources: [MyApp.Post, MyApp.Comment] + ``` + + After the pipeline runs, `conn.private.jsonapi_plug.operations` holds a list of + normalised operation maps, each with `:op`, `:type`, and `:params` keys. + + ## Options + + - `api:` (required) — A module `use`-ing `JSONAPIPlug.API`. + - `resources:` (required) — A list of resource modules used to resolve operation types. + The JSON:API `type` string of each module is mapped to the module at init time. + + ## Notes + + - Atomicity at the database level is the responsibility of the calling application. + - `lid` values in `add` operations are forwarded in params as `"lid"` for + cross-operation identity resolution by the application. + """ + + @options_schema NimbleOptions.new!( + api: [ + doc: "A module use-ing `JSONAPIPlug.API` to provide configuration", + type: :atom, + required: true + ], + resources: [ + doc: + "List of resource modules. The JSON:API type of each is resolved at init time.", + type: {:list, :atom}, + required: true + ] + ) + + @typedoc """ + Options: + #{NimbleOptions.docs(@options_schema)} + """ + @type options :: keyword() + + use Plug.Builder, copy_opts_to_assign: :jsonapi_plug + use Plug.ErrorHandler + + require Logger + + alias JSONAPIPlug.{API, Document, Exceptions, Resource} + alias JSONAPIPlug.Plug.{AtomicParams, ContentTypeNegotiation, ResponseContentType} + alias Plug.Conn + + plug :enforce_post_method + plug :config + plug ContentTypeNegotiation + plug ResponseContentType + plug AtomicParams + + @impl Plug + def init(opts) do + opts = NimbleOptions.validate!(opts, @options_schema) + resource_types = build_resource_types(opts[:resources]) + Keyword.put(opts, :resource_types, resource_types) + end + + @doc false + def enforce_post_method(%Conn{method: "POST"} = conn, _opts), do: conn + + def enforce_post_method(conn, _opts) do + conn + |> put_resp_content_type(JSONAPIPlug.mime_type()) + |> send_resp( + 405, + Jason.encode!(%Document{ + errors: [%Document.ErrorObject{status: "405", title: "Method Not Allowed"}] + }) + ) + |> halt() + end + + @doc false + def config(%Conn{} = conn, _options) do + {options, assigns} = Map.pop!(conn.assigns, :jsonapi_plug) + + %{conn | assigns: assigns} + |> fetch_query_params() + |> put_private(:jsonapi_plug, %JSONAPIPlug{ + config: API.get_config(options[:api]), + resource_types: options[:resource_types], + base_url: build_base_url(conn, options) + }) + end + + @impl Plug.ErrorHandler + def handle_errors( + conn, + %{kind: :error, reason: %Exceptions.InvalidDocument{} = exception, stack: _stack} + ) do + send_error(conn, :bad_request, %Document.ErrorObject{ + detail: "#{exception.message}. See #{exception.reference} for more information." + }) + end + + def handle_errors( + conn, + %{kind: :error, reason: %Exceptions.InvalidHeader{} = exception, stack: _stack} + ) do + send_error(conn, exception.status, %Document.ErrorObject{ + detail: "#{exception.message}. See #{exception.reference} for more information.", + source: %{pointer: "/header/#{exception.header}"} + }) + end + + def handle_errors(conn, error) do + Logger.error("Unhandled exception in AtomicPlug: #{inspect(error)}") + send_resp(conn, 500, "Something went wrong") + end + + defp send_error(conn, code, %Document.ErrorObject{} = error) do + status_code = Conn.Status.code(code) + + conn + |> put_resp_content_type(JSONAPIPlug.mime_type()) + |> send_resp( + status_code, + Jason.encode!(%Document{ + errors: [ + %{error | status: to_string(status_code), title: Conn.Status.reason_phrase(status_code)} + ] + }) + ) + |> halt() + end + + defp build_resource_types(resource_modules) do + Map.new(resource_modules, fn module -> + resource = struct(module) + {Resource.type(resource), module} + end) + end + + defp build_base_url(conn, options) do + config = API.get_config(options[:api]) + + scheme = to_string(config[:scheme] || conn.scheme) + host = config[:host] || conn.host + + namespace = + case config[:namespace] do + nil -> "" + ns -> "/" <> ns + end + + port = config[:port] || conn.port + port = if port != URI.default_port(scheme), do: port + + to_string(%URI{ + scheme: scheme, + host: host, + path: Enum.join([namespace, "operations"], "/"), + port: port + }) + end +end diff --git a/lib/jsonapi_plug/document.ex b/lib/jsonapi_plug/document.ex index 5138c99..fbc7be3 100644 --- a/lib/jsonapi_plug/document.ex +++ b/lib/jsonapi_plug/document.ex @@ -9,6 +9,7 @@ defmodule JSONAPIPlug.Document do """ alias JSONAPIPlug.{ + Document.AtomicOperation, Document.ErrorObject, Document.JSONAPIObject, Document.LinkObject, @@ -63,6 +64,20 @@ defmodule JSONAPIPlug.Document do """ @type links :: %{atom() => LinkObject.t()} + @typedoc """ + JSON:API Atomic Operations + + https://jsonapi.org/ext/atomic/ + """ + @type operations :: [AtomicOperation.t()] + + @typedoc """ + JSON:API Atomic Results + + https://jsonapi.org/ext/atomic/ + """ + @type results :: [%{optional(:data) => ResourceObject.t() | nil, optional(:meta) => meta()}] + @typedoc """ JSON:API Document @@ -74,9 +89,11 @@ defmodule JSONAPIPlug.Document do included: included() | nil, jsonapi: jsonapi() | nil, links: links() | nil, - meta: meta() | nil + meta: meta() | nil, + operations: operations() | nil, + results: results() | nil } - defstruct [:data, :errors, :included, :jsonapi, :links, :meta] + defstruct [:data, :errors, :included, :jsonapi, :links, :meta, :operations, :results] @doc """ Deserialize JSON:API Document @@ -85,6 +102,27 @@ defmodule JSONAPIPlug.Document do and parses it into a `t:t/0` struct. """ @spec deserialize(payload()) :: t() | no_return() + def deserialize(%{"atomic:operations" => _, "data" => _}) do + raise InvalidDocument, + message: "Document cannot contain both 'atomic:operations' and 'data' members", + reference: "https://jsonapi.org/ext/atomic/" + end + + def deserialize(%{"atomic:operations" => _, "errors" => _}) do + raise InvalidDocument, + message: "Document cannot contain both 'atomic:operations' and 'errors' members", + reference: "https://jsonapi.org/ext/atomic/" + end + + def deserialize(%{"atomic:operations" => operations} = data) do + %__MODULE__{ + jsonapi: deserialize_jsonapi(data), + links: deserialize_links(data), + meta: deserialize_meta(data), + operations: deserialize_operations(operations) + } + end + def deserialize(data) do %__MODULE__{ data: deserialize_data(data), @@ -96,6 +134,21 @@ defmodule JSONAPIPlug.Document do } end + defp deserialize_operations([]) do + raise InvalidDocument, + message: "'atomic:operations' array must not be empty", + reference: "https://jsonapi.org/ext/atomic/" + end + + defp deserialize_operations(operations) when is_list(operations), + do: Enum.map(operations, &AtomicOperation.deserialize/1) + + defp deserialize_operations(_operations) do + raise InvalidDocument, + message: "'atomic:operations' must be an array", + reference: "https://jsonapi.org/ext/atomic/" + end + defp deserialize_data(%{"data" => _data, "errors" => _errors}) do raise InvalidDocument, message: "Document cannot contain both 'data' and 'errors' members", @@ -165,6 +218,10 @@ defmodule JSONAPIPlug.Document do it and returns the struct if valid. """ @spec serialize(t()) :: t() | no_return() + def serialize(%__MODULE__{results: results} = document) when not is_nil(results) do + %{document | results: serialize_results(results)} + end + def serialize(%__MODULE__{} = document) do %{ document @@ -184,6 +241,15 @@ defmodule JSONAPIPlug.Document do defp serialize_data(nil), do: nil + defp serialize_results(results) when is_list(results), + do: Enum.map(results, &serialize_result/1) + + defp serialize_result(%{data: %ResourceObject{} = resource_object} = result) do + %{result | data: ResourceObject.serialize(resource_object)} + end + + defp serialize_result(result), do: result + defp serialize_errors(errors) when not is_nil(errors) and not is_list(errors) do raise InvalidDocument, @@ -228,7 +294,6 @@ end defimpl Jason.Encoder, for: [ - JSONAPIPlug.Document, JSONAPIPlug.Document.ErrorObject, JSONAPIPlug.Document.JSONAPIObject, JSONAPIPlug.Document.LinkObject, @@ -247,3 +312,51 @@ defimpl Jason.Encoder, |> Jason.Encode.map(options) end end + +defimpl Jason.Encoder, for: JSONAPIPlug.Document do + def encode(%JSONAPIPlug.Document{results: results} = document, options) + when not is_nil(results) do + # atomic:results response document — emit "atomic:results" with string key + document + |> Map.from_struct() + |> Enum.reduce(%{}, fn + {:results, results}, data when not is_nil(results) -> + Map.put(data, "atomic:results", results) + + {:data, _}, data -> + data + + {:included, _}, data -> + data + + {:operations, _}, data -> + data + + {:errors, _}, data -> + data + + {_key, nil}, data -> + data + + {_key, %{} = map}, data when map_size(map) == 0 -> + data + + {key, value}, data -> + Map.put(data, key, value) + end) + |> Jason.Encode.map(options) + end + + def encode(document, options) do + document + |> Map.from_struct() + |> Enum.reduce(%{}, fn + {:operations, _}, data -> data + {:results, _}, data -> data + {_key, nil}, data -> data + {_key, %{} = map}, data when map_size(map) == 0 -> data + {key, value}, data -> Map.put(data, key, value) + end) + |> Jason.Encode.map(options) + end +end diff --git a/lib/jsonapi_plug/document/atomic_operation.ex b/lib/jsonapi_plug/document/atomic_operation.ex new file mode 100644 index 0000000..ae95cce --- /dev/null +++ b/lib/jsonapi_plug/document/atomic_operation.ex @@ -0,0 +1,76 @@ +defmodule JSONAPIPlug.Document.AtomicOperation do + @moduledoc """ + JSON:API Atomic Operations Extension — Operation Object + + Represents a single operation within an `atomic:operations` request document. + + https://jsonapi.org/ext/atomic/ + """ + + alias JSONAPIPlug.{ + Document, + Document.OperationRef, + Document.ResourceObject, + Exceptions.InvalidDocument + } + + @valid_ops ~w(add update remove) + + @type op :: String.t() + + @type t :: %__MODULE__{ + data: ResourceObject.t() | nil, + href: String.t() | nil, + meta: Document.meta() | nil, + op: op(), + ref: OperationRef.t() | nil + } + + defstruct data: nil, href: nil, meta: nil, op: nil, ref: nil + + @doc "Deserializes a raw map into an %AtomicOperation{} struct" + @spec deserialize(Document.payload()) :: t() | no_return() + def deserialize(%{"op" => op} = data) when op in @valid_ops do + validate_ref_href_mutual_exclusion(data) + + %__MODULE__{ + op: op, + ref: deserialize_ref(data), + href: deserialize_href(data), + data: deserialize_data(data), + meta: deserialize_meta(data) + } + end + + def deserialize(%{"op" => op}) do + raise InvalidDocument, + message: "Operation 'op' must be one of 'add', 'update', or 'remove', got '#{op}'", + reference: "https://jsonapi.org/ext/atomic/" + end + + def deserialize(_data) do + raise InvalidDocument, + message: "Operation object must contain an 'op' member", + reference: "https://jsonapi.org/ext/atomic/" + end + + defp validate_ref_href_mutual_exclusion(%{"ref" => _, "href" => _}) do + raise InvalidDocument, + message: "Operation object MUST NOT contain both 'ref' and 'href'", + reference: "https://jsonapi.org/ext/atomic/" + end + + defp validate_ref_href_mutual_exclusion(_data), do: :ok + + defp deserialize_ref(%{"ref" => ref}) when is_map(ref), do: OperationRef.deserialize(ref) + defp deserialize_ref(_data), do: nil + + defp deserialize_href(%{"href" => href}) when is_binary(href) and byte_size(href) > 0, do: href + defp deserialize_href(_data), do: nil + + defp deserialize_data(%{"data" => data}) when is_map(data), do: ResourceObject.deserialize(data) + defp deserialize_data(_data), do: nil + + defp deserialize_meta(%{"meta" => meta}) when is_map(meta), do: meta + defp deserialize_meta(_data), do: nil +end diff --git a/lib/jsonapi_plug/document/operation_ref.ex b/lib/jsonapi_plug/document/operation_ref.ex new file mode 100644 index 0000000..3a64cdb --- /dev/null +++ b/lib/jsonapi_plug/document/operation_ref.ex @@ -0,0 +1,60 @@ +defmodule JSONAPIPlug.Document.OperationRef do + @moduledoc """ + JSON:API Atomic Operations Extension — Operation Ref Object + + Represents the `ref` member of an operation object, used to target a specific + resource or relationship. + + https://jsonapi.org/ext/atomic/ + """ + + alias JSONAPIPlug.{Document, Exceptions.InvalidDocument} + + @type t :: %__MODULE__{ + id: Document.ResourceObject.id() | nil, + lid: Document.ResourceObject.id() | nil, + relationship: String.t() | nil, + type: Document.ResourceObject.type() + } + + defstruct id: nil, lid: nil, relationship: nil, type: nil + + @doc "Deserializes a raw map into an %OperationRef{} struct" + @spec deserialize(Document.payload()) :: t() | no_return() + def deserialize(%{"type" => type} = data) + when is_binary(type) and byte_size(type) > 0 do + id = deserialize_id(data) + lid = deserialize_lid(data) + + if is_nil(id) and is_nil(lid) do + raise InvalidDocument, + message: "Operation 'ref' must contain either 'id' or 'lid'", + reference: "https://jsonapi.org/ext/atomic/" + end + + %__MODULE__{ + type: type, + id: id, + lid: lid, + relationship: deserialize_relationship(data) + } + end + + def deserialize(_data) do + raise InvalidDocument, + message: "Operation 'ref' must contain a non-empty string 'type' member", + reference: "https://jsonapi.org/ext/atomic/" + end + + defp deserialize_id(%{"id" => id}) when is_binary(id) and byte_size(id) > 0, do: id + defp deserialize_id(_data), do: nil + + defp deserialize_lid(%{"lid" => lid}) when is_binary(lid) and byte_size(lid) > 0, do: lid + defp deserialize_lid(_data), do: nil + + defp deserialize_relationship(%{"relationship" => rel}) + when is_binary(rel) and byte_size(rel) > 0, + do: rel + + defp deserialize_relationship(_data), do: nil +end diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index bb6bae5..6599ef2 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -33,6 +33,7 @@ defmodule JSONAPIPlug.Normalizer do alias JSONAPIPlug.{ Document, + Document.AtomicOperation, Document.JSONAPIObject, Document.RelationshipObject, Document.ResourceIdentifierObject, @@ -67,7 +68,12 @@ defmodule JSONAPIPlug.Normalizer do params() | no_return() @doc "Transforms a JSON:API Document user data" - @spec denormalize(Document.t(), Resource.t(), Conn.t()) :: Conn.params() | no_return() + @spec denormalize(Document.t(), Resource.t(), Conn.t()) :: + Conn.params() | [Conn.params()] | no_return() + def denormalize(%Document{operations: operations} = document, _resource, conn) + when is_list(operations), + do: Enum.map(operations, &denormalize_operation(document, &1, conn)) + def denormalize(%Document{data: nil}, _resource, _conn), do: %{} def denormalize(%Document{data: resource_objects} = document, resource, conn) @@ -93,6 +99,132 @@ defmodule JSONAPIPlug.Normalizer do |> denormalize_relationships(resource_object, document, resource, conn) end + defp denormalize_operation( + document, + %AtomicOperation{op: op, ref: ref, data: data} = _operation, + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn + ) do + type = resolve_operation_type(ref, data) + resource = resolve_resource_module(type, jsonapi_plug) + normalizer = jsonapi_plug.config[:normalizer] + + params = + normalizer.resource_params() + |> denormalize_operation_id(op, ref, data, resource, conn) + + params = + if op in ["add", "update"] and not is_nil(data) do + params + |> denormalize_attributes(data, resource, conn) + |> denormalize_relationships(data, document, resource, conn) + else + params + end + + # Capture lid if present (always forwarded to application for cross-operation resolution) + params = + if op == "add" and not is_nil(data) and not is_nil(data.lid) do + Map.put(params, "lid", data.lid) + else + params + end + + %{op: op, type: type, params: params} + end + + defp resolve_operation_type(ref, data) do + cond do + not is_nil(ref) -> + ref.type + + not is_nil(data) -> + data.type + + true -> + raise InvalidDocument, + message: "Cannot determine type for operation", + reference: "https://jsonapi.org/ext/atomic/" + end + end + + defp resolve_resource_module(type, %JSONAPIPlug{} = jsonapi_plug) do + case Map.get(jsonapi_plug.resource_types || %{}, type) do + nil -> + raise InvalidDocument, + message: "Unknown resource type '#{type}' in atomic operation", + reference: "https://jsonapi.org/ext/atomic/" + + module -> + struct(module) + end + end + + # For "remove" operations: get id from ref or data, no attributes + defp denormalize_operation_id( + params, + "remove", + ref, + data, + resource, + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = _conn + ) do + id = (ref && ref.id) || (data && data.id) + + if is_nil(id) do + params + else + jsonapi_plug.config[:normalizer].denormalize_attribute( + params, + Resource.id_attribute(resource), + id + ) + end + end + + # For "update" operations: id required from ref or data + defp denormalize_operation_id( + params, + "update", + ref, + data, + resource, + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = _conn + ) do + id = (ref && ref.id) || (data && data.id) + + if is_nil(id) do + params + else + jsonapi_plug.config[:normalizer].denormalize_attribute( + params, + Resource.id_attribute(resource), + id + ) + end + end + + # For "add" operations: include id only if client-generated IDs enabled + defp denormalize_operation_id( + params, + "add", + _ref, + data, + resource, + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = _conn + ) do + case {jsonapi_plug.config[:client_generated_ids], data && data.id} do + {true, id} when not is_nil(id) -> + jsonapi_plug.config[:normalizer].denormalize_attribute( + params, + Resource.id_attribute(resource), + id + ) + + _ -> + params + end + end + defp denormalize_id( params, %ResourceObject{} = resource_object, diff --git a/lib/jsonapi_plug/phoenix/component.ex b/lib/jsonapi_plug/phoenix/component.ex index 8be5b41..9d0d16c 100644 --- a/lib/jsonapi_plug/phoenix/component.ex +++ b/lib/jsonapi_plug/phoenix/component.ex @@ -6,11 +6,11 @@ defmodule JSONAPIPlug.Phoenix.Component do @doc """ JSONAPIPlug generated resource render function - It takes the action (one of "create.json", "index.json", "show.json", "update.json") and - the assings as a keyword list or map with atom keys. + It takes the action (one of "create.json", "index.json", "show.json", "update.json", + "operations.json") and the assigns as a keyword list or map with atom keys. """ @spec render(action :: String.t(), assigns :: keyword() | %{atom() => term()}) :: - JSONAPIPlug.Document.t() | no_return() + JSONAPIPlug.Document.t() | nil | no_return() def render(action, assigns) when action in ["create.json", "index.json", "show.json", "update.json"] do JSONAPIPlug.render( @@ -21,8 +21,16 @@ defmodule JSONAPIPlug.Phoenix.Component do ) end + def render("operations.json", assigns) do + JSONAPIPlug.render_atomic( + assigns[:conn], + assigns[:results], + assigns[:options] || [] + ) + end + def render(action, _assigns) do - raise "invalid action #{action}, use one of create.json, index.json, show.json, update.json" + raise "invalid action #{action}, use one of create.json, index.json, show.json, update.json, operations.json" end end end diff --git a/lib/jsonapi_plug/plug/atomic_params.ex b/lib/jsonapi_plug/plug/atomic_params.ex new file mode 100644 index 0000000..67cbfa5 --- /dev/null +++ b/lib/jsonapi_plug/plug/atomic_params.ex @@ -0,0 +1,37 @@ +defmodule JSONAPIPlug.Plug.AtomicParams do + @moduledoc """ + Plug for parsing the JSON:API Atomic Operations document in requests + + It reads `"atomic:operations"` from the request body, deserializes the document + into a list of `%AtomicOperation{}` structs, normalizes each operation into a params map, + and stores the resulting list in `conn.private.jsonapi_plug.operations`. + + `conn.private.jsonapi_plug.params` remains `nil` for atomic requests. + """ + + alias JSONAPIPlug.{Document, Normalizer} + alias Plug.Conn + + @behaviour Plug + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(%Conn{body_params: %Conn.Unfetched{aspect: :body_params}}, _opts) do + raise "Body unfetched when trying to parse JSON:API Atomic Operations document" + end + + def call( + %Conn{body_params: body_params, private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = + conn, + _opts + ) do + document = Document.deserialize(body_params) + + operations = + Normalizer.denormalize(document, jsonapi_plug.resource, conn) + + Conn.put_private(conn, :jsonapi_plug, %{jsonapi_plug | operations: operations, params: nil}) + end +end diff --git a/test/jsonapi_plug/atomic_plug_test.exs b/test/jsonapi_plug/atomic_plug_test.exs new file mode 100644 index 0000000..f53d12f --- /dev/null +++ b/test/jsonapi_plug/atomic_plug_test.exs @@ -0,0 +1,171 @@ +defmodule JSONAPIPlug.AtomicPlugTest do + use ExUnit.Case, async: false + + import Plug.Conn + import Plug.Test + + alias JSONAPIPlug.TestSupport.Plugs.AtomicOperationsPlug + alias Plug.Conn + + @atomic_ext "https://jsonapi.org/ext/atomic" + @content_type "application/vnd.api+json; ext=\"#{@atomic_ext}\"" + + defp atomic_post(body) do + conn(:post, "/operations", Jason.encode!(body)) + |> put_req_header("content-type", @content_type) + |> put_req_header("accept", @content_type) + end + + describe "POST method enforcement" do + test "POST request with valid body is accepted" do + body = %{ + "atomic:operations" => [ + %{"op" => "add", "data" => %{"type" => "post", "attributes" => %{"title" => "Hi"}}} + ] + } + + conn = + atomic_post(body) + |> AtomicOperationsPlug.call([]) + + refute conn.halted + assert conn.private.jsonapi_plug.operations != nil + end + + test "GET request returns 405" do + conn = + conn(:get, "/operations") + |> put_req_header("content-type", @content_type) + |> put_req_header("accept", @content_type) + |> AtomicOperationsPlug.call([]) + + assert conn.halted + assert conn.status == 405 + end + + test "PATCH request returns 405" do + conn = + conn(:patch, "/operations", "{}") + |> put_req_header("content-type", @content_type) + |> put_req_header("accept", @content_type) + |> AtomicOperationsPlug.call([]) + + assert conn.halted + assert conn.status == 405 + end + + test "DELETE request returns 405" do + conn = + conn(:delete, "/operations") + |> put_req_header("content-type", @content_type) + |> put_req_header("accept", @content_type) + |> AtomicOperationsPlug.call([]) + + assert conn.halted + assert conn.status == 405 + end + end + + describe "content negotiation" do + test "request with unsupported ext in Content-Type raises 415" do + body = %{ + "atomic:operations" => [ + %{"op" => "add", "data" => %{"type" => "post", "attributes" => %{"title" => "Hi"}}} + ] + } + + # Sending a different (unsupported) ext URI — should get 415 + assert_raise JSONAPIPlug.Exceptions.InvalidHeader, fn -> + conn(:post, "/operations", Jason.encode!(body)) + |> put_req_header( + "content-type", + "application/vnd.api+json; ext=\"https://unknown.example.com/ext\"" + ) + |> put_req_header("accept", @content_type) + |> AtomicOperationsPlug.call([]) + end + end + + test "request with atomic ext in Content-Type and Accept passes" do + body = %{ + "atomic:operations" => [ + %{"op" => "add", "data" => %{"type" => "post", "attributes" => %{"title" => "Hi"}}} + ] + } + + conn = + atomic_post(body) + |> AtomicOperationsPlug.call([]) + + refute conn.halted + end + + test "request without ext in Content-Type passes content negotiation (ext not required by server)" do + body = %{ + "atomic:operations" => [ + %{"op" => "add", "data" => %{"type" => "post", "attributes" => %{"title" => "Hi"}}} + ] + } + + conn = + conn(:post, "/operations", Jason.encode!(body)) + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> AtomicOperationsPlug.call([]) + + # Content negotiation does not require ext from client; body parsing will succeed + refute conn.halted + end + end + + describe "operations parsing" do + test "valid multi-operation batch is parsed into jsonapi_plug.operations" do + body = %{ + "atomic:operations" => [ + %{"op" => "add", "data" => %{"type" => "post", "attributes" => %{"title" => "First"}}}, + %{"op" => "remove", "ref" => %{"type" => "post", "id" => "99"}} + ] + } + + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{operations: operations}}} = + atomic_post(body) + |> AtomicOperationsPlug.call([]) + + assert length(operations) == 2 + assert Enum.at(operations, 0).op == "add" + assert Enum.at(operations, 1).op == "remove" + assert Enum.at(operations, 1).params["id"] == "99" + end + + test "jsonapi_plug.params is nil for atomic requests" do + body = %{ + "atomic:operations" => [ + %{"op" => "add", "data" => %{"type" => "post", "attributes" => %{"title" => "Hi"}}} + ] + } + + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + atomic_post(body) + |> AtomicOperationsPlug.call([]) + + assert is_nil(params) + end + end + + describe "pipeline isolation" do + test "AtomicPlug does not affect single-resource plug pipelines" do + # Verify that the regular PostResourcePlug still works independently + alias JSONAPIPlug.TestSupport.Plugs.PostResourcePlug + + conn = + conn(:get, "/") + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> PostResourcePlug.call([]) + + refute conn.halted + assert %JSONAPIPlug{} = conn.private.jsonapi_plug + assert is_nil(conn.private.jsonapi_plug.operations) + end + end +end diff --git a/test/jsonapi_plug/document/atomic_operation_test.exs b/test/jsonapi_plug/document/atomic_operation_test.exs new file mode 100644 index 0000000..284bf72 --- /dev/null +++ b/test/jsonapi_plug/document/atomic_operation_test.exs @@ -0,0 +1,101 @@ +defmodule JSONAPIPlug.Document.AtomicOperationTest do + use ExUnit.Case, async: true + + alias JSONAPIPlug.Document.{AtomicOperation, OperationRef, ResourceObject} + alias JSONAPIPlug.Exceptions.InvalidDocument + + describe "deserialize/1 — valid op codes" do + test "op code 'add' with data is accepted" do + assert %AtomicOperation{op: "add", data: %ResourceObject{type: "articles"}} = + AtomicOperation.deserialize(%{ + "op" => "add", + "data" => %{"type" => "articles", "attributes" => %{"title" => "Hello"}} + }) + end + + test "op code 'update' is accepted" do + assert %AtomicOperation{op: "update", data: %ResourceObject{type: "articles", id: "1"}} = + AtomicOperation.deserialize(%{ + "op" => "update", + "data" => %{"type" => "articles", "id" => "1", "attributes" => %{"title" => "X"}} + }) + end + + test "op code 'remove' with ref is accepted" do + assert %AtomicOperation{op: "remove", ref: %OperationRef{type: "articles", id: "13"}} = + AtomicOperation.deserialize(%{ + "op" => "remove", + "ref" => %{"type" => "articles", "id" => "13"} + }) + end + end + + describe "deserialize/1 — invalid op codes" do + test "unknown op code raises InvalidDocument" do + assert_raise InvalidDocument, fn -> + AtomicOperation.deserialize(%{"op" => "replace", "data" => %{"type" => "articles"}}) + end + end + + test "op code 'create' raises InvalidDocument" do + assert_raise InvalidDocument, fn -> + AtomicOperation.deserialize(%{"op" => "create", "data" => %{"type" => "articles"}}) + end + end + + test "missing op member raises InvalidDocument" do + assert_raise InvalidDocument, fn -> + AtomicOperation.deserialize(%{"data" => %{"type" => "articles"}}) + end + end + end + + describe "deserialize/1 — ref and href mutual exclusion" do + test "both ref and href raises InvalidDocument" do + assert_raise InvalidDocument, fn -> + AtomicOperation.deserialize(%{ + "op" => "update", + "ref" => %{"type" => "articles", "id" => "1"}, + "href" => "/articles/1" + }) + end + end + + test "only ref is accepted" do + assert %AtomicOperation{op: "update", ref: %OperationRef{}, href: nil} = + AtomicOperation.deserialize(%{ + "op" => "update", + "ref" => %{"type" => "articles", "id" => "1"}, + "data" => %{"type" => "articles", "attributes" => %{"title" => "New"}} + }) + end + + test "only href is accepted" do + assert %AtomicOperation{op: "add", href: "/blogPosts", ref: nil} = + AtomicOperation.deserialize(%{ + "op" => "add", + "href" => "/blogPosts", + "data" => %{"type" => "articles", "attributes" => %{"title" => "Hello"}} + }) + end + end + + describe "deserialize/1 — meta member" do + test "meta is parsed when present" do + assert %AtomicOperation{meta: %{"key" => "value"}} = + AtomicOperation.deserialize(%{ + "op" => "add", + "data" => %{"type" => "articles"}, + "meta" => %{"key" => "value"} + }) + end + + test "meta is nil when not present" do + assert %AtomicOperation{meta: nil} = + AtomicOperation.deserialize(%{ + "op" => "add", + "data" => %{"type" => "articles"} + }) + end + end +end diff --git a/test/jsonapi_plug/document/atomic_results_test.exs b/test/jsonapi_plug/document/atomic_results_test.exs new file mode 100644 index 0000000..e0af2b8 --- /dev/null +++ b/test/jsonapi_plug/document/atomic_results_test.exs @@ -0,0 +1,113 @@ +defmodule JSONAPIPlug.Document.AtomicResultsTest do + use ExUnit.Case, async: true + + alias JSONAPIPlug.Document + alias JSONAPIPlug.Document.ResourceObject + alias JSONAPIPlug.Exceptions.InvalidDocument + + describe "Document.deserialize/1 with atomic:operations" do + test "deserializes a valid atomic:operations document" do + assert %Document{operations: [operation], data: nil} = + Document.deserialize(%{ + "atomic:operations" => [ + %{ + "op" => "add", + "data" => %{"type" => "articles", "attributes" => %{"title" => "Hello"}} + } + ] + }) + + assert operation.op == "add" + end + + test "rejects document with both atomic:operations and data" do + assert_raise InvalidDocument, fn -> + Document.deserialize(%{ + "atomic:operations" => [%{"op" => "add", "data" => %{"type" => "articles"}}], + "data" => %{"type" => "articles", "id" => "1"} + }) + end + end + + test "rejects document with both atomic:operations and errors" do + assert_raise InvalidDocument, fn -> + Document.deserialize(%{ + "atomic:operations" => [%{"op" => "add", "data" => %{"type" => "articles"}}], + "errors" => [%{"title" => "some error"}] + }) + end + end + + test "rejects empty atomic:operations array" do + assert_raise InvalidDocument, fn -> + Document.deserialize(%{"atomic:operations" => []}) + end + end + end + + describe "Document.serialize/1 with results" do + test "serializes atomic:results document with data" do + resource = %ResourceObject{id: "1", type: "articles", attributes: %{"title" => "Hello"}} + document = Document.serialize(%Document{results: [%{data: resource}]}) + + assert %Document{results: [%{data: %ResourceObject{id: "1"}}]} = document + end + + test "serializes empty result objects for nil-data results" do + document = Document.serialize(%Document{results: [%{}, %{data: nil}]}) + assert %Document{results: [%{}, %{data: nil}]} = document + end + + test "serializes mixed results" do + resource = %ResourceObject{id: "2", type: "post"} + + document = + Document.serialize(%Document{ + results: [%{data: resource}, %{}] + }) + + assert %Document{results: [_, _]} = document + end + + test "JSON encoding emits atomic:results key" do + resource = %ResourceObject{id: "1", type: "articles"} + document = Document.serialize(%Document{results: [%{data: resource}]}) + encoded = Jason.encode!(document) + decoded = Jason.decode!(encoded) + + assert Map.has_key?(decoded, "atomic:results") + refute Map.has_key?(decoded, "data") + refute Map.has_key?(decoded, "included") + end + + test "JSON encoding does not include data or included in atomic:results document" do + resource = %ResourceObject{id: "1", type: "articles"} + + document = + Document.serialize(%Document{ + results: [%{data: resource}], + data: resource, + included: [resource] + }) + + encoded = Jason.encode!(document) + decoded = Jason.decode!(encoded) + + refute Map.has_key?(decoded, "data") + refute Map.has_key?(decoded, "included") + assert Map.has_key?(decoded, "atomic:results") + end + end + + describe "Document.serialize/1 standard (no results)" do + test "JSON encoding does not emit atomic:results key in standard document" do + resource = %ResourceObject{id: "1", type: "articles"} + document = Document.serialize(%Document{data: resource}) + encoded = Jason.encode!(document) + decoded = Jason.decode!(encoded) + + refute Map.has_key?(decoded, "atomic:results") + assert Map.has_key?(decoded, "data") + end + end +end diff --git a/test/jsonapi_plug/document/operation_ref_test.exs b/test/jsonapi_plug/document/operation_ref_test.exs new file mode 100644 index 0000000..e6c1371 --- /dev/null +++ b/test/jsonapi_plug/document/operation_ref_test.exs @@ -0,0 +1,67 @@ +defmodule JSONAPIPlug.Document.OperationRefTest do + use ExUnit.Case, async: true + + alias JSONAPIPlug.Document.OperationRef + alias JSONAPIPlug.Exceptions.InvalidDocument + + describe "deserialize/1" do + test "valid ref with type and id" do + assert %OperationRef{type: "articles", id: "1", lid: nil, relationship: nil} = + OperationRef.deserialize(%{"type" => "articles", "id" => "1"}) + end + + test "valid ref with type and lid" do + assert %OperationRef{type: "articles", lid: "temp-1", id: nil, relationship: nil} = + OperationRef.deserialize(%{"type" => "articles", "lid" => "temp-1"}) + end + + test "valid ref with type, id, and relationship" do + assert %OperationRef{type: "articles", id: "1", relationship: "author"} = + OperationRef.deserialize(%{ + "type" => "articles", + "id" => "1", + "relationship" => "author" + }) + end + + test "valid ref with type, lid, and relationship" do + assert %OperationRef{type: "articles", lid: "temp-1", relationship: "tags"} = + OperationRef.deserialize(%{ + "type" => "articles", + "lid" => "temp-1", + "relationship" => "tags" + }) + end + + test "missing id and lid raises InvalidDocument" do + assert_raise InvalidDocument, fn -> + OperationRef.deserialize(%{"type" => "articles"}) + end + end + + test "missing type raises InvalidDocument" do + assert_raise InvalidDocument, fn -> + OperationRef.deserialize(%{"id" => "1"}) + end + end + + test "empty type string raises InvalidDocument" do + assert_raise InvalidDocument, fn -> + OperationRef.deserialize(%{"type" => "", "id" => "1"}) + end + end + + test "empty id string is treated as missing" do + assert_raise InvalidDocument, fn -> + OperationRef.deserialize(%{"type" => "articles", "id" => ""}) + end + end + + test "relationship field is nil when not provided" do + %OperationRef{relationship: relationship} = + OperationRef.deserialize(%{"type" => "articles", "id" => "1"}) + + assert is_nil(relationship) + end + end +end diff --git a/test/jsonapi_plug/plug/atomic_params_test.exs b/test/jsonapi_plug/plug/atomic_params_test.exs new file mode 100644 index 0000000..7204840 --- /dev/null +++ b/test/jsonapi_plug/plug/atomic_params_test.exs @@ -0,0 +1,167 @@ +defmodule JSONAPIPlug.Plug.AtomicParamsTest do + use ExUnit.Case, async: false + + import Plug.Conn + import Plug.Test + + alias JSONAPIPlug.Exceptions.InvalidDocument + alias JSONAPIPlug.TestSupport.Plugs.AtomicOperationsPlug + alias Plug.Conn + + @atomic_ext "https://jsonapi.org/ext/atomic" + @content_type "application/vnd.api+json; ext=\"#{@atomic_ext}\"" + + defp atomic_conn(body) do + conn(:post, "/operations", Jason.encode!(body)) + |> put_req_header("content-type", @content_type) + |> put_req_header("accept", @content_type) + end + + describe "add operation normalisation" do + test "add operation with attributes produces params" do + body = %{ + "atomic:operations" => [ + %{ + "op" => "add", + "data" => %{ + "type" => "post", + "attributes" => %{"title" => "Hello", "body" => "World"} + } + } + ] + } + + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{operations: operations, params: params}}} = + atomic_conn(body) + |> AtomicOperationsPlug.call([]) + + assert is_nil(params) + assert [%{op: "add", type: "post", params: op_params}] = operations + assert op_params["title"] == "Hello" + assert op_params["body"] == "World" + end + + test "add operation with lid stores lid in params" do + body = %{ + "atomic:operations" => [ + %{ + "op" => "add", + "data" => %{"type" => "post", "lid" => "temp-1", "attributes" => %{"title" => "Hi"}} + } + ] + } + + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{operations: operations}}} = + atomic_conn(body) + |> AtomicOperationsPlug.call([]) + + assert [%{op: "add", type: "post", params: op_params}] = operations + assert op_params["lid"] == "temp-1" + end + end + + describe "remove operation normalisation" do + test "remove operation with ref produces params with id" do + body = %{ + "atomic:operations" => [ + %{ + "op" => "remove", + "ref" => %{"type" => "post", "id" => "13"} + } + ] + } + + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{operations: operations}}} = + atomic_conn(body) + |> AtomicOperationsPlug.call([]) + + assert [%{op: "remove", type: "post", params: op_params}] = operations + assert op_params["id"] == "13" + end + end + + describe "update operation normalisation" do + test "update operation produces params with id and attributes" do + body = %{ + "atomic:operations" => [ + %{ + "op" => "update", + "data" => %{ + "type" => "post", + "id" => "5", + "attributes" => %{"title" => "Updated title"} + } + } + ] + } + + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{operations: operations}}} = + atomic_conn(body) + |> AtomicOperationsPlug.call([]) + + assert [%{op: "update", type: "post", params: op_params}] = operations + assert op_params["id"] == "5" + assert op_params["title"] == "Updated title" + end + end + + describe "multiple operations" do + test "multiple operations are all normalised" do + body = %{ + "atomic:operations" => [ + %{ + "op" => "add", + "data" => %{"type" => "post", "attributes" => %{"title" => "New"}} + }, + %{ + "op" => "remove", + "ref" => %{"type" => "post", "id" => "42"} + } + ] + } + + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{operations: operations}}} = + atomic_conn(body) + |> AtomicOperationsPlug.call([]) + + assert length(operations) == 2 + assert Enum.at(operations, 0).op == "add" + assert Enum.at(operations, 1).op == "remove" + assert Enum.at(operations, 1).params["id"] == "42" + end + end + + describe "unknown resource type" do + test "unknown type in operation raises InvalidDocument" do + body = %{ + "atomic:operations" => [ + %{ + "op" => "add", + "data" => %{"type" => "unknown-widget", "attributes" => %{}} + } + ] + } + + assert_raise InvalidDocument, fn -> + atomic_conn(body) + |> AtomicOperationsPlug.call([]) + end + end + end + + describe "params field exclusivity" do + test "jsonapi_plug.params is nil for atomic requests" do + body = %{ + "atomic:operations" => [ + %{"op" => "add", "data" => %{"type" => "post", "attributes" => %{"title" => "Hi"}}} + ] + } + + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + atomic_conn(body) + |> AtomicOperationsPlug.call([]) + + assert is_nil(params) + end + end +end diff --git a/test/jsonapi_plug/render_atomic_test.exs b/test/jsonapi_plug/render_atomic_test.exs new file mode 100644 index 0000000..3b3b6ca --- /dev/null +++ b/test/jsonapi_plug/render_atomic_test.exs @@ -0,0 +1,86 @@ +defmodule JSONAPIPlug.RenderAtomicTest do + use ExUnit.Case, async: false + + import Plug.Conn + import Plug.Test + + alias JSONAPIPlug.Document + alias JSONAPIPlug.Document.ResourceObject + alias JSONAPIPlug.TestSupport.Plugs.AtomicOperationsPlug + alias JSONAPIPlug.TestSupport.Resources.Post + + @atomic_ext "https://jsonapi.org/ext/atomic" + @content_type "application/vnd.api+json; ext=\"#{@atomic_ext}\"" + + defp build_conn do + body = %{ + "atomic:operations" => [ + %{"op" => "add", "data" => %{"type" => "post", "attributes" => %{"title" => "Hi"}}} + ] + } + + conn(:post, "/operations", Jason.encode!(body)) + |> put_req_header("content-type", @content_type) + |> put_req_header("accept", @content_type) + |> AtomicOperationsPlug.call([]) + end + + describe "render_atomic/3" do + test "returns nil when all results have nil resource (signals 204)" do + conn = build_conn() + result = JSONAPIPlug.render_atomic(conn, [{nil, nil}, {nil, nil}]) + assert is_nil(result) + end + + test "returns nil for empty result tuple with nil resource" do + conn = build_conn() + assert is_nil(JSONAPIPlug.render_atomic(conn, [{nil, nil}])) + end + + test "returns Document with atomic:results when any result has data" do + conn = build_conn() + post = %Post{id: "1", title: "Hello", text: "World"} + result = JSONAPIPlug.render_atomic(conn, [{post, nil}]) + + assert %Document{results: [%{data: %ResourceObject{id: "1", type: "post"}}]} = result + end + + test "positional alignment: nil results produce empty result objects" do + conn = build_conn() + post = %Post{id: "2", title: "Second", text: "Text"} + result = JSONAPIPlug.render_atomic(conn, [{nil, nil}, {post, nil}]) + + assert %Document{results: [result_0, result_1]} = result + assert result_0 == %{} + assert %{data: %ResourceObject{id: "2", type: "post"}} = result_1 + end + + test "result with meta includes meta in result object" do + conn = build_conn() + post = %Post{id: "3", title: "With meta", text: "text"} + result = JSONAPIPlug.render_atomic(conn, [{post, %{"custom" => "value"}}]) + + assert %Document{results: [%{data: %ResourceObject{}, meta: %{"custom" => "value"}}]} = + result + end + + test "result with nil resource and meta includes meta in empty result" do + conn = build_conn() + result = JSONAPIPlug.render_atomic(conn, [{nil, %{"info" => "ok"}}]) + + assert %Document{results: [%{meta: %{"info" => "ok"}}]} = result + end + + test "JSON encoding of atomic results document emits atomic:results key" do + conn = build_conn() + post = %Post{id: "4", title: "Encoded", text: "text"} + document = JSONAPIPlug.render_atomic(conn, [{post, nil}]) + + encoded = Jason.encode!(document) + decoded = Jason.decode!(encoded) + + assert Map.has_key?(decoded, "atomic:results") + refute Map.has_key?(decoded, "data") + end + end +end diff --git a/test/support/api.ex b/test/support/api.ex index 5ac26a4..930d599 100644 --- a/test/support/api.ex +++ b/test/support/api.ex @@ -1,6 +1,11 @@ defmodule JSONAPIPlug.TestSupport.API do @moduledoc false + defmodule AtomicAPI do + @moduledoc false + use JSONAPIPlug.API, otp_app: :jsonapi_plug + end + defmodule DasherizingAPI do @moduledoc false use JSONAPIPlug.API, otp_app: :jsonapi_plug diff --git a/test/support/plugs.ex b/test/support/plugs.ex index 36e944d..7b4d9ab 100644 --- a/test/support/plugs.ex +++ b/test/support/plugs.ex @@ -2,6 +2,7 @@ defmodule JSONAPIPlug.TestSupport.Plugs do @moduledoc false alias JSONAPIPlug.TestSupport.API.{ + AtomicAPI, DasherizingAPI, DefaultAPI, OtherHostAPI, @@ -13,6 +14,15 @@ defmodule JSONAPIPlug.TestSupport.Plugs do alias JSONAPIPlug.TestSupport.Resources.{Car, Post, User} + defmodule AtomicOperationsPlug do + @moduledoc false + + use Plug.Builder + + plug Plug.Parsers, parsers: [:json], json_decoder: Jason + plug JSONAPIPlug.AtomicPlug, api: AtomicAPI, resources: [Post, User] + end + defmodule CarResourcePlug do @moduledoc false