From 6277f048665af33000f8d565a47c2a79edb00b5a Mon Sep 17 00:00:00 2001 From: treere Date: Fri, 22 May 2026 18:32:07 +0200 Subject: [PATCH] feat: JSON:API 1.1 compliance (v2.1.0) - API config: add version: :"1.1", extensions and profiles options - ContentTypeNegotiation: accept ext/profile params per 1.1 spec; 415 on unsupported ext URI, 406 when all Accept entries unsupported - ResponseContentType: emit ext/profile params in Content-Type and add Vary: Accept header when extensions/profiles configured - JSONAPIObject: add ext and profile fields; populate from API config on render; emit version 1.1 when configured - LinkObject: add rel, describedby, title, type, hreflang fields - ErrorObject: pass through links.type key (1.1 addition) - Document: silently drop @-prefixed keys from attributes, relationships and meta during deserialization - Bump version to 2.1.0 --- CHANGELOG.md | 12 ++ lib/jsonapi_plug/api.ex | 14 +- lib/jsonapi_plug/document.ex | 3 +- lib/jsonapi_plug/document/jsonapi_object.ex | 37 +++- lib/jsonapi_plug/document/link_object.ex | 51 ++++- lib/jsonapi_plug/document/resource_object.ex | 11 +- lib/jsonapi_plug/normalizer.ex | 21 +- .../plug/content_type_negotiation.ex | 187 +++++++++++++++--- .../plug/response_content_type.ex | 67 ++++++- mix.exs | 2 +- test/jsonapi_plug/api_test.exs | 144 ++++++++++++++ test/jsonapi_plug/error_object_test.exs | 61 ++++++ test/jsonapi_plug/link_object_test.exs | 127 ++++++++++++ .../plug/content_type_negotiation_test.exs | 137 +++++++++++-- .../plug/response_content_type_test.exs | 90 ++++++++- test/jsonapi_plug/plug_test.exs | 50 +++++ 16 files changed, 955 insertions(+), 59 deletions(-) create mode 100644 test/jsonapi_plug/api_test.exs create mode 100644 test/jsonapi_plug/error_object_test.exs create mode 100644 test/jsonapi_plug/link_object_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b149f78e..fc7d1dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2.1.0 (2026-05-22) + +JSON:API 1.1 compliance additions. All changes are backwards-compatible with existing 1.0 configurations. + +- **API config**: Added `version: :"1.1"` as a valid value. Added `extensions: [String.t()]` and `profiles: [String.t()]` configuration options. +- **Content-Type negotiation**: `ext` and `profile` media type parameters are now accepted per JSON:API 1.1. Unknown `ext` URIs return 415. An `Accept` header where all JSON:API entries have unsupported `ext` URIs returns 406. Other media type parameters (e.g. `charset`) still return 415. +- **Response headers**: When `extensions` or `profiles` are configured, the response `Content-Type` header includes the corresponding `ext` and/or `profile` parameters. A `Vary: Accept` header is added automatically in that case. +- **`jsonapi` document member**: The outbound `jsonapi` object now includes `version`, `ext`, and `profile` fields populated from the API configuration. +- **Link objects**: `LinkObject` gains five new optional fields from JSON:API 1.1: `rel`, `describedby`, `title`, `type`, and `hreflang`. +- **Error objects**: `ErrorObject.links` now passes through the `type` key introduced in JSON:API 1.1. +- **`@`-members**: `@`-prefixed keys in `attributes`, `relationships`, and `meta` objects are silently ignored during deserialization, as specified in JSON:API 1.1. + ## 2.0.3 (2026-03-20) - Fix include parser dropping sibling sub-includes under a shared intermediate relationship by @albertoforcato in https://github.com/lucacorti/jsonapi_plug/pull/127 diff --git a/lib/jsonapi_plug/api.ex b/lib/jsonapi_plug/api.ex index f9343b16..63f7c9ad 100644 --- a/lib/jsonapi_plug/api.ex +++ b/lib/jsonapi_plug/api.ex @@ -107,9 +107,21 @@ defmodule JSONAPIPlug.API do doc: "Scheme used for link generation instead of deriving it from the connection.", type: {:in, [:http, :https]} ], + extensions: [ + doc: + "List of JSON:API extension URIs supported by this API. Used for content negotiation and advertised in the `jsonapi` document member.", + type: {:list, :string}, + default: [] + ], + profiles: [ + doc: + "List of JSON:API profile URIs applied by this API. Advertised in the `jsonapi` document member and in the response `Content-Type` header.", + type: {:list, :string}, + default: [] + ], version: [ doc: "`JSON:API` version advertised in the document", - type: {:in, [:"1.0"]}, + type: {:in, [:"1.0", :"1.1"]}, default: :"1.0" ] ] diff --git a/lib/jsonapi_plug/document.ex b/lib/jsonapi_plug/document.ex index 2139ff8c..5138c999 100644 --- a/lib/jsonapi_plug/document.ex +++ b/lib/jsonapi_plug/document.ex @@ -147,7 +147,8 @@ defmodule JSONAPIPlug.Document do defp deserialize_links(_data), do: nil - defp deserialize_meta(%{"meta" => meta}) when is_map(meta), do: meta + defp deserialize_meta(%{"meta" => meta}) when is_map(meta), + do: Map.reject(meta, fn {key, _value} -> String.starts_with?(key, "@") end) defp deserialize_meta(%{"meta" => meta}) when not is_nil(meta) do raise InvalidDocument, diff --git a/lib/jsonapi_plug/document/jsonapi_object.ex b/lib/jsonapi_plug/document/jsonapi_object.ex index fd789746..3fc4bcad 100644 --- a/lib/jsonapi_plug/document/jsonapi_object.ex +++ b/lib/jsonapi_plug/document/jsonapi_object.ex @@ -7,14 +7,34 @@ defmodule JSONAPIPlug.Document.JSONAPIObject do @type version :: :"1.0" | :"1.1" - @type t :: %__MODULE__{meta: Document.meta() | nil, version: version() | nil} - defstruct meta: nil, version: nil + @type t :: %__MODULE__{ + ext: [String.t()] | nil, + meta: Document.meta() | nil, + profile: [String.t()] | nil, + version: version() | nil + } + defstruct ext: nil, meta: nil, profile: nil, version: nil @spec deserialize(Document.payload()) :: t() | no_return() def deserialize(data) do - %__MODULE__{meta: deserialize_meta(data), version: deserialize_version(data)} + %__MODULE__{ + ext: deserialize_ext(data), + meta: deserialize_meta(data), + profile: deserialize_profile(data), + version: deserialize_version(data) + } end + defp deserialize_ext(%{"ext" => ext}) when is_list(ext), do: ext + + defp deserialize_ext(%{"ext" => _ext}) do + raise InvalidDocument, + message: "JSON:API object 'ext' must be an array", + reference: "https://jsonapi.org/format/#document-jsonapi-object" + end + + defp deserialize_ext(_data), do: nil + defp deserialize_meta(%{"meta" => meta}) when is_map(meta), do: meta defp deserialize_meta(%{"meta" => _meta}) do @@ -24,6 +44,17 @@ defmodule JSONAPIPlug.Document.JSONAPIObject do end defp deserialize_meta(_data), do: nil + + defp deserialize_profile(%{"profile" => profile}) when is_list(profile), do: profile + + defp deserialize_profile(%{"profile" => _profile}) do + raise InvalidDocument, + message: "JSON:API object 'profile' must be an array", + reference: "https://jsonapi.org/format/#document-jsonapi-object" + end + + defp deserialize_profile(_data), do: nil + defp deserialize_version(%{"version" => "1.0"}), do: :"1.0" defp deserialize_version(%{"version" => "1.1"}), do: :"1.1" diff --git a/lib/jsonapi_plug/document/link_object.ex b/lib/jsonapi_plug/document/link_object.ex index a30da76b..f269f46b 100644 --- a/lib/jsonapi_plug/document/link_object.ex +++ b/lib/jsonapi_plug/document/link_object.ex @@ -7,22 +7,67 @@ defmodule JSONAPIPlug.Document.LinkObject do alias JSONAPIPlug.Document - @type t :: %__MODULE__{href: String.t() | nil, meta: Document.meta() | nil} | String.t() - defstruct [:href, :meta] + @type t :: + %__MODULE__{ + describedby: String.t() | nil, + href: String.t() | nil, + hreflang: String.t() | [String.t()] | nil, + meta: Document.meta() | nil, + rel: String.t() | nil, + title: String.t() | nil, + type: String.t() | nil + } + | String.t() + + defstruct [:describedby, :href, :hreflang, :meta, :rel, :title, :type] @spec deserialize(Document.payload()) :: t() | no_return() def deserialize(data) when is_binary(data), do: data def deserialize(data) when is_map(data) do - %__MODULE__{href: deserialize_href(data), meta: deserialize_meta(data)} + %__MODULE__{ + describedby: deserialize_describedby(data), + href: deserialize_href(data), + hreflang: deserialize_hreflang(data), + meta: deserialize_meta(data), + rel: deserialize_rel(data), + title: deserialize_title(data), + type: deserialize_type(data) + } end + defp deserialize_describedby(%{"describedby" => describedby}) + when is_binary(describedby) and byte_size(describedby) > 0, + do: describedby + + defp deserialize_describedby(_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_hreflang(%{"hreflang" => hreflang}) + when is_binary(hreflang) and byte_size(hreflang) > 0, + do: hreflang + + defp deserialize_hreflang(%{"hreflang" => hreflang}) when is_list(hreflang), do: hreflang + defp deserialize_hreflang(_data), do: nil + defp deserialize_meta(%{"meta" => meta}) when is_map(meta), do: meta defp deserialize_meta(_data), do: nil + defp deserialize_rel(%{"rel" => rel}) when is_binary(rel) and byte_size(rel) > 0, do: rel + defp deserialize_rel(_data), do: nil + + defp deserialize_title(%{"title" => title}) when is_binary(title) and byte_size(title) > 0, + do: title + + defp deserialize_title(_data), do: nil + + defp deserialize_type(%{"type" => type}) when is_binary(type) and byte_size(type) > 0, + do: type + + defp deserialize_type(_data), do: nil + @spec serialize(t()) :: t() def serialize(link_object), do: link_object end diff --git a/lib/jsonapi_plug/document/resource_object.ex b/lib/jsonapi_plug/document/resource_object.ex index 20363359..e477895b 100644 --- a/lib/jsonapi_plug/document/resource_object.ex +++ b/lib/jsonapi_plug/document/resource_object.ex @@ -73,7 +73,7 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_attributes(%{"attributes" => attributes}) when is_map(attributes), - do: attributes + do: reject_at_members(attributes) defp deserialize_attributes(_data), do: %{} @@ -99,7 +99,9 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_relationships(%{"relationships" => relationships}) when is_map(relationships) do - Enum.into(relationships, %{}, fn + relationships + |> reject_at_members() + |> Enum.into(%{}, fn {name, data} when is_list(data) -> {name, Enum.map(data, &RelationshipObject.deserialize/1)} @@ -118,7 +120,7 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_relationships(_data), do: %{} - defp deserialize_meta(%{"meta" => meta}) when is_map(meta), do: meta + defp deserialize_meta(%{"meta" => meta}) when is_map(meta), do: reject_at_members(meta) defp deserialize_meta(%{"meta" => _meta}) do raise InvalidDocument, @@ -128,6 +130,9 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_meta(_data), do: nil + defp reject_at_members(map), + do: Map.reject(map, fn {key, _value} -> String.starts_with?(key, "@") end) + @spec serialize(t()) :: t() def serialize(resource_object), do: resource_object end diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 23242277..bb6bae56 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.JSONAPIObject, Document.RelationshipObject, Document.ResourceIdentifierObject, Document.ResourceObject, @@ -299,8 +300,15 @@ defmodule JSONAPIPlug.Normalizer do Resource.options() ) :: Document.t() | no_return() - def normalize(conn, resource_or_resources, links, meta, options) do + def normalize( + %Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn, + resource_or_resources, + links, + meta, + options + ) do %Document{ + jsonapi: normalize_jsonapi(jsonapi_plug.config), meta: meta, data: normalize_data(conn, resource_or_resources, options), links: normalize_links(conn, resource_or_resources, links || %{}, options), @@ -310,6 +318,17 @@ defmodule JSONAPIPlug.Normalizer do } end + defp normalize_jsonapi(config) do + %JSONAPIObject{ + version: config[:version], + ext: nonempty_list(config[:extensions]), + profile: nonempty_list(config[:profiles]) + } + end + + defp nonempty_list([_ | _] = list), do: list + defp nonempty_list(_), do: nil + defp normalize_data(_conn, nil, _options), do: nil defp normalize_data(conn, resources, options) when is_list(resources), diff --git a/lib/jsonapi_plug/plug/content_type_negotiation.ex b/lib/jsonapi_plug/plug/content_type_negotiation.ex index 73a877da..a4cdcf6c 100644 --- a/lib/jsonapi_plug/plug/content_type_negotiation.ex +++ b/lib/jsonapi_plug/plug/content_type_negotiation.ex @@ -4,6 +4,14 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiation do The proper jsonapi.org content type is `application/vnd.api+json` as per [the specification](http://jsonapi.org/format/#content-negotiation-servers). + + As of JSON:API 1.1, the `ext` and `profile` media type parameters are supported: + + - `ext`: Specifies extensions. The server validates that all listed extension URIs are + supported and responds with 415 if any are unsupported. + - `profile`: Specifies profiles. Profiles are always accepted (unknown profiles are ignored). + + Any other media type parameters are rejected with 415. """ alias JSONAPIPlug.Exceptions.InvalidHeader @@ -19,40 +27,173 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiation do do: conn def call(conn, _opts) do - conn |> validate("content_type") |> validate("accept") + conn |> validate_content_type() |> validate_accept() end - defp validate(conn, "content_type") do - if validate_header(conn, "content-type") do - conn - else + # Parse a single media type entry. + # Returns {:ok, params} | :not_jsonapi | :invalid_param + defp parse_jsonapi_entry(entry) do + case Conn.Utils.media_type(String.trim(entry)) do + {:ok, "application", "vnd.api+json", params} -> check_params(params) + _ -> :not_jsonapi + end + end + + defp check_params(params) do + case Enum.find(params, fn {k, _v} -> k not in ["ext", "profile"] end) do + nil -> {:ok, params} + _ -> :invalid_param + end + end + + defp split_media_types(header_value), do: String.split(header_value, ",", trim: true) + + defp supported_extensions(%Conn{private: %{jsonapi_plug: %JSONAPIPlug{config: config}}}) + when not is_nil(config), + do: config[:extensions] || [] + + defp supported_extensions(_conn), do: [] + + defp validate_content_type(conn) do + conn + |> Conn.get_req_header("content-type") + |> List.first() + |> do_validate_content_type(conn) + end + + defp do_validate_content_type(nil, conn), do: conn + + defp do_validate_content_type(raw_header, conn) do + raw_header + |> split_media_types() + |> classify_content_type_header() + |> apply_content_type_result(conn, raw_header) + end + + defp classify_content_type_header(entries) do + results = Enum.map(entries, &parse_jsonapi_entry/1) + + cond do + Enum.any?(results, &match?({:ok, _}, &1)) -> :ok + Enum.any?(results, &(&1 == :invalid_param)) -> :invalid_param + true -> nil + end + end + + defp apply_content_type_result(:ok, conn, raw_header), + do: validate_content_type_ext(conn, raw_header) + + defp apply_content_type_result(:invalid_param, _conn, _raw_header) do + raise InvalidHeader, + status: :unsupported_media_type, + message: + "The 'content-type' request header must contain the JSON:API mime type " <> + "(#{JSONAPIPlug.mime_type()}) without parameters other than 'ext' or 'profile'", + reference: "https://jsonapi.org/format/#content-negotiation.", + header: "content-type" + end + + defp apply_content_type_result(nil, _conn, _raw_header) do + raise InvalidHeader, + status: :unsupported_media_type, + message: + "The 'content-type' request header must contain the JSON:API mime type (#{JSONAPIPlug.mime_type()})", + reference: "https://jsonapi.org/format/#content-negotiation.", + header: "content-type" + end + + # Second pass: check that any ext URIs in content-type are all supported + defp validate_content_type_ext(conn, raw_header) do + supported = supported_extensions(conn) + + if content_type_has_unsupported_ext?(raw_header, supported) do raise InvalidHeader, status: :unsupported_media_type, - message: - "The 'content-type' request header must contain the JSON:API mime type (#{JSONAPIPlug.mime_type()})", - reference: "https://jsonapi.org/format/#content-negotiation.", + message: "The 'content-type' request header contains an unsupported extension URI", + reference: "https://jsonapi.org/format/#content-negotiation-servers", header: "content-type" + else + conn end end - defp validate(conn, "accept") do - if validate_header(conn, "accept") do - conn - else - raise InvalidHeader, - status: :not_acceptable, - message: - "The 'accept' request header must contain the JSON:API mime type (#{JSONAPIPlug.mime_type()})", - reference: "https://jsonapi.org/format/#content-negotiation", - header: "accept" + defp content_type_has_unsupported_ext?(raw_header, supported) do + Enum.any?(split_media_types(raw_header), &entry_has_unsupported_ext?(&1, supported)) + end + + defp entry_has_unsupported_ext?(entry, supported) do + case parse_jsonapi_entry(entry) do + {:ok, %{"ext" => ext_value}} -> has_unsupported_uri?(ext_value, supported) + _ -> false end end - defp validate_header(conn, header) do + defp has_unsupported_uri?(ext_value, supported) do + ext_value + |> String.split(" ", trim: true) + |> Enum.any?(fn uri -> uri not in supported end) + end + + defp validate_accept(conn) do conn - |> Conn.get_req_header(header) - |> List.first(JSONAPIPlug.mime_type()) - |> String.split(",", trim: true) - |> Enum.member?(JSONAPIPlug.mime_type()) + |> Conn.get_req_header("accept") + |> List.first() + |> do_validate_accept(conn) + end + + defp do_validate_accept(nil, conn), do: conn + + defp do_validate_accept(raw_header, conn) do + supported = supported_extensions(conn) + + classified = + raw_header |> split_media_types() |> Enum.map(&classify_accept_entry(&1, supported)) + + apply_accept_result(conn, classified) + end + + defp classify_accept_entry(entry, supported) do + case parse_jsonapi_entry(entry) do + {:ok, params} when map_size(params) == 0 -> :valid + {:ok, %{"profile" => _}} -> :profile_only + {:ok, %{"ext" => ext_value}} -> classify_ext(ext_value, supported) + {:ok, %{"ext" => ext_value, "profile" => _}} -> classify_ext(ext_value, supported) + :not_jsonapi -> :not_jsonapi + :invalid_param -> :invalid_param + end + end + + defp classify_ext(ext_value, supported) do + uris = String.split(ext_value, " ", trim: true) + if Enum.all?(uris, &(&1 in supported)), do: :valid, else: :unsupported_ext + end + + defp apply_accept_result(conn, classified) do + jsonapi_entries = Enum.reject(classified, &(&1 == :not_jsonapi)) + + cond do + Enum.any?(classified, &(&1 in [:valid, :profile_only])) -> + conn + + Enum.all?(classified, &(&1 == :not_jsonapi)) -> + conn + + jsonapi_entries != [] and + Enum.all?(jsonapi_entries, &(&1 in [:unsupported_ext, :invalid_param])) -> + raise InvalidHeader, + status: :not_acceptable, + message: + "The 'accept' request header does not contain any acceptable JSON:API media type", + reference: "https://jsonapi.org/format/#content-negotiation", + header: "accept" + + true -> + raise InvalidHeader, + status: :not_acceptable, + message: + "The 'accept' request header must contain the JSON:API mime type (#{JSONAPIPlug.mime_type()})", + reference: "https://jsonapi.org/format/#content-negotiation", + header: "accept" + end end end diff --git a/lib/jsonapi_plug/plug/response_content_type.ex b/lib/jsonapi_plug/plug/response_content_type.ex index b6e6e2c0..a86813f7 100644 --- a/lib/jsonapi_plug/plug/response_content_type.ex +++ b/lib/jsonapi_plug/plug/response_content_type.ex @@ -2,8 +2,12 @@ defmodule JSONAPIPlug.Plug.ResponseContentType do @moduledoc """ Plug for setting the response content type - Registers a before send function that sets the `JSON:API` content type on responses unless a response - content type has already been set on the connection. + Registers a before send function that sets the `JSON:API` content type on responses unless a + response content type has already been set on the connection. + + When the API is configured with `extensions` or `profiles`, the response `Content-Type` header + will include the corresponding `ext` and/or `profile` media type parameters. A `Vary: Accept` + header is also added in that case, as required by JSON:API 1.1. """ alias Plug.Conn @@ -15,12 +19,61 @@ defmodule JSONAPIPlug.Plug.ResponseContentType do @impl Plug def call(conn, _opts) do - Conn.register_before_send( - conn, - &set_content_type(&1, Conn.get_resp_header(&1, "content-type")) - ) + Conn.register_before_send(conn, &set_response_headers/1) + end + + defp set_response_headers(conn) do + conn + |> set_content_type(Conn.get_resp_header(conn, "content-type")) + |> set_vary() + end + + defp set_content_type(conn, []) do + mime = build_content_type(conn) + Conn.put_resp_content_type(conn, mime) end - defp set_content_type(conn, []), do: Conn.put_resp_content_type(conn, JSONAPIPlug.mime_type()) defp set_content_type(conn, _content_type), do: conn + + defp set_vary(conn) do + extensions = api_config(conn, :extensions) + profiles = api_config(conn, :profiles) + + if extensions != [] or profiles != [] do + Conn.prepend_resp_headers(conn, [{"vary", "Accept"}]) + else + conn + end + end + + defp build_content_type(conn) do + extensions = api_config(conn, :extensions) + profiles = api_config(conn, :profiles) + + params = + [] + |> add_param("ext", extensions) + |> add_param("profile", profiles) + + case params do + [] -> JSONAPIPlug.mime_type() + _ -> "#{JSONAPIPlug.mime_type()}; #{Enum.join(params, "; ")}" + end + end + + defp add_param(params, _name, []), do: params + + defp add_param(params, name, uris) do + params ++ ["#{name}=\"#{Enum.join(uris, " ")}\""] + end + + defp api_config(conn, key) do + case conn.private do + %{jsonapi_plug: %JSONAPIPlug{config: config}} when not is_nil(config) -> + config[key] || [] + + _ -> + [] + end + end end diff --git a/mix.exs b/mix.exs index b410d1a0..a339ea1b 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule JSONAPIPlug.Mixfile do def project do [ app: :jsonapi_plug, - version: "2.0.3", + version: "2.1.0", package: package(), description: "JSON:API library for Plug and Phoenix applications", elixir: "~> 1.15", diff --git a/test/jsonapi_plug/api_test.exs b/test/jsonapi_plug/api_test.exs new file mode 100644 index 00000000..275d6486 --- /dev/null +++ b/test/jsonapi_plug/api_test.exs @@ -0,0 +1,144 @@ +defmodule JSONAPIPlug.APITest do + use ExUnit.Case, async: false + + alias JSONAPIPlug.API + + defmodule VersionOneZeroAPI do + @moduledoc false + use JSONAPIPlug.API, otp_app: :jsonapi_plug + end + + defmodule VersionOneOneAPI do + @moduledoc false + use JSONAPIPlug.API, otp_app: :jsonapi_plug + end + + defmodule ExtensionsAPI do + @moduledoc false + use JSONAPIPlug.API, otp_app: :jsonapi_plug + end + + defmodule ProfilesAPI do + @moduledoc false + use JSONAPIPlug.API, otp_app: :jsonapi_plug + end + + setup do + :persistent_term.erase(VersionOneZeroAPI) + :persistent_term.erase(VersionOneOneAPI) + :persistent_term.erase(ExtensionsAPI) + :persistent_term.erase(ProfilesAPI) + + Application.put_env(:jsonapi_plug, VersionOneZeroAPI, version: :"1.0") + Application.put_env(:jsonapi_plug, VersionOneOneAPI, version: :"1.1") + + Application.put_env(:jsonapi_plug, ExtensionsAPI, extensions: ["https://example.com/ext"]) + + Application.put_env(:jsonapi_plug, ProfilesAPI, profiles: ["https://example.com/profile"]) + + on_exit(fn -> + :persistent_term.erase(VersionOneZeroAPI) + :persistent_term.erase(VersionOneOneAPI) + :persistent_term.erase(ExtensionsAPI) + :persistent_term.erase(ProfilesAPI) + end) + + :ok + end + + test "version 1.0 is valid" do + config = API.get_config(VersionOneZeroAPI) + assert config[:version] == :"1.0" + end + + test "version 1.1 is valid" do + config = API.get_config(VersionOneOneAPI) + assert config[:version] == :"1.1" + end + + test "invalid version raises error" do + Application.put_env(:jsonapi_plug, VersionOneZeroAPI, version: :"1.2") + :persistent_term.erase(VersionOneZeroAPI) + + assert_raise NimbleOptions.ValidationError, fn -> + API.get_config(VersionOneZeroAPI) + end + end + + test "extensions list is stored and retrievable" do + config = API.get_config(ExtensionsAPI) + assert config[:extensions] == ["https://example.com/ext"] + end + + test "extensions defaults to empty list" do + config = API.get_config(VersionOneZeroAPI) + assert config[:extensions] == [] + end + + test "profiles list is stored and retrievable" do + config = API.get_config(ProfilesAPI) + assert config[:profiles] == ["https://example.com/profile"] + end + + test "profiles defaults to empty list" do + config = API.get_config(VersionOneZeroAPI) + assert config[:profiles] == [] + end + + test "invalid extensions type raises error" do + Application.put_env(:jsonapi_plug, ExtensionsAPI, extensions: "not-a-list") + :persistent_term.erase(ExtensionsAPI) + + assert_raise NimbleOptions.ValidationError, fn -> + API.get_config(ExtensionsAPI) + end + end + + describe "JSONAPIObject encoding" do + test "encodes version 1.0" do + jsonapi = %JSONAPIPlug.Document.JSONAPIObject{version: :"1.0"} + encoded = Jason.decode!(Jason.encode!(jsonapi)) + assert encoded["version"] == "1.0" + refute Map.has_key?(encoded, "ext") + refute Map.has_key?(encoded, "profile") + end + + test "encodes version 1.1" do + jsonapi = %JSONAPIPlug.Document.JSONAPIObject{version: :"1.1"} + encoded = Jason.decode!(Jason.encode!(jsonapi)) + assert encoded["version"] == "1.1" + end + + test "encodes ext array when present" do + jsonapi = %JSONAPIPlug.Document.JSONAPIObject{ + version: :"1.1", + ext: ["https://example.com/ext"] + } + + encoded = Jason.decode!(Jason.encode!(jsonapi)) + assert encoded["ext"] == ["https://example.com/ext"] + end + + test "omits ext key when nil" do + jsonapi = %JSONAPIPlug.Document.JSONAPIObject{version: :"1.0", ext: nil} + encoded = Jason.decode!(Jason.encode!(jsonapi)) + refute Map.has_key?(encoded, "ext") + end + + test "encodes profile array when present" do + jsonapi = %JSONAPIPlug.Document.JSONAPIObject{ + version: :"1.1", + profile: ["https://example.com/profile"] + } + + encoded = Jason.decode!(Jason.encode!(jsonapi)) + assert encoded["profile"] == ["https://example.com/profile"] + end + + test "omits profile key when nil" do + jsonapi = %JSONAPIPlug.Document.JSONAPIObject{version: :"1.0", profile: nil} + encoded = Jason.decode!(Jason.encode!(jsonapi)) + refute Map.has_key?(encoded, "profile") + end + end +end diff --git a/test/jsonapi_plug/error_object_test.exs b/test/jsonapi_plug/error_object_test.exs new file mode 100644 index 00000000..ddcd3052 --- /dev/null +++ b/test/jsonapi_plug/error_object_test.exs @@ -0,0 +1,61 @@ +defmodule JSONAPIPlug.Document.ErrorObjectTest do + use ExUnit.Case, async: true + + alias JSONAPIPlug.Document.ErrorObject + + describe "ErrorObject links with type member" do + test "serializes links with about key" do + error = %ErrorObject{ + status: "404", + links: %{"about" => "https://example.com/help"} + } + + encoded = Jason.decode!(Jason.encode!(error)) + assert encoded["links"]["about"] == "https://example.com/help" + end + + test "serializes links with type key" do + error = %ErrorObject{ + status: "400", + links: %{"type" => "https://example.com/errors/bad-request"} + } + + encoded = Jason.decode!(Jason.encode!(error)) + assert encoded["links"]["type"] == "https://example.com/errors/bad-request" + end + + test "serializes links with both about and type keys" do + error = %ErrorObject{ + status: "400", + links: %{ + "about" => "https://example.com/help", + "type" => "https://example.com/errors/bad-request" + } + } + + encoded = Jason.decode!(Jason.encode!(error)) + assert encoded["links"]["about"] == "https://example.com/help" + assert encoded["links"]["type"] == "https://example.com/errors/bad-request" + end + + test "deserializes error with links containing type" do + data = %{ + "status" => "400", + "links" => %{ + "about" => "https://example.com/help", + "type" => "https://example.com/errors/bad-request" + } + } + + error = ErrorObject.deserialize(data) + assert error.links["about"] == "https://example.com/help" + assert error.links["type"] == "https://example.com/errors/bad-request" + end + + test "omits links when nil" do + error = %ErrorObject{status: "500"} + encoded = Jason.decode!(Jason.encode!(error)) + refute Map.has_key?(encoded, "links") + end + end +end diff --git a/test/jsonapi_plug/link_object_test.exs b/test/jsonapi_plug/link_object_test.exs new file mode 100644 index 00000000..c3192f05 --- /dev/null +++ b/test/jsonapi_plug/link_object_test.exs @@ -0,0 +1,127 @@ +defmodule JSONAPIPlug.Document.LinkObjectTest do + use ExUnit.Case, async: true + + alias JSONAPIPlug.Document.LinkObject + + describe "LinkObject encoding" do + test "encodes href as string link" do + link = "https://example.com/articles/1" + assert Jason.encode!(link) == ~s("https://example.com/articles/1") + end + + test "encodes link object with href only" do + link = %LinkObject{href: "https://example.com/articles/1"} + encoded = Jason.decode!(Jason.encode!(link)) + assert encoded["href"] == "https://example.com/articles/1" + end + + test "encodes rel when present" do + link = %LinkObject{href: "https://example.com", rel: "self"} + encoded = Jason.decode!(Jason.encode!(link)) + assert encoded["rel"] == "self" + end + + test "omits rel when nil" do + link = %LinkObject{href: "https://example.com"} + encoded = Jason.decode!(Jason.encode!(link)) + refute Map.has_key?(encoded, "rel") + end + + test "encodes describedby when present" do + link = %LinkObject{href: "https://example.com", describedby: "https://example.com/schema"} + encoded = Jason.decode!(Jason.encode!(link)) + assert encoded["describedby"] == "https://example.com/schema" + end + + test "omits describedby when nil" do + link = %LinkObject{href: "https://example.com"} + encoded = Jason.decode!(Jason.encode!(link)) + refute Map.has_key?(encoded, "describedby") + end + + test "encodes title when present" do + link = %LinkObject{href: "https://example.com", title: "Comments"} + encoded = Jason.decode!(Jason.encode!(link)) + assert encoded["title"] == "Comments" + end + + test "omits title when nil" do + link = %LinkObject{href: "https://example.com"} + encoded = Jason.decode!(Jason.encode!(link)) + refute Map.has_key?(encoded, "title") + end + + test "encodes type when present" do + link = %LinkObject{href: "https://example.com", type: "application/vnd.api+json"} + encoded = Jason.decode!(Jason.encode!(link)) + assert encoded["type"] == "application/vnd.api+json" + end + + test "omits type when nil" do + link = %LinkObject{href: "https://example.com"} + encoded = Jason.decode!(Jason.encode!(link)) + refute Map.has_key?(encoded, "type") + end + + test "encodes hreflang as string when present" do + link = %LinkObject{href: "https://example.com", hreflang: "en"} + encoded = Jason.decode!(Jason.encode!(link)) + assert encoded["hreflang"] == "en" + end + + test "encodes hreflang as list when present" do + link = %LinkObject{href: "https://example.com", hreflang: ["en", "fr"]} + encoded = Jason.decode!(Jason.encode!(link)) + assert encoded["hreflang"] == ["en", "fr"] + end + + test "omits hreflang when nil" do + link = %LinkObject{href: "https://example.com"} + encoded = Jason.decode!(Jason.encode!(link)) + refute Map.has_key?(encoded, "hreflang") + end + + test "omits all nil fields" do + link = %LinkObject{href: "https://example.com"} + encoded = Jason.decode!(Jason.encode!(link)) + assert Map.keys(encoded) == ["href"] + end + end + + describe "LinkObject deserialization" do + test "deserializes string link" do + assert LinkObject.deserialize("https://example.com") == "https://example.com" + end + + test "deserializes link object with all 1.1 fields" do + data = %{ + "href" => "https://example.com", + "rel" => "self", + "describedby" => "https://example.com/schema", + "title" => "Comments", + "type" => "application/vnd.api+json", + "hreflang" => "en" + } + + link = LinkObject.deserialize(data) + assert link.href == "https://example.com" + assert link.rel == "self" + assert link.describedby == "https://example.com/schema" + assert link.title == "Comments" + assert link.type == "application/vnd.api+json" + assert link.hreflang == "en" + end + end + + describe "describedby in top-level document links" do + test "document with describedby link is serialized" do + doc = %JSONAPIPlug.Document{ + data: nil, + links: %{describedby: "https://example.com/openapi"} + } + + encoded = Jason.decode!(Jason.encode!(doc)) + assert encoded["links"]["describedby"] == "https://example.com/openapi" + end + end +end diff --git a/test/jsonapi_plug/plug/content_type_negotiation_test.exs b/test/jsonapi_plug/plug/content_type_negotiation_test.exs index 22134cd0..8be59fc5 100644 --- a/test/jsonapi_plug/plug/content_type_negotiation_test.exs +++ b/test/jsonapi_plug/plug/content_type_negotiation_test.exs @@ -6,9 +6,17 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do alias JSONAPIPlug.Plug.ContentTypeNegotiation alias Plug.Conn + # Helper to build a conn pre-loaded with a simulated jsonapi_plug config + defp conn_with_config(method, path, body \\ "", extensions \\ []) do + conn(method, path, body) + |> Conn.put_private(:jsonapi_plug, %JSONAPIPlug{ + config: [extensions: extensions, profiles: []] + }) + end + test "passes request through" do conn = - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header("content-type", JSONAPIPlug.mime_type()) |> Conn.put_req_header("accept", JSONAPIPlug.mime_type()) |> ContentTypeNegotiation.call([]) @@ -18,7 +26,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "halts and returns an error if no content-type or accept header" do conn = - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> ContentTypeNegotiation.call([]) refute conn.halted @@ -26,7 +34,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "passes request through if only content-type header" do conn = - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header("content-type", JSONAPIPlug.mime_type()) |> ContentTypeNegotiation.call([]) @@ -35,7 +43,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "passes request through if only accept header" do conn = - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header("accept", JSONAPIPlug.mime_type()) |> ContentTypeNegotiation.call([]) @@ -44,7 +52,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "passes request through if multiple accept header" do conn = - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header( "accept", "#{JSONAPIPlug.mime_type()}, #{JSONAPIPlug.mime_type()}; version=1.0" @@ -56,7 +64,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "passes request through if correct content-type header is last" do conn = - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header( "content-type", "#{JSONAPIPlug.mime_type()}, #{JSONAPIPlug.mime_type()}; version=1.0" @@ -66,9 +74,21 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do refute conn.halted end + test "passes request through if correct content-type header is last (invalid before valid)" do + conn = + conn_with_config(:post, "/example") + |> Conn.put_req_header( + "content-type", + "#{JSONAPIPlug.mime_type()}; version=1.0, #{JSONAPIPlug.mime_type()}" + ) + |> ContentTypeNegotiation.call([]) + + refute conn.halted + end + test "passes request through if correct accept header is last" do conn = - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header( "accept", "#{JSONAPIPlug.mime_type()}, #{JSONAPIPlug.mime_type()}; version=1.0" @@ -80,7 +100,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "halts and returns an error if content-type header contains other media type" do assert_raise InvalidHeader, fn -> - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header("content-type", "text/html") |> ContentTypeNegotiation.call([]) end @@ -88,7 +108,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "halts and returns an error if content-type header contains other media type params" do assert_raise InvalidHeader, fn -> - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header("content-type", "#{JSONAPIPlug.mime_type()}; version=1.0") |> ContentTypeNegotiation.call([]) end @@ -96,7 +116,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "halts and returns an error if content-type header contains other media type params (multiple)" do assert_raise InvalidHeader, fn -> - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header( "content-type", "#{JSONAPIPlug.mime_type()}; version=1.0, #{JSONAPIPlug.mime_type()}; version=1.0" @@ -107,7 +127,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "halts and returns an error if content-type header contains other media type params with correct accept header" do assert_raise InvalidHeader, fn -> - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header("content-type", "#{JSONAPIPlug.mime_type()}; version=1.0") |> Conn.put_req_header("accept", "#{JSONAPIPlug.mime_type()}") |> ContentTypeNegotiation.call([]) @@ -116,7 +136,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "halts and returns an error if accept header contains other media type params" do assert_raise InvalidHeader, fn -> - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header("content-type", JSONAPIPlug.mime_type()) |> Conn.put_req_header("accept", "#{JSONAPIPlug.mime_type()}; charset=utf-8") |> ContentTypeNegotiation.call([]) @@ -125,7 +145,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "halts and returns an error if all accept header media types contain media type params with no content-type" do assert_raise InvalidHeader, fn -> - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header( "accept", "#{JSONAPIPlug.mime_type()}; version=1.0, #{JSONAPIPlug.mime_type()}; version=1.0" @@ -136,7 +156,7 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do test "halts and returns an error if all accept header media types contain media type params" do assert_raise InvalidHeader, fn -> - conn(:post, "/example", "") + conn_with_config(:post, "/example") |> Conn.put_req_header("content-type", JSONAPIPlug.mime_type()) |> Conn.put_req_header( "accept", @@ -145,4 +165,93 @@ defmodule JSONAPIPlug.Plug.ContentTypeNegotiationTest do |> ContentTypeNegotiation.call([]) end end + + # JSON:API 1.1 ext/profile parameter tests + + test "passes request through with ext parameter for supported extension" do + ext_uri = "https://example.com/ext" + + conn = + conn_with_config(:post, "/example", "", [ext_uri]) + |> Conn.put_req_header( + "content-type", + "#{JSONAPIPlug.mime_type()}; ext=\"#{ext_uri}\"" + ) + |> Conn.put_req_header("accept", JSONAPIPlug.mime_type()) + |> ContentTypeNegotiation.call([]) + + refute conn.halted + end + + test "raises 415 if content-type ext contains unsupported extension URI" do + assert_raise InvalidHeader, fn -> + conn_with_config(:post, "/example") + |> Conn.put_req_header( + "content-type", + "#{JSONAPIPlug.mime_type()}; ext=\"https://unknown.example.com/ext\"" + ) + |> Conn.put_req_header("accept", JSONAPIPlug.mime_type()) + |> ContentTypeNegotiation.call([]) + end + end + + test "passes request through with profile parameter in content-type" do + conn = + conn_with_config(:post, "/example") + |> Conn.put_req_header( + "content-type", + "#{JSONAPIPlug.mime_type()}; profile=\"https://example.com/profile\"" + ) + |> Conn.put_req_header("accept", JSONAPIPlug.mime_type()) + |> ContentTypeNegotiation.call([]) + + refute conn.halted + end + + test "raises 406 when all accept entries have unsupported ext URIs" do + assert_raise InvalidHeader, fn -> + conn_with_config(:post, "/example") + |> Conn.put_req_header("content-type", JSONAPIPlug.mime_type()) + |> Conn.put_req_header( + "accept", + "#{JSONAPIPlug.mime_type()}; ext=\"https://unknown.example.com/ext\"" + ) + |> ContentTypeNegotiation.call([]) + end + end + + test "passes request through with profile param only in accept header" do + conn = + conn_with_config(:post, "/example") + |> Conn.put_req_header("content-type", JSONAPIPlug.mime_type()) + |> Conn.put_req_header( + "accept", + "#{JSONAPIPlug.mime_type()}; profile=\"https://example.com/profile\"" + ) + |> ContentTypeNegotiation.call([]) + + refute conn.halted + end + + test "passes if accept has mix of unsupported ext and plain JSON:API entries" do + conn = + conn_with_config(:post, "/example") + |> Conn.put_req_header("content-type", JSONAPIPlug.mime_type()) + |> Conn.put_req_header( + "accept", + "#{JSONAPIPlug.mime_type()}; ext=\"https://unknown.example.com/ext\", #{JSONAPIPlug.mime_type()}" + ) + |> ContentTypeNegotiation.call([]) + + refute conn.halted + end + + test "GET requests skip content-type validation" do + conn = + conn_with_config(:get, "/example") + |> Conn.put_req_header("content-type", "text/html") + |> ContentTypeNegotiation.call([]) + + refute conn.halted + end end diff --git a/test/jsonapi_plug/plug/response_content_type_test.exs b/test/jsonapi_plug/plug/response_content_type_test.exs index c8357b9b..8a89e5db 100644 --- a/test/jsonapi_plug/plug/response_content_type_test.exs +++ b/test/jsonapi_plug/plug/response_content_type_test.exs @@ -6,12 +6,98 @@ defmodule JSONAPIPlug.Plug.ResponseContentTypeTest do alias JSONAPIPlug.Plug.ResponseContentType - test "sets response content type" do + defp conn_with_config(extensions \\ [], profiles \\ []) do + conn(:get, "/example", "") + |> Plug.Conn.put_private(:jsonapi_plug, %JSONAPIPlug{ + config: [extensions: extensions, profiles: profiles] + }) + end + + test "sets plain JSON:API response content type when no extensions or profiles" do conn = - conn(:get, "/example", "") + conn_with_config() |> ResponseContentType.call([]) |> send_resp(200, "done") assert get_resp_header(conn, "content-type") == ["#{JSONAPIPlug.mime_type()}; charset=utf-8"] end + + test "does not add Vary header when no extensions or profiles" do + conn = + conn_with_config() + |> ResponseContentType.call([]) + |> send_resp(200, "done") + + assert get_resp_header(conn, "vary") == [] + end + + test "sets content type with ext parameter when extensions configured" do + ext_uri = "https://example.com/ext" + + conn = + conn_with_config([ext_uri]) + |> ResponseContentType.call([]) + |> send_resp(200, "done") + + [content_type] = get_resp_header(conn, "content-type") + assert String.contains?(content_type, "application/vnd.api+json") + assert String.contains?(content_type, ~s(ext="#{ext_uri}")) + end + + test "sets content type with profile parameter when profiles configured" do + profile_uri = "https://example.com/profile" + + conn = + conn_with_config([], [profile_uri]) + |> ResponseContentType.call([]) + |> send_resp(200, "done") + + [content_type] = get_resp_header(conn, "content-type") + assert String.contains?(content_type, "application/vnd.api+json") + assert String.contains?(content_type, ~s(profile="#{profile_uri}")) + end + + test "adds Vary: Accept header when extensions are configured" do + conn = + conn_with_config(["https://example.com/ext"]) + |> ResponseContentType.call([]) + |> send_resp(200, "done") + + assert get_resp_header(conn, "vary") == ["Accept"] + end + + test "adds Vary: Accept header when profiles are configured" do + conn = + conn_with_config([], ["https://example.com/profile"]) + |> ResponseContentType.call([]) + |> send_resp(200, "done") + + assert get_resp_header(conn, "vary") == ["Accept"] + end + + test "does not overwrite existing Vary header set by an upstream plug" do + conn = + conn_with_config(["https://example.com/ext"]) + |> put_resp_header("vary", "Origin") + |> ResponseContentType.call([]) + |> send_resp(200, "done") + + vary_values = get_resp_header(conn, "vary") + vary_string = Enum.join(vary_values, ", ") + assert String.contains?(vary_string, "Accept"), "Vary should contain Accept" + + assert String.contains?(vary_string, "Origin"), + "Vary should contain Origin (must not be overwritten)" + end + + test "does not override existing content-type header" do + conn = + conn_with_config() + |> put_resp_content_type("text/plain") + |> ResponseContentType.call([]) + |> send_resp(200, "done") + + [content_type] = get_resp_header(conn, "content-type") + assert String.starts_with?(content_type, "text/plain") + end end diff --git a/test/jsonapi_plug/plug_test.exs b/test/jsonapi_plug/plug_test.exs index 139aee3e..508429c6 100644 --- a/test/jsonapi_plug/plug_test.exs +++ b/test/jsonapi_plug/plug_test.exs @@ -792,4 +792,54 @@ defmodule JSONAPIPlug.PlugTest do |> PostResourcePlug.call([]) end end + + describe "@-member filtering" do + test "silently drops @-prefixed keys from attributes" do + assert %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + conn( + :post, + "/", + Jason.encode!(%{ + "data" => %{ + "type" => "post", + "attributes" => %{ + "text" => "Hello", + "@context" => "https://schema.org", + "@type" => "Article" + } + } + }) + ) + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> PostResourcePlug.call([]) + + assert params["text"] == "Hello" + refute Map.has_key?(params, "@context") + end + + test "regular attributes are preserved alongside @-members" do + assert %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + conn( + :post, + "/", + Jason.encode!(%{ + "data" => %{ + "type" => "post", + "attributes" => %{ + "text" => "Hello", + "body" => "World", + "@context" => "https://schema.org" + } + } + }) + ) + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> PostResourcePlug.call([]) + + assert params["text"] == "Hello" + assert params["body"] == "World" + end + end end