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
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Config

alias JSONAPIPlug.TestSupport.API.{
AtomicAPI,
DasherizingAPI,
DefaultAPI,
OtherHostAPI,
Expand All @@ -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"
Expand Down
56 changes: 56 additions & 0 deletions lib/jsonapi_plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 """
Expand Down Expand Up @@ -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

Expand Down
177 changes: 177 additions & 0 deletions lib/jsonapi_plug/atomic_plug.ex
Original file line number Diff line number Diff line change
@@ -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
Loading