Wymcp. Plugs. Pipeline
(Wymcp v0.8.7)
View Source
The POST plug chain: every check and stage a POST passes through, in the one order that makes each stage's precondition true.
Wymcp.Plugs.OriginCheck # before anything is parsed
parse_body # the parse step
Wymcp.Plugs.Classify # message kind
Wymcp.Plugs.Auth # authentication
Wymcp.Plugs.SingletonHeaders # singleton-header cardinality
Wymcp.Plugs.ProtocolFields # protocol-fields enforcement
Wymcp.Plugs.HeaderBinding # header binding
Wymcp.Plugs.Validate # MCP schema
Wymcp.Plugs.Dispatch # method → its answering moduleWhy that order. The origin check runs before anything is parsed because
nothing has validated Origin when it runs — Wymcp.Router's wire-check
invariant, which is also where the wire checks and their order are stated.
The parse step and classification sit between the origin check and the
remaining wire checks so those checks' rejections can carry the body's id
and know the message kind. Wymcp.RouterTest pins that placement end to end — its
authentication describe proves the tag is present when the auth check,
the first consumer, reads it, and its singleton-header check describe
proves the same at the next site; Wymcp.Plugs.AuthTest and
Wymcp.Plugs.SingletonHeadersTest call their plug directly rather than
routing a request, so neither sees the order. The protocol-fields check
runs after the singleton-header check, which guarantees at most one
MCP-Protocol-Version header for the check after it to read. The
header-binding check runs after Wymcp.Plugs.ProtocolFields, which has
just proven the body's protocol version is a string naming a served
revision — so its protocol-version row has a known-good body value to
compare against, and a malformed _meta meets its own -32602 first. Its
other rows read body fields nothing has validated yet, and answer none of
them: a body value with no header spelling binds nothing, so the plug or
method that owns the defect still answers it. That plug's own moduledoc
carries the rule. Validation runs last before dispatch, so the wire checks
and the body-bound checks answer first.
The chain is gated, not derived. The wire checks cannot be spliced in as
one list — the parse step and classification interleave them — so this module
writes the order by hand and exposes it through chain/0, and
Wymcp.WireCheckInvariantTest holds it to the router's list, in two cells
that both read the chain only as far as Wymcp.Plugs.Validate — the point
past which a request is validated and answered: every wire check is in
that prefix, in list order, and every other plug in it is declared
body-bound in body_bound_plugs/0, with the reason it reads the body. A
plug added ahead of Wymcp.Plugs.Validate therefore joins the wire-check
list or declares why it is not one; a wire check moved past
Wymcp.Plugs.Validate leaves that prefix, and fails the first of the two
cells. chain/0 reads Plug.Builder's accumulated @plugs, which is
stored newest-first as {plug, opts, guards} tuples, so the accessor
reverses it and keeps the first element; a function plug appears as its
bare atom, exactly as it is declared.
POST is the one served verb, so this chain is the one path a request
takes; every other verb reaches Wymcp.Router's fallthrough and runs
nothing here.
The parse step
The parse step is an inline function
rather than a plug so that a request it refuses is answered in wymcp's own
dialect and recorded as a rejection, instead of escaping the mount as a
Plug exception. It reads the Content-Type header's cardinality itself —
Plug.Parsers raises nothing when the header is absent and reads only the
first of several — and then runs Plug.Parsers, whose own order decides
the rest. A request wrong in more than one way meets the first arm in this
order:
| Condition | Status | Reason |
|---|---|---|
no Content-Type header | 415 | :unsupported_media_type |
more than one Content-Type header | 400 | :duplicated_header |
| a query string over the query-string ceiling | 414 | :query_too_long |
a query string carrying invalid UTF-8, or keys nested past Plug's maximum | 400 | :malformed_query |
a type other than application/json (with any parameters) or an application/*+json subtype | 415 | :unsupported_media_type |
| a body over the body ceiling | 413 | :body_too_large |
| a body that does not decode as JSON | 400 | :malformed_json |
An absent Content-Type is refused under the same reason and message as a
type the parser does not take: either way nothing declares the body JSON.
A malformed body answers -32700, its envelope assembled here; every other
arm answers -32600 through Wymcp.Response.send_rejection/5. Every
envelope's id is null: nothing has classified the message yet. The two
arms that answer after the body read has begun — the 413 and the
malformed-JSON 400 — close an HTTP/1.x connection with their answer: they
answer on the connection as it stood before the read, so a keep-alive
connection would otherwise be drained from the next request's bytes. Every
other arm answers before any body is read and leaves the connection open;
HTTP/2 frames each request as its own stream and gets no such header. The two
ceilings — 8000000 bytes of body and 1000000 bytes of query string, each
inclusive — and the query string's UTF-8 validation are set here rather
than left to Plug.Parsers' defaults, so a refusal can name its ceiling
and a Plug upgrade cannot change what the wire says. Over HTTP/1.1,
Bandit's default request-line limit sits far below the query-string
ceiling, so there a query string anywhere near it is refused by the
adapter's own 414 before the mount runs.
The parse step reads the body through its own reader,
read_request_body/2, in place of Plug.Conn.read_body/2 itself, to
hold the adapter to Plug's read contract. A read bounded by the body
ceiling answers {:more, …} when more remains, so a {:more, …}
carrying at least the ceiling meets the 413 row, whatever the reader:
exactly the ceiling on an HTTP/1.1 Content-Length read and under
Plug.Test, past it on Bandit's HTTP/2 stream — and exactly the ceiling
on Bandit's HTTP/1.1 chunked read, which stops at the ceiling without
reading on for the terminating chunk, so a chunked body of exactly the
ceiling meets the row too. A {:more, …} carrying less is a
stalled read the adapter reported as
a short answer rather than as the {:error, :timeout} Plug's contract
names. The reader answers it as that {:error, :timeout}, so
Plug.Parsers.JSON raises Plug.TimeoutError for it exactly as it
does for an adapter that keeps the contract, and records nothing: no
row, no rejection mark, no event, no log line. A stalled read is the
host's to answer, as Wymcp.Response's boundary paragraph says of every
failure of the body read, and Plug's raise is how it reaches the host as
one when the adapter did not raise it first. The edge is deliberate: a
stalled read that had received exactly the ceiling meets the 413 row,
since such a body is at least the ceiling and unfinished, and "send at
most" is the right instruction for it.
The reader that reports a stalled read as a {:more, …} today is
Bandit's HTTP/2 stream, which answers the same tuple for a stalled read
as for a body past the ceiling. Bandit's HTTP/1.1 socket raises its own
408 inside the read, before this reader sees a tuple, and logs it at
error level through its protocol-error path; the Plug.TimeoutError
Plug raises for the reader's answer escapes to the host as a raised
exception. A bare Bandit host answers it as a 408 with no body — on
HTTP/2 the status alone, no header — and logs it only when 408 is in
its log_exceptions_with_status_codes; a host whose endpoint renders
escaped exceptions itself — a Phoenix endpoint's render_errors —
answers with what that renders and logs it through its own path before
the exception reaches Bandit. The read timeout is pinned here at its
adapter default, 15000 milliseconds, for the ceilings' reason: how long
the adapter's body read may wait before its 408 is not left to a
library default.
Summary
Functions
Callback implementation for Plug.call/2.
Callback implementation for Plug.init/1.