Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 13 additions & 1 deletion lib/jsonapi_plug/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
]
Expand Down
3 changes: 2 additions & 1 deletion lib/jsonapi_plug/document.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 34 additions & 3 deletions lib/jsonapi_plug/document/jsonapi_object.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
treere marked this conversation as resolved.

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
Expand All @@ -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
Comment thread
treere marked this conversation as resolved.

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"

Expand Down
51 changes: 48 additions & 3 deletions lib/jsonapi_plug/document/link_object.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 8 additions & 3 deletions lib/jsonapi_plug/document/resource_object.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: %{}

Expand All @@ -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)}

Expand All @@ -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,
Expand All @@ -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
21 changes: 20 additions & 1 deletion lib/jsonapi_plug/normalizer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ defmodule JSONAPIPlug.Normalizer do

alias JSONAPIPlug.{
Document,
Document.JSONAPIObject,
Document.RelationshipObject,
Document.ResourceIdentifierObject,
Document.ResourceObject,
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down
Loading