Capture a bounded request before destructive parsing or origin/IP rewriting.
Original ingress facts remain separate from the selected origin. Transport facts stay declared and describe ingress, never the forwarded origin.
Headers retain Plug's occurrence order and protocol provenance. Plug has
already normalized header names; HTTP/2 servers can join cookie crumbs.
Request trailers are :unavailable; policies requiring them reject.
The target is origin-form reconstructed from request_path and query_string.
It supports path derivation, not exact target evidence. Verify rejects required
or covered @request-target, @target-uri, and @query with
:unsupported_component; SignResponse also rejects those request components.
See RequestSeal.Plug for the tested per-transport rule.
OPTIONS * is retained.
After a successful capture, the Delivery adapter replays retained bytes to all readers, including multipart. Custom wrapper readers must return replayed bytes unchanged; their subsequent transformations cannot be observed by the adapter. Retention remains caller-owned.
Summary
Functions
Reads the request body once and retains the request as an immutable message.
Validates capture options once, at plug initialization.
Records that the RequestSeal reader ran, then reads the body through Plug.
Types
@type t() :: %RequestSeal.Plug.Capture{ ingress: %{ scheme: atom(), host: binary(), port: non_neg_integer(), remote_ip: :inet.ip_address(), protocol: atom() }, message: RequestSeal.Message.t(), origin: %{ scheme: binary(), authority: binary(), source: :connection | :declared | :forwarded } }
Functions
Reads the request body once and retains the request as an immutable message.
One Plug.Conn.read_body/2 call with length: max_body_bytes captures the
bytes. On success the request-local Delivery adapter replaces the
connection adapter so later readers receive the same bytes, and the capture
is stored in private state for RequestSeal.Plug.capture/1.
Parameters
Returns
Plug.Conn- on success, the connection with the capture stored.Plug.Conn- on rejection, halted with an empty body and status 413 for:limitor 400 for every other reason. Only HTTP/1.0 and HTTP/1.1 rejections carryconnection: close; HTTP/2 rejections omit connection fields. Private state's:errorholds a%RequestSeal.Adapter.Error{stage: :capture, attempt: 0}with reason::parser_order(body_paramsalready fetched; checked before any read),:invalid_request(CONNECT, an empty host, an existing capture, a malformed or oversized forwarded field, an invalid forwarded scheme or authority, a valueRequestSeal.Message.new/1rejects, or an unexpected exception),:untrusted_proxy(the socket peer is outsidetrusted_peers),:limit(the read returned:moreor more thanmax_body_bytes; no second read occurs), and:body_unavailable(the server returned a read error). Meanings:RequestSeal.Adapter.Error.
Examples
iex> opts =
...> RequestSeal.Plug.Capture.init(origin: :connection, max_body_bytes: 4, read_timeout: 5_000)
iex> conn =
...> RequestSeal.Plug.Capture.call(
...> Plug.Test.conn(:post, "https://telemetry.example.com/ingest", "12345"),
...> opts
...> )
iex> {conn.status, conn.halted, conn.private.request_seal.error.reason}
{413, true, :limit}
iex> Plug.Conn.get_resp_header(conn, "connection")
["close"]RequestSeal.Plug.capture/1 shows a successful capture.
Validates capture options once, at plug initialization.
Parameters
opts- keyword list of the options below. All are required; unknown or duplicate options reject.
Options
:origin- how the request origin (scheme and authority) is selected. Every selected origin lowercases scheme and host and omits the default port (80 for HTTP, 443 for HTTPS).:connection- the connection's scheme, host, and port.{:declared, scheme, authority}- a caller-trusted scheme and authority, validated and normalized at init.{:forwarded, %{trusted_peers: cidrs, field: field}}-cidrsis a list of 1..1,024{ip_tuple, prefix}ranges;fieldis:forwardedor:x_forwarded. The last forwarded element is used only after the actual socket peer is found in a trusted range.
:max_body_bytes- nonnegative integer bound on the captured body.:read_timeout- 1..300,000 milliseconds for the single body read.
Returns
- keyword list - the validated options, with a declared origin normalized.
Raises RequestSeal.Adapter.Error with reason :invalid_options and stage
:capture on any invalid option.
Examples
iex> RequestSeal.Plug.Capture.init(
...> origin: {:declared, "HTTPS", "Docs.Example.com:443"},
...> max_body_bytes: 1_024,
...> read_timeout: 5_000
...> )
[origin: {:declared, "https", "docs.example.com"}, max_body_bytes: 1024, read_timeout: 5000]
iex> try do
...> RequestSeal.Plug.Capture.init(origin: :connection, max_body_bytes: -1, read_timeout: 5_000)
...> rescue
...> error in RequestSeal.Adapter.Error -> {error.reason, error.stage}
...> end
{:invalid_options, :capture}
@spec read_body(Plug.Conn.t(), keyword()) :: {:ok | :more, binary(), Plug.Conn.t()} | {:error, term()}
Records that the RequestSeal reader ran, then reads the body through Plug.
Configure it as a parser reader with
body_reader: {RequestSeal.Plug.Capture, :read_body, []}. After a capture,
reads come from the retained replay: each returns at most :length bytes
(default 8,000,000), :more until the replay is drained, then :ok with
empty bytes. Without a capture the call is a plain Plug.Conn.read_body/2.
RequestSeal.Plug.Verify uses the recorded invocation to require a fully
drained, matching replay.
Parameters
conn- aPlug.Conn.opts-Plug.Conn.read_body/2options; the replay honors:length.
Returns
{:ok, binary, conn}- the last bytes of the body, or empty bytes once drained.{:more, binary, conn}- a chunk; more bytes remain.{:error, %RequestSeal.Adapter.Error{reason: :invalid_options}}- a negative or non-integer replay:length; the replay does not advance.{:error, term}- any other error from the underlying Plug adapter when no capture is installed.
Examples
iex> opts =
...> RequestSeal.Plug.Capture.init(origin: :connection, max_body_bytes: 1_024, read_timeout: 5_000)
iex> conn =
...> RequestSeal.Plug.Capture.call(
...> Plug.Test.conn(:post, "https://docs.example.com/upload", "abcdef"),
...> opts
...> )
iex> {:more, first, conn} = RequestSeal.Plug.Capture.read_body(conn, length: 4)
iex> {:ok, rest, conn} = RequestSeal.Plug.Capture.read_body(conn, length: 4)
iex> {first, rest}
{"abcd", "ef"}
iex> {:ok, "", conn} = RequestSeal.Plug.Capture.read_body(conn, [])
iex> {:error, error} = RequestSeal.Plug.Capture.read_body(conn, length: -1)
iex> error.reason
:invalid_options