The "auth routes" open question in GOALS.md conflated four unrelated things once the upstream routes and controllers were checked: logout and the passkey settings routes sit behind the same token_auth middleware as everything else this crate wraps, login needs a credential exchange since there's no token yet, refresh/refresh_react turned out to be the bulk sync-dump endpoint already parked as a separate open question, and connected_account is Gmail OAuth linking that belongs with Integrations. Adds logout, list_passkeys, delete_passkey (passkey registration is left out — it needs a WebAuthn ceremony a browser has to drive), and InvoiceNinjaClient::login as an associated function returning a lean LoginResponse that types only is_owner/is_admin/token and leaves the server's much larger nested payload in extra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
22 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.
GOALS.md states what the project is for, what it deliberately is not,
and what is still undecided. Read it before making a design call — several
choices in here look like oversights until you know the reasoning (the
curated model subset, the untyped chart, not retrying POST, the
divergences from the official PHP SDK). If a change would contradict a
stated goal or settle an open question, update GOALS.md in the same
change.
Development environment
This project uses Nix for toolchain management (Rust via rust-overlay):
nix develop
flake.nix also has packages.default: nix build compiles with the
pinned dev toolchain and runs the full test suite, including doctests,
inside the Nix sandbox. The crate has no [[bin]] target (see the "No
CLI" non-goal in GOALS.md), so the resulting store path is empty —
cargoInstallHook has no binary to copy out of target/release. Treat
nix build as a build-and-test gate, not a source of an installable
artifact.
Rust version
Two versions are pinned, and three places must agree when either moves:
| Version | Declared in | |
|---|---|---|
| Dev toolchain | 1.99.1 | devVersion in flake.nix, test job in ci.yml |
| MSRV | 1.88 | msrvVersion in flake.nix, rust-version in Cargo.toml, msrv job in ci.yml |
CI has a third job, stable, which tracks latest stable and runs weekly
(Mondays, via schedule) as well as on push and PR. It exists so a new
Rust release that breaks the build or fires a new clippy lint shows up
before the pin moves. Check it when bumping devVersion — if it is red,
the bump needs work first. Nothing needs to stay in sync with it.
It is advisory, but it reports red. There is deliberately no
continue-on-error, because a green check with an annotation is a signal
nobody reads. That means it must stay out of the required status checks
in branch protection — that repo setting, not anything in ci.yml, is what
keeps it from blocking merges. Expect it red occasionally through no fault
of the change under test; that is the tradeoff for it being visible.
The pinned test and msrv jobs skip scheduled runs (if: github.event_name != 'schedule'), since a pinned toolchain against a
locked dependency graph cannot change on a timer.
The MSRV is set by the dependency graph, not by this crate's code:
reqwest pulls in url → idna → idna_adapter → icu_*, and the icu
crates require 1.88. Recheck the floor after a dependency bump with:
cargo metadata --format-version 1 \
| jq -r '.packages[] | select(.rust_version) | "\(.rust_version)\t\(.name)"' \
| sort -V | tail -5
Verify a candidate MSRV actually builds before declaring it — the number above was confirmed by running the suite, not inferred:
CARGO_TARGET_DIR=/tmp/msrv-target nix develop .#msrv --command cargo test
Don't reach for a newer std API without checking it against the MSRV; a
convenience like Option::is_none_or (1.82) is rarely worth raising the
floor for.
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. Four files:
model_roundtrip.rs— the serde layer, from hand-written payloads.client_requests.rs— the request plumbing (pagination, path escaping, headers, timeouts, retry, error mapping, one test per endpoint extra) against awiremockserver on loopback.decode_tolerance.rs— for every model, serialisesDefault, then nulls and removes each field in turn and requires the result to still decode. A new model belongs in its list; a new non-Optionfield withoutnull_to_defaultfails it.real_shapes.rs— decodestests/fixtures/*.json, which are the shapes (keys and JSON types, no values) of responses captured from a real server, and checks a decode + re-serialise loses no key. It also refuses fixtures that hold any real value, and fixtures with no test.
No credentials or internet needed for any of them.
Why the fixtures matter: twice, mocks written from the upstream source
passed while a real server disagreed (system-log timestamps; the
/subscriptions/steps shape), and frequency_id and several empty-string
fields were wrong the same way. When the smoke test finds a divergence,
refresh the fixtures so it stays caught:
# 1. Fill a DISPOSABLE instance with one row of each kind (writes!).
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
cargo run --example seed_sample_data
# 2. Capture shapes (GET only). Review `git diff tests/fixtures` before committing.
IN_BASE_URL=... IN_API_TOKEN=... cargo run --example capture_shapes
A resource with no rows is skipped, so the fixture set only covers what the instance held. Bank integrations, company gateways, client gateway tokens, subscriptions, task schedulers, and recurring quotes are not captured: they need external accounts, or (recurring quotes on 5.13.50) the server's own insert fails.
Fields omitted when empty. The server runs ConvertEmptyStringsToNull,
so an unset String serialised as "" becomes NULL, which a non-null
column or an integer rule then rejects. Location.country_id,
PurchaseOrder.status_id and Webhook.format therefore carry
skip_serializing_if = "String::is_empty". If a create from a Default
model 500s or 422s on an empty field, do the same — and add the path to
OMITTED_WHEN_EMPTY in tests/real_shapes.rs and examples/smoke_test.rs,
which is deliberately a list of decisions rather than a blanket exemption.
Testing against a real server
Everything above is checked against mock servers written from the upstream
source, so it cannot catch a place where the real server disagrees with that
reading. examples/smoke_test.rs can:
# Read-only (GET requests only). IN_BASE_URL has no default, on purpose.
IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
cargo run --example smoke_test
# Adds a write tier: create a throwaway client, upload files to it, delete it.
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
cargo run --example smoke_test
Never point the write tier at a production server. The guard is that
IN_SMOKE_WRITE_HOST must equal the host in IN_BASE_URL, so a write only
reaches a server that was named explicitly. Even after its delete the write
tier leaves a gap in the client number sequence and a soft-deleted row (a
real purge needs the account password), and it may fire webhooks. Use a
disposable instance — a local VM or container — for it.
Beyond "does it decode", the read tier fetches the same rows raw and fails
if a typed row lost a key (keys kept: <resource>, names only, never
values), and the write tier creates a purchase order, location, webhook and
recurring invoice from bare Default models, since those are what broke.
The read tier also downloads an invoice by invitation key, which is there to
re-check the claim that those routes are token-authenticated; the matching
download_e_* is left out, since it renders from e-invoice settings a test
instance need not have.
With IN_API_PASSWORD set as well, a password tier merges two throwaway
clients and purges the survivor (a real, permanent delete); it was run
against a disposable 5.13.50 instance on 2026-10-24 and all four checks
pass. .github/workflows/smoke.yml runs it on demand (workflow_dispatch only,
needing IN_BASE_URL and IN_API_TOKEN secrets); the write tier runs only if
the write_host input names the instance's host.
It prints PASS/FAIL per check and never the contents of a record. The upload
wire format (POST plus _method=PUT) was read from the framework source,
then confirmed by running the write tier against a disposable Invoice Ninja
5.13.50 instance (2026-10-24): single and multi-file uploads both attached.
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.invitation_download_methods!generatesdownload_{x}_by_invitation_keyanddownload_e_{x}_by_invitation_keyfor the same four resources. Two traps, both pinned by tests: the entity segment is singular (invoice/{key}/download, the only route family in the API that is), and the routes are authenticated despite being the link a client follows — they sit inroutes/api.php'sapi/v1group undertoken_auth. The genuinely token-less invitation downloads are the separate client-portal routes inroutes/client.php, which are not under/api/v1and are not wrapped.COVERAGE.mdused to list the/api/v1eight as unwrapped for that reason, having conflated the two families; don't reintroduce the claim.upload_methods!generatesupload_{x}for the 16 resources with a/{id}/uploadroute; documents can only be created this way, becauseDocumentController::storeis an empty stub upstream (hence nocreate_document). Three things are easy to get wrong, all verified against the upstream source: the request is aPOSTwith_method=PUT(PHP does not parse multipart on a realPUT, and the spec's_method=POSTwould 405);is_publicgoes out as1/0(Laravel's strictbooleanrule rejects the string"true"); and uploads are never retried, because reqwest's multipart body is not cloneable andexecutegives an un-cloneable request exactly one attempt — a test pins this, since nothing in the types would notice if it changed. Don't "fix" any of the three.- 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. - The import, e-invoicing and integration blocks return
serde_json::Valueon purpose. They are pass-throughs from Storecove, Stripe, Yodlee or GoCardless, or arrays built for Invoice Ninja's React client; none has a documented schema and they move on the provider's release schedule. Don't "finish the job" by typing them — the same reasoning that keepschartgeneric applies, andCOVERAGE.mdrecords it as the crate's largest deliberate untyped surface.ExportJobandImportPrevieware typed because their one useful field (the URL, the hash) is the whole point of the call. - Three dispatch helpers exist only for that surface:
send_unwrappedfor aPUTorDELETEthat carries a body (peppol_remove_legal_identifieris the crate's onlyDELETEwith one),post_unwrapped_optfor a route that answers either JSON or a bare204(peppol_setup), andmultipart_unwrappedfor the import uploads, whose form field isfiles[<entity_type>]rather thandocuments[]and whose reply has nodataenvelope. All three go throughreq/executelike everything else. 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.- A missing record is a
400, not a404.Handler.phpmaps Laravel'sModelNotFoundExceptionto a 400 carryingNo query results for model [App\Models\X]., and keeps 404 for a route that does not exist or a method it does not support. So don't write a not-found check against 404 — the smoke test's post-purge assertion did, and passed only because it had never been run. Match the status together with the message.
Coverage
All 34 of the API's Route::resource groups are wrapped, plus users
(which isn't one). 375 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, 4 with invitation-key downloads, 16 with
document uploads, and 111 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, their invitation-key PDF and e-document
downloads, report, chart, search, statics, send_email,
refund_payment, ping, health_check, import/export, the 17 e-invoicing
routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
integrations, logout, passkey list/delete, and a standalone login.
GOALS.md's roadmap is closed — every route an API-token client can
reach is wrapped. What is left out is left out for a reason, not pending:
passkey registration and passkey login (WebAuthn ceremonies only a browser
can drive), SSO (oauth_login, oidc/*) and /login/precheck,
/refresh//refresh_react (the bulk sync-dump shape parked as its own open
question in GOALS.md, not an auth concept), /connected_account (Gmail
OAuth linking — an integration, not login/logout), the client-portal family
and the OAuth and provider-webhook routes (all outside /api/v1, so they
don't fit the client's base_url or its token), POST /quickbooks/settings
(a registered route with no controller method, so it 500s), and a few
company and user extras.
COVERAGE.md is the detailed inventory — every wrapped endpoint, every
gap, and the reason for each gap.
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.