Updates the three coverage summaries to 325 methods so they agree, moves the
per-resource extras out of the gap list in `COVERAGE.md` now that they are
wrapped, and records why the fixtures exist and how to refresh them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
`Cargo.toml` named github.com/bryant/invoiceninjars, a guess from the
git author name that was never a real repository. It now points at the
mirror that actually exists, so the crates.io "Repository" link will
resolve.
Gitea is the source of truth and a push mirror copies every commit to
GitHub. Anything written to the GitHub copy is overwritten by the next
sync, so a merged pull request there would silently vanish. README and
GOALS.md say so, and direct issues and pull requests to Gitea.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Several decisions in here look like oversights until you know the
reasoning: the curated model subset with everything else in `extra`, the
still-generic `chart`, not retrying `POST`, the deliberate divergences
from the official PHP SDK. That reasoning was spread across code
comments and commit messages, where it is easy to miss and easy to
"fix".
GOALS.md states:
- What the crate is for: complete coverage, and not quietly losing data,
truncating results, or succeeding when it did not.
- Seven goals, each tied to a decision visible in the code, so the
rationale survives the next refactor.
- Non-goals, including no sync API, no caching or ORM layer, no
modelling of every field, and no retrying of non-idempotent requests.
- Publication posture: on crates.io, pre-1.0, no stability promise, pin
an exact version. MSRV may rise with dependencies.
- A roadmap ordering the COVERAGE.md gaps by value over effort, starting
with multipart uploads, which is the only thing blocking
`create_document`.
- Open questions, left open rather than papered over: whether auth routes
belong here at all given they are a different auth model, whether
per-chart types earn their keep, streaming for large exports, and the
untested `Error::PaginationLimit`.
README, COVERAGE.md, and CLAUDE.md link to it. No changelog entry: this
adds no behaviour, per the rule in CLAUDE.md.
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>
Expands coverage to every `Route::resource` group in the v5 API plus
`users` (265 methods), and fixes several defects in the shared request
path that the new breadth made more exposed.
Coverage:
- 25 new models, each keeping the `extra` flatten map and the
`null_to_default` convention so unmodeled fields round-trip.
- `crud_methods!` split out of `resource_methods!` for the three groups
with no `/bulk` route, where a generated `bulk_*` would only 405.
- COVERAGE.md added as the detailed inventory, verified against
`routes/api.php` on v5-stable.
Fixes, each confirmed against upstream source or an observed request:
- `list_all_*` silently truncated. `BaseController` caps page size at
`min(abs(per_page), 5000)`, so the "short page means last page" rule
stopped early and returned a partial list as if complete. Termination
now prefers `meta.pagination.total_pages` (decoded but previously
discarded), and `per_page` is clamped so the fallback stays sound.
- `per_page(0)` paged forever: the check `got < 0` has no solution. It
now falls back to the default size, with a MAX_PAGES backstop that
raises `Error::PaginationLimit` rather than quietly truncating.
- Ids were interpolated into paths raw, so a `/` or `?` in an id
retargeted the request. All sites now use `seg(id)?`.
Escaping alone is insufficient: the WHATWG rules `url` implements
decode `%2E` to `.` before resolving dot segments, so an escaped `..`
still collapses the path. Empty and dot-only ids are rejected.
- No request timeout. reqwest applies none, so a stalled connection hung
the caller indefinitely. Adds a 60s default and `with_timeout`.
- Base URL is validated at construction; a missing scheme used to 404
on every call instead of failing once.
Adds tests/client_requests.rs: 26 tests over the request plumbing
against a wiremock loopback server, including regressions for the
truncation and non-termination bugs. No network or credentials needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers Clients, Invoices, Quotes, Credits, Payments, Products, Expenses,
Projects, Tasks, and Vendors, each with list, list_all, get, create,
update, and delete, generated by the resource_methods! macro.
Every model carries a #[serde(flatten)] extra map so fields the API
returns but the crate does not model survive a get/update round-trip
instead of being silently dropped.
Includes a Nix dev shell (Rust via rust-overlay), a GitHub Actions CI
workflow running build/test/fmt/clippy, and round-trip tests for the
extra-preservation invariant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>