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>
11 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
An async Rust client for the Invoice Ninja v5 API.
Development environment
This project uses Nix for toolchain management (Rust via rust-overlay):
nix develop
Common commands
cargo build
cargo test
cargo test <test_name> # run a single test
cargo check
cargo clippy
cargo fmt
# Run the example (requires IN_API_TOKEN, optionally IN_BASE_URL)
IN_API_TOKEN=... cargo run --example list_clients
Tests live in tests/ (integration-style, no network access required);
src/ has no #[test] modules. Two files: model_roundtrip.rs covers the
serde layer, and client_requests.rs covers the request plumbing
(pagination, path escaping, headers, timeouts, error mapping) against a
wiremock server on loopback — no credentials or internet needed.
Changelog
CHANGELOG.md follows Keep a Changelog
and semantic versioning. Update it in the same change that alters
behaviour — add an entry under ## [Unreleased], in the right section
(Added, Changed, Deprecated, Removed, Fixed, Security), creating
the section if it is not there yet. Entries describe the effect on a user of
the crate, not the implementation: say what broke or changed for a caller and
why, not which function was edited. Purely internal work (refactors, test-only
changes, doc tweaks) does not need an entry.
Architecture
src/client.rs—InvoiceNinjaClientholds areqwest::Client(with theX-Api-Tokenauth header baked in) and abase_url. Low-level helpers (get_list,get_one,post_one,put_one,delete_one,post_list) handle request dispatch and decodeListResponse<T>/ItemResponse<T>envelopes, mapping non-2xx responses throughapi_errorintoError::Api.- Not every endpoint answers with a JSON envelope, so there are three other
helper families:
get_raw/post_rawreturn the body as bytes (PDF streams and CSV report exports),get_unwrapped/post_unwrappeddecode a bare JSON body with nodatawrapper (/statics,/charts/*,/search), andget_discardjust checks the status (/ping). - Per-resource methods (
list_clients,get_invoice,create_product, etc.) are generated by theresource_methods!macro at the bottom ofclient.rs, which expands tolist/list_all/get/create/update/delete/bulkfor a given REST path and model type. To add a new resource: add a model undersrc/models/, re-export it fromsrc/models/mod.rs, and add oneresource_methods!invocation inclient.rs. Note the macro takes seven method names before the path and model — adding a resource means passing all seven. resource_methods!delegates the six CRUD methods tocrud_methods!and addsbulkon top. Usecrud_methods!directly for resources with no/bulkroute — currentlyclient_gateway_tokens,companies, andsystem_logs. Checkroutes/api.phpbefore assuming a resource has one; a generatedbulk_*against a missing route just 405s.- Requests go through two chokepoints:
req(method, path)builds them and is the single place the optionalX-API-PASSWORDheader and the per-request timeout are attached, andexecute(builder)dispatches them and is the single place retrying happens. A new helper must use both — callingself.httpdirectly would skip the header, the timeout, and the retry policy. Onlyreqandexecuteshould touchself.http. - The request timeout is per attempt, not per call.
RetryPolicy:: max_elapsedis what bounds a call overall; without it, four attempts at the 60s default would take four minutes. Keep both in mind when changing either. - Both credentials are stored as
HeaderValues withset_sensitive(true).reqwest'sClientDebug prints its default headers, so that flag is the only thing keeping the API token out of any{:?}log. Don't build either header without it; a test pins this. src/retry.rs—RetryPolicyplus the rules for what may be replayed. The method matters, not just the status. A 429 means the server refused the request without processing it, so any method is safe to retry; a 5xx or timeout gives no such guarantee, so only idempotent methods are. Don't extend retrying toPOSTon 5xx — this is a billing API, and a replayed create would duplicate an invoice or payment.- Interpolate ids into paths with
seg(id)?, never raw. It escapes the value into a single path segment and rejects ids that are empty or dot-only. Both halves matter: escaping stops a/or?in an id from retargeting the request, but escaping alone is not enough, because the WHATWG rules theurlcrate follows decode%2Eback to.before resolving dot segments — so an escaped..still collapses the path. The exceptions arereportandchart, which take a multi-segment tail by design (chart("project_burnup/<id>", …)) and must stay unescaped. entity_action_methods!generates three methods —{x}_actionanddownload_{x}for theGET /{resource}/{id}/{action}route, plusdownload_{plural}for the bulk merged-PDF action. Only invoices, quotes, credits, and purchase orders have the action route — don't add it elsewhere without checkingroutes/api.php. In particular the official PHP SDK puts bulkdownloadon every resource, which is wrong; only PDF-rendering entities support it.- Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
refunds, ping/health) are hand-written in the last
implblock rather than macro-generated, because their request and response shapes are all different. list_*returns one page;list_all_*goes throughget_all_pages, which terminates onmeta.pagination.total_pagesand only falls back to the short-page heuristic when the server sends no metadata. The heuristic is not sound on its own:BaseControllercapsper_pageatmin(abs(per_page), 5000), so a page can be shorter than requested while records remain — which is whyper_pageis clamped to 5000 before the walk starts.per_page(0)falls back to the default size (it would make the short-page checkgot < 0, which never terminates), the page counter advances locally rather than trusting the echoedcurrent_page, an empty page always ends the walk, and aMAX_PAGESbackstop raisesError::PaginationLimitrather than quietly returning a truncated list.src/models/— one file per resource, pluscommon.rsfor shared envelope/pagination types (ListParams,ListResponse,ItemResponse,Meta,Pagination).- Several endpoints share a schema, so some models are type aliases rather
than separate structs: Invoice Ninja keeps quotes and credits in the
invoices table (an
entity_typefield distinguishes them), soQuoteandCreditaliasInvoice(src/models/invoice.rs); likewiseRecurringQuotealiasesRecurringInvoice(src/models/recurring_invoice.rs), whose fields are a strict superset. Each alias still gets its own path via the macro. - Every model struct has a
#[serde(flatten)] pub extra: Map<String, Value>field. This preserves any API fields not explicitly modeled, so agetfollowed by anupdateround-trips without silently dropping data. Keep this pattern when adding fields or new models. - Every non-
Optionfield carries#[serde(default, deserialize_with = "super::common::null_to_default")], not a bare#[serde(default)].defaultalone only covers a missing key; a key present with an explicitnullfails withinvalid type: null, expected a string. Invoice Ninja returnsnullfor many nominally-string fields, so a baredefaultwould break decoding of the whole response.Option<T>fields keep the plain#[serde(default)]— they already mapnulltoNone, and that distinction matters (an unsetarchived_atshould stayNone, not becomeSome(0)). - Models deliberately cover a curated subset of each schema and let
extracarry the rest —custom_value1..4andcustom_surcharge1..4are intentionally unmodeled. Fields whose JSON type is ambiguous or inconsistent in the upstream spec (e.g.Subscription.currency_id,webhook_configuration,due_date_days) are also left toextra, since a wrong concrete type is a hard deserialize error whereasextrais lossless. - Field names and types come from Invoice Ninja's own OpenAPI spec
(
openapi/api-docs.yamlon thev5-stablebranch of the invoiceninja repo). Check it before adding or retyping a field; the same name can differ in type across resources (frequency_idis a number on recurring invoices and expenses but a string on subscriptions). - The published spec is incomplete for routes: it documents
/{id}and action paths only for clients, locations, and tags, and omits them for invoices and everything else.routes/api.phpin the same repo is the authoritative route list, and the controllers are authoritative for what a given action returns. Use the spec for schemas, the routes and controllers for endpoints. src/error.rs—Errorenum (thiserror) covering transport, JSON decode, API error responses (with per-field validationerrorsmap), and client construction errors.ApiErrorBodymirrors Invoice Ninja's error JSON shape.
Coverage
All 34 of the API's Route::resource groups are wrapped, plus users
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, and 25 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, report, chart, search, statics,
send_email, refund_payment, ping, and health_check.
COVERAGE.md is the detailed inventory — every wrapped endpoint, every
gap, and the reason for each gap. The main remaining gaps are multipart
uploads (needs a reqwest feature), a handful of per-resource extras
(clients/{id}/purge, company_gateways/{id}/test, …), and the
integration/auth routes.
Keep COVERAGE.md current when adding or removing an endpoint: it names
exact counts and route groups, so it goes stale quietly. The three coverage
summaries — here, README.md, and COVERAGE.md — should agree;
COVERAGE.md is the one with the specifics, the other two summarize and
link to it.
Relationship to the official PHP SDK
invoiceninja/sdk-php is the
first-party wrapper and a useful cross-check, but it is thinner than this
crate — each endpoint class is just a URI over a shared BaseEntity. See the
comparison table in COVERAGE.md for what each side covers.
Two divergences are deliberate and shouldn't be "fixed" to match it:
- We send JSON; it sends
form_params. Form encoding bracket-encodes nested structures, which is bad for payloads like an invoice'sline_items. The API accepts both. - We keep the whole
errorsmap onError::Api; it surfaces only the first validation message.
And two of its behaviours are bugs not to copy: it sends stream: false in
download payloads (no controller reads a stream input), and it exposes
download on every resource even though only PDF-rendering entities support
that action.