Repository navigation
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Prepares the 1.0.0 release: resolves every outstanding
TODOinlib/, 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:When the flag is false — the default — nothing of the feature is compiled. No plug, no validators, and neither
Apical.Plugs.EgressnorApical.Exceptions.EgressErroris even defined. Verified by building:devand:prodand 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/2callback, which fires withconn.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 thedefaultresponse 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:
Documented in a new
guides/Response Validation.mdand in theApicalmoduledoc.Bugfix: malformed matrix parameters returned a 500
A matrix segment carrying more than one
=(e.g.;a=b=c) fell through acasewith no catch-all and raisedCaseClauseError. That carries noplug_status, so a malformed request surfaced as a 500 instead of a 400.The split runs before the
conddispatches on type, so this affected all four matrix shapes — array, object, exploded object and primitive — not just objects. Now reported with the same wording thesimpleandlabelstyles already use.Dependencies
mix hex.auditreported 47 advisories; this clears 46 of them across 22 packages:plugphoenixbanditmintcowlibcowboyreqexonerateplus
hpax,plug_crypto,plug_cowboy,jason,mox,yaml_elixir,ex_docand transitives. No requirement inmix.exsneeded to change — every update was already permitted.plugis the only affected package Apical depends on at runtime, so it was the only one whose advisories reached consumers through this library.moxandbypassare nowonly: :testBoth were
optional: truebut notonly: :test. Those control different things —optional: truegoverns whether a consumer is forced to install a package;only:scopes which of our environments fetch it. With just the former,devandprodbuilds compiled bypass and the entire cowboy stack it hard-depends on, includingcowlib:Safe because
MoxandBypassare referenced only insidequoteblocks inApical.Testing, so neither is resolved when this library compiles; the generated code needs them in the user's test env, whichoptional: truestill expresses.One advisory remains, unfixable
cowlibEEF-CVE-2026-43966 (MEDIUM, HTTP response splitting) — 2.21.0 is the latest published version, so there is nothing to upgrade to. It arrives viabypass→plug_cowboy→cowboy. Bypass is hard-wired to Cowboy (itsmix.exsrequiresplug_cowboyunconditionally, it depends onranchdirectly, and it has had no release since 2020-11-13 — upstreammasterstill requiresplug_cowboy), so the Cowboy stack is unavoidable while Bypass is a dependency. With Bypass nowonly: :testit reaches nodevorprodbuild.TODO cleanup
All seven
TODOs inlib/are resolved;grep TODO lib/is now empty.Enum/Map.new. Removed. One was orphaned between two functions, attached to neither.request_body.ex:content_sourcesis read only byinit/1, which resolves the single source matching that plug's own media type;call/2never touches it. It was being embedded in full in every generatedplug/2call, 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 thecatchstays.parse_kv/3throws 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 everyhandle_query_part/5clause, 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 currentdescribe_exonerate/1output 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:
CaseClauseError (no case clause matching: ["object", "foo", "bar"])Expected exception Apical.Exceptions.EgressError but nothing was raisedClean under
--warnings-as-errorsand--check-formattedin:test,:devand:prod; docs build;mix hex.buildproducesapical-1.0.0.tar.Exonerate 1.3.0 emits warnings about an optional
Decimaldependency we do not pull in. Those are confined to that package's own compilation and do not affect the project build CI runs (verified: touchinglib/apical.exand recompiling with--warnings-as-errorsexits 0).Also
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
mix hex.publishhas not been run. The version is bumped but nothing is published.🤖 Generated with Claude Code