Skip to content

Prepare 1.0.0: egress validation, TODO cleanup, dependency updates - #62

Merged
ityonemo merged 8 commits into
mainfrom
v1.0-prep
Oct 24, 2026
Merged

ityonemo merged 8 commits into
mainfrom
v1.0-prep

Conversation

@ityonemo

Copy link
Copy Markdown
Contributor

Prepares the 1.0.0 release: resolves every outstanding TODO in lib/, adds egress (response) schema validation, and brings dependencies up to date.

Egress (response) schema validation

The headline feature. Response bodies can now be validated against the schemas declared for them under responses, the same way parameters and request bodies already are.

It is gated on the application environment, read with Application.compile_env/3:

# config/test.exs
config :apical, egress_testing: true

When the flag is false — the default — nothing of the feature is compiled. No plug, no validators, and neither Apical.Plugs.Egress nor Apical.Exceptions.EgressError is even defined. Verified by building :dev and :prod and checking both modules are absent:

Egress:      false
EgressError: false

A nonconforming response raises Apical.Exceptions.EgressError, carrying a 500. The status is 5xx rather than 4xx deliberately: responses are checked at the end of request handling, after the operation has run and its side effects (database writes, for example) have already happened. A 4xx would tell the client their request was rejected and nothing occurred, which is not true — the fault is the server's, for emitting a response its own schema forbids.

Validation runs in a Plug.Conn.register_before_send/2 callback, which fires with conn.state == :set — after the controller builds the response but before any of it reaches the socket — so a failure cannot leave a half-sent response. Validators key on status code and content type, falling back to the default response object. Responses with no schema, and bodies that are not JSON, pass through. Works on both the Phoenix and Plug adapters.

Real output from the Plug-adapter test:

** (Apical.Exceptions.EgressError) Egress Error in operation plugEgressNonconforming
   (200 application/json): value `{"wrong":"field"}` at `/` fails schema criterion at
   `#/paths/~1nonconforming/get/responses/200/content/application~1json/schema/required/0`.
   required: "/count"

Documented in a new guides/Response Validation.md and in the Apical moduledoc.

Bugfix: malformed matrix parameters returned a 500

A matrix segment carrying more than one = (e.g. ;a=b=c) fell through a case with no catch-all and raised CaseClauseError. That carries no plug_status, so a malformed request surfaced as a 500 instead of a 400.

The split runs before the cond dispatches on type, so this affected all four matrix shapes — array, object, exploded object and primitive — not just objects. Now reported with the same wording the simple and label styles already use.

Dependencies

mix hex.audit reported 47 advisories; this clears 46 of them across 22 packages:

advisories
plug 1.19.1 → 1.20.3 4 (2 HIGH)
phoenix 1.8.5 → 1.8.16 3 (2 HIGH)
bandit 1.10.4 → 1.13.1 11 (6 HIGH)
mint 1.7.1 → 1.11.0 14 (7 HIGH)
cowlib 2.16.0 → 2.21.0 7 (3 HIGH)
cowboy 2.14.2 → 2.20.0 3 (1 HIGH)
req 0.5.17 → 0.7.5 2 (1 HIGH)
exonerate 1.2.1 → 1.3.0 —

plus hpax, plug_crypto, plug_cowboy, jason, mox, yaml_elixir, ex_doc and transitives. No requirement in mix.exs needed to change — every update was already permitted.

plug is the only affected package Apical depends on at runtime, so it was the only one whose advisories reached consumers through this library.

mox and bypass are now only: :test

Both were optional: true but not only: :test. Those control different things — optional: true governs whether a consumer is forced to install a package; only: scopes which of our environments fetch it. With just the former, dev and prod builds compiled bypass and the entire cowboy stack it hard-depends on, including cowlib:

before (MIX_ENV=prod):
  apical bypass cowboy cowboy_telemetry cowlib exonerate jason json_ptr
  match_spec mime mox nimble_ownership nimble_parsec pegasus plug
  plug_cowboy plug_crypto ranch telemetry yamerl yaml_elixir

after:
  apical exonerate jason json_ptr match_spec mime nimble_parsec pegasus
  plug plug_crypto telemetry yamerl yaml_elixir

Safe because Mox and Bypass are referenced only inside quote blocks in Apical.Testing, so neither is resolved when this library compiles; the generated code needs them in the user's test env, which optional: true still expresses.

One advisory remains, unfixable

cowlib EEF-CVE-2026-43966 (MEDIUM, HTTP response splitting) — 2.21.0 is the latest published version, so there is nothing to upgrade to. It arrives via bypass → plug_cowboy → cowboy. Bypass is hard-wired to Cowboy (its mix.exs requires plug_cowboy unconditionally, it depends on ranch directly, and it has had no release since 2020-11-13 — upstream master still requires plug_cowboy), so the Cowboy stack is unavoidable while Bypass is a dependency. With Bypass now only: :test it reaches no dev or prod build.

TODO cleanup

All seven TODOs in lib/ are resolved; grep TODO lib/ is now empty.

  • Three ("make this recursive" ×2, "refactor into a recursive call") described work already done or that would have made the code less idiomatic — the surrounding code already iterates correctly via Enum/Map.new. Removed. One was orphaned between two functions, attached to neither.
  • request_body.ex: content_sources is read only by init/1, which resolves the single source matching that plug's own media type; call/2 never touches it. It was being embedded in full in every generated plug/2 call, once per media type per operation. Now narrowed to the entry each plug can use.
  • query.ex: the "assess if this can be done without a catch" note is replaced with the reason the catch stays. parse_kv/3 throws when a custom style parser rejects a value, and its result is spliced straight into the parser accumulator ({key, parse_kv(...)}) — it has no error channel, so returning an error tuple would store that tuple as the parsed value. Threading a failure out would have to pass through every handle_query_part/5 clause, the exploded-array accumulator and the context map, to replace a non-local exit that is caught at the module boundary and never escapes.
  • parameter_error.ex: the note was truncated mid-phrase ("improve this error by turning") and never recorded enough intent to act on. The current describe_exonerate/1 output is asserted verbatim by ~30 tests, so the format is unchanged.

Testing

484 tests, 0 failures (up from 468 — 13 new egress tests, 3 new matrix regression tests).

Both bugfix/feature commits were red-green verified:

  • matrix fix — with the fix stashed, all three new tests fail with CaseClauseError (no case clause matching: ["object", "foo", "bar"])
  • egress — with the test handlers changed to return conforming bodies, all four raise-expecting tests fail with Expected exception Apical.Exceptions.EgressError but nothing was raised

Clean under --warnings-as-errors and --check-formatted in :test, :dev and :prod; docs build; mix hex.build produces apical-1.0.0.tar.

Exonerate 1.3.0 emits warnings about an optional Decimal dependency we do not pull in. Those are confined to that package's own compilation and do not affect the project build CI runs (verified: touching lib/apical.ex and recompiling with --warnings-as-errors exits 0).

Also

  • CHANGELOG sorted into consistent descending version order — the older entries ran 0.1.0, 0.2.0, 0.2.1, 0.3.0 ascending while newer ones descended, so the file read in two directions. Content unchanged (verified by sorted-line diff).
  • mix.exs → 1.0.0; README install snippet → {:apical, "~> 1.0"}.
  • data egress checking (conditional, compile-time) removed from the roadmap, as it now ships.

Not included

  • Milestones 0.2 / 0.3 / 0.4 are still open on GitHub with zero open issues — bookkeeping to close out separately.
  • mix hex.publish has not been run. The version is bumped but nothing is published.

🤖 Generated with Claude Code

ityonemo and others added 8 commits October 24, 2026 07:16
Three of the seven `TODO`s in `lib/` described work that is already done or
that would make the code worse:

- `conn.ex` / `cookie.ex` "make this recursive": both sat next to code that
  already iterates a collection via `Enum`/`Map.new` and is correct as
  written. The `conn.ex` note was also orphaned, floating between
  `fetch_path_params!/2` and `fetch_header_params!/2` and attached to
  neither.
- `_parameter.ex` "refactor this into a recursive call": `check_required/4`
  uses `Enum.each` with an `is_map_key/2` guard clause, which is the
  idiomatic form here; a hand-rolled recursion would be less clear.

Also drops an unused `Endpoint` alias in the additionalProperties-boolean
request body test, which warned on every `mix test` run.

No functional change; 468 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A matrix segment is `name=value`.  A segment carrying more than one `=`
(e.g. `;a=b=c`) fell through the `case String.split(part, "=")` in the
matrix branch of `Style.parse/5`, which had no catch-all clause, and raised
`CaseClauseError`.  Since that is not an `Apical.Exceptions.ParameterError`
it carried no `plug_status`, so a malformed request surfaced as a 500
instead of a 400.

The split runs before the `cond` dispatches on type, so this affected all
four matrix shapes — array, object, exploded object, and primitive — not
just objects.

`split_matrix/1` now reduces over the segments and short-circuits with
`{:error, part}`, which `parse/5` renders using the same wording the
`simple` and `label` styles already use for this case:

    matrix object parameter `;object=foo=bar` for parameter `object`
    has a malformed entry: `object=foo=bar`

`parse_matrix/5` is split out so the `parse/5` clauses stay grouped.

Red-green verified: with the fix stashed, all three new tests fail with
`CaseClauseError (no case clause matching: ["object", "foo", "bar"])`; with
it restored they pass.  471 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clears the last three of the seven `TODO`s, leaving `lib/` TODO-free.

`parameter_error.ex`: drop the truncated "improve this error by turning"
note.  It was cut off mid-phrase and never recorded enough intent to act
on; the current `describe_exonerate/1` output is asserted verbatim by ~30
tests, so the message format stays as it is.

`query.ex`: the `catch` around `parse_query/2` is deliberate, so replace
the "assess if this can be done without a catch" note with the reason it
stays.  `parse_kv/3` throws when a custom style parser rejects a value, and
its result is spliced straight into the parser accumulator
(`{key, parse_kv(...)}`) — it has no error channel of its own, so returning
an error tuple would store that tuple *as* the parsed value.  Threading a
failure out instead would have to pass through every `handle_query_part/5`
clause, the exploded-array accumulator and the context map, to replace a
non-local exit that is caught at the module boundary and never escapes.

`request_body.ex`: `content_sources` is read only by `init/1`, which
resolves the single source matching that plug's own media type; `call/2`
never touches it.  Embedding the whole proplist in the generated `plug/2`
call baked every operation's sources into the router once per media type.
`narrow_content_sources/2` now keeps just the entry a given plug can use,
and drops the key when none matches.  Resolution still happens in `init/1`,
so `source_module.validate!/2` and media-type parsing are unchanged.

471 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Responses can now be validated against the schemas declared for them under
`responses` in the OpenAPI document.  The feature is gated on the
application environment:

    config :apical, egress_testing: true

read with `Application.compile_env/3`.  When it is false -- the default --
nothing of the feature is compiled: `Apical.Path` emits no egress plug and
no response validators, and neither `Apical.Plugs.Egress` nor
`Apical.Exceptions.EgressError` is defined, so a production build carries
no branch and not even a struct definition.  Verified by compiling `:dev`
and `:prod` and checking both modules are absent.

A nonconforming response raises `Apical.Exceptions.EgressError`, carrying a
500.  The status is 5xx rather than 4xx deliberately: responses are checked
at the end of request handling, after the operation has run and its side
effects (database writes, for example) have already happened.  A 4xx would
tell the client their request was rejected and nothing occurred, which is
not true; the fault is the server's, for emitting a response its own schema
forbids.

Validation runs in a `Plug.Conn.register_before_send/2` callback, which
fires with `conn.state == :set` -- after the controller has built the
response but before any of it reaches the socket -- so a failure cannot
leave a half-sent response.  Validators are keyed on status code and
response content type and fall back to the `default` response object.
Responses with no schema, and bodies that are not JSON, pass through.

Wired into both adapters.  The Plug adapter emits the validators inside its
nested per-operation module, where its other validators already resolve the
Exonerate resource.

Red-green verified: with the test handlers changed to return *conforming*
bodies, all four raise-expecting tests fail with "Expected exception
Apical.Exceptions.EgressError but nothing was raised".

484 tests, 0 failures (13 new).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mix hex.audit` reported 47 advisories across the HTTP stack.  Updating
clears 46 of them; 22 packages move in `mix.lock`:

    plug          1.19.1  -> 1.20.3    (4 advisories, incl. 2 HIGH)
    phoenix       1.8.5   -> 1.8.16    (3, incl. 2 HIGH)
    bandit        1.10.4  -> 1.13.1    (11, incl. 6 HIGH)
    req           0.5.17  -> 0.7.5     (2, incl. 1 HIGH)
    exonerate     1.2.1   -> 1.3.0
    cowboy        2.14.2  -> 2.20.0    (3, incl. 1 HIGH)
    cowlib        2.16.0  -> 2.21.0    (7, incl. 3 HIGH)
    mint          1.7.1   -> 1.11.0    (14, incl. 7 HIGH)
    hpax          1.0.3   -> 1.1.0     (1 HIGH)
    plug_crypto   2.1.1   -> 2.2.1     (1)
    plug_cowboy   2.8.0   -> 2.9.0     (1 HIGH)

plus jason, mox, yaml_elixir, ex_doc, finch, telemetry, thousand_island,
websock_adapter, phoenix_pubsub, phoenix_template, makeup and earmark_parser.

`plug` is the only one of these that Apical depends on at runtime, so it was
the only package whose advisories reached consumers through this library;
the rest are test/dev-only or transitive from those.

No requirement in `mix.exs` needed to change -- every update was already
permitted by the existing constraints, so this is a lockfile change only.

One advisory remains and cannot currently be fixed: `cowlib`
EEF-CVE-2026-43966 (MEDIUM, HTTP response splitting), for which 2.21.0 is
the latest published version.  It comes in transitively through
`bypass` -> `plug_cowboy` -> `cowboy`, which is test-only tooling.

Apical itself still compiles warning-free under `--warnings-as-errors` in
`:test`, `:dev` and `:prod`.  Exonerate 1.3.0 emits warnings about an
optional `Decimal` dependency we do not pull in; those are confined to that
package's own compilation and do not affect the project build that CI runs.

484 tests, 0 failures.  Docs build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bumps `mix.exs` to 1.0.0 and the README's install snippet to `~> 1.0`.

Also sorts the CHANGELOG into consistent descending version order.  The
older entries ran 0.1.0, 0.2.0, 0.2.1, 0.3.0 ascending while the newer ones
descended, so the file read in two directions at once.  Content is
unchanged -- only the order of the sections.

The `{:exonerate, "~> 1.2"}` requirement is left as it is: it already
permits the 1.3.0 now in `mix.lock`.

484 tests, 0 failures.  `mix hex.build` produces apical-1.0.0.tar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were declared `optional: true` but not `only: :test`.  Those control
different things: `optional: true` governs whether a *consumer* is forced to
install the package, while `only:` scopes which of *our* environments fetch
and compile it.  With just the former, Apical's own dev and prod builds
pulled in bypass and the whole cowboy stack it hard-depends on:

    before (MIX_ENV=prod):
      apical bypass cowboy cowboy_telemetry cowlib exonerate jason json_ptr
      match_spec mime mox nimble_ownership nimble_parsec pegasus plug
      plug_cowboy plug_crypto ranch telemetry yamerl yaml_elixir

    after:
      apical exonerate jason json_ptr match_spec mime nimble_parsec pegasus
      plug plug_crypto telemetry yamerl yaml_elixir

That included `cowlib`, the one package whose advisory has no published fix.
It now appears in no build but `:test`.

Safe because `Mox` and `Bypass` are referenced only inside `quote` blocks in
`Apical.Testing`, so neither is resolved when this library itself compiles --
the generated code needs them in the *user's* test environment, which is
what `optional: true` continues to express.

484 tests, 0 failures; dev and prod compile warning-free from a clean build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dependency update took `cowlib` to 2.21.0, which broke the Elixir 1.17 /
OTP 26 matrix row:

    ===> Compiling src/cow_capsule.erl failed
    src/cow_capsule.erl:67:3: syntax error before: '{'
    ** (Mix) Could not compile dependency :cowlib

`cow_capsule.erl` uses a `maybe ... ?=` expression, which is only enabled by
default from OTP 27, so cowlib 2.20.0 and later cannot compile on OTP 26.
Upstream has moved to OTP 27+ only (`AUTO_CI_OTP ?= OTP-LATEST-27+` in its
Makefile).  Verified by compiling each release against OTP 26: 2.17.1,
2.18.0 and 2.19.0 build; 2.20.0 and 2.21.0 do not.

Since `elixir: "~> 1.17"` still implies OTP 26 support, pin to the newest
combination that works there -- cowboy 2.18.0, whose requirement is
`cowlib >= 2.19.0`.  Both are `only: :test, override: true`, as they exist
only because bypass hard-depends on plug_cowboy.

The cost is two further test-only advisories (cowlib 2.19.0 carries
EEF-CVE-2026-43966 MEDIUM, -43971 MEDIUM and -43969 LOW, where 2.21.0
carried only the first).  None has a fix that builds on OTP 26, and with
bypass scoped `only: :test` none reaches a dev or prod build or any
consumer.  Dropping OTP 26 instead would clear two of them, but that is a
support-policy change rather than a dependency fix.

Verified on the full matrix: 484 tests, 0 failures on Elixir 1.17.3/OTP 26
(the row that failed), 1.18.4/OTP 28 and 1.19.6/OTP 28; compile
`--warnings-as-errors` and `--check-formatted` clean on 1.17/OTP 26; prod
build still free of the entire cowboy stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ityonemo
ityonemo merged commit 6edc69d into main Oct 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant