First item on the GOALS.md roadmap. `upload_{x}(id, files, is_public)`
attaches files through `/{resource}/{id}/upload` and returns the
refreshed record. Verified against upstream that all 16 controllers
answer with `itemResponse($x->fresh())`.
Three details that look wrong but are not, each checked in the upstream
source rather than taken from the published spec:
- The request is a POST carrying `_method=PUT`. The route is registered
PUT, but PHP does not parse a multipart body on a real PUT, so the
files never arrive. Laravel's kernel calls
`enableHttpMethodParameterOverride()`, turning the spoofed method back
into a PUT before routing. The spec documents POST with `_method=POST`,
which would 405 against these routes.
- `is_public` is sent as 1/0. Laravel's `boolean` rule accepts only
true, false, 0, 1, '0' and '1' under strict comparison, so "true"
fails validation.
- Uploads are never retried. An upload adds a document, so it is not
idempotent whatever its verb, and a replay after a lost response would
attach the file twice. This falls out of reqwest's multipart body being
un-cloneable, which nothing in the types enforces, so a test pins it
for 500, 503 and 429.
An upload with no files, a nameless file or an unparseable MIME type is
refused locally (`Error::InvalidUpload`), since the server answers an
empty upload with a success that changed nothing.
Corrects two earlier claims:
- GOALS.md and COVERAGE.md said uploads would make `create_document`
possible. They do not: `DocumentController::store` is an empty stub,
and an upload is the only way a document is created. There is no
`create_document`.
- The documented method count (265) had been stale since the typed
`statics`/`search`/`health_check` commit, which added three `_as`
methods without updating it. Recounted from the source: 284 = 210 +
18 + 12 + 16 uploads + 28 hand-written.
Also fixes a README example, `let statics: Value = client.statics()`,
that stopped compiling when `statics` lost its type parameter.
`edocument/upload` is left out: it imports an expense from an
e-document rather than attaching a file, so it moves to the
import/export roadmap item.
Enabling `multipart` adds mime, mime_guess and unicase; MSRV stays 1.88,
re-verified by running the suite on it. 101 tests, up from 91.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The advisory job was marked `continue-on-error`, which GitHub renders as
a green check with an annotation — a signal nobody reads. Removed, so a
failure shows as a real red X.
What now keeps it from blocking merges is branch protection: it has to
stay out of the required status checks. That is a repo setting rather
than anything in this file, so it is called out in ci.yml and CLAUDE.md.
Expect the job red occasionally through no fault of the change under
test; that is the cost of it being visible.
Also runs weekly (Mondays 06:00 UTC). A Rust release lands on its own
schedule, so on push/PR alone breakage could sit unnoticed for weeks.
The pinned `test` and `msrv` jobs skip scheduled runs — a pinned
toolchain against a locked dependency graph cannot change on a timer.
Adds a top-level `permissions: contents: read`, since the workflow needs
nothing more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pinning CI meant a future Rust release could break the build without
anyone noticing until the pin moved. This job tracks latest stable and
runs the test suite and clippy, so that breakage is visible early.
Marked `continue-on-error`, so it reports rather than gates: a new
clippy lint landing in stable should not fail a pull request that did
not cause it. The pinned `test` job remains the gate.
Note that GitHub renders a `continue-on-error` job as passing with an
annotation rather than as failed. If it should be more visible, make it
a required check and drop the flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dev shell tracked `stable.latest` and CI used `@stable`, so nothing
pinned a version and nothing declared a floor. A consumer on an older
toolchain hit a confusing error deep in a dependency rather than cargo
saying the version was too old, and a new stable release could turn a
lint into an unrelated red build.
- `rust-version = "1.88"` in Cargo.toml. The floor comes from the
dependency graph, not this crate's code: reqwest pulls in url -> idna
-> idna_adapter -> icu_*, and the icu crates require 1.88. Verified by
running the full suite against 1.88, not inferred.
- flake.nix pins the dev toolchain to 1.99.1 and adds an `msrv` devShell,
so the floor can be rechecked with
`nix develop .#msrv --command cargo test`.
- CI pins the main job to the same 1.99.1 and adds a job building on
1.88. Lint and format stay on the pinned toolchain only, since their
output varies by release.
Note the tradeoff: with CI pinned, a future stable that breaks the build
will not be noticed until the pin moves. Worth adding a non-blocking
`stable` job if that matters.
CLAUDE.md records the three places that must agree when either version
moves, and how to recheck the floor after a dependency bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second hardening pass, over the code added in 23c660d.
Error reporting:
- Decode failures now carry an excerpt of the body that arrived. The
serde message alone ("expected value at line 1 column 1") gives no hint
that the response was an HTML error page from a proxy or a login
redirect from a wrong base URL. The excerpt is collapsed to one line
and truncated so a large body cannot become a large error.
- API errors with an empty or message-less body report the status reason
("Not Found") rather than rendering blank.
- `Error` is `#[non_exhaustive]`, so future variants are not breaking.
Retry bounds:
- `RetryPolicy::max_elapsed` (120s default) bounds the time one call may
spend across attempts. The request timeout is per attempt, so the
previous defaults could spend four minutes on a call that kept timing
out — a surprise for anyone reading `with_timeout(60)` as a bound on
the call.
- `backoff` uses saturating arithmetic; a large `max_delay` could
overflow the jitter span and panic in a debug build.
- Response bodies are drained before a retry so the connection returns to
the pool instead of the next attempt opening a fresh one.
Credentials:
- Test pinning that neither the token nor the password appears in `Debug`
output. `reqwest`'s `Client` prints its default headers, so the
sensitive flag set when building them is the only thing keeping the
token out of logs, and nothing enforced that.
Also drops `Option::is_none_or`, which raised the minimum toolchain to
1.82 for no benefit over a match.
91 tests, up from 80.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retry:
- `RetryPolicy` (3 retries, full-jitter exponential backoff) applied in a
new `execute` dispatch chokepoint, configurable via
`with_retry_policy`. `Retry-After` is honoured, and one longer than
`max_delay` is surfaced rather than slept through.
- What may be replayed depends on the method, not just the status. A 429
means the server refused the request without processing it, so any
method is safe. A 5xx or timeout gives no such guarantee, so only
idempotent methods are retried: this is a billing API, and replaying a
POST the server had already applied would duplicate an invoice,
payment, or email.
Response types:
- `statics`, `search`, and `health_check` return `Statics`,
`SearchResults`, and `HealthCheck` instead of being generic.
`HealthCheck` covers both deployments — hosted returns a bare message
rather than a report — and `SearchResults` covers both the
Elasticsearch and non-Elasticsearch bucket sets.
- Each keeps the `extra` convention, and `statics_as` / `search_as` /
`health_check_as` remain for pinning shapes left untyped. Nested config
blobs (gateways, templates, extensions) stay `Value`, same reasoning as
`Company::settings`.
- `send_email` previously decoded the response as a bare body, but the
controller returns `itemResponse(...)` — a `data`-wrapped entity. It now
unwraps, so `T` is the entity. Still generic, since the endpoint serves
invoices, quotes, credits, and purchase orders.
- `chart` stays generic on purpose: the eleven charts share no response
shape.
- Adds `id_as_string`, since `/statics` returns `payment_types.id` as a
string and the other tables return numbers.
Adds CHANGELOG.md (Keep a Changelog), and a CLAUDE.md note to keep it
current. 17 new tests, 43 in client_requests.rs total.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>