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>
240 lines
12 KiB
Markdown
240 lines
12 KiB
Markdown
# 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](https://www.invoiceninja.com/) v5 API.
|
|
|
|
## Development environment
|
|
|
|
This project uses Nix for toolchain management (Rust via `rust-overlay`):
|
|
|
|
```sh
|
|
nix develop
|
|
```
|
|
|
|
### 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` |
|
|
|
|
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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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](https://keepachangelog.com/en/1.0.0/)
|
|
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` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
|
|
`X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers
|
|
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`, `post_list`)
|
|
handle request dispatch and decode `ListResponse<T>` / `ItemResponse<T>`
|
|
envelopes, mapping non-2xx responses through `api_error` into `Error::Api`.
|
|
- Not every endpoint answers with a JSON envelope, so there are three other
|
|
helper families: `get_raw`/`post_raw` return the body as bytes (PDF streams
|
|
and CSV report exports), `get_unwrapped`/`post_unwrapped` decode a bare
|
|
JSON body with no `data` wrapper (`/statics`, `/charts/*`, `/search`), and
|
|
`get_discard` just checks the status (`/ping`).
|
|
- Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.)
|
|
are generated by the `resource_methods!` macro at the bottom of
|
|
`client.rs`, which expands to
|
|
`list`/`list_all`/`get`/`create`/`update`/`delete`/`bulk` for a given REST
|
|
path and model type. **To add a new resource**: add a model under
|
|
`src/models/`, re-export it from `src/models/mod.rs`, and add one
|
|
`resource_methods!` invocation in `client.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 to `crud_methods!` and
|
|
adds `bulk` on top. **Use `crud_methods!` directly for resources with no
|
|
`/bulk` route** — currently `client_gateway_tokens`, `companies`, and
|
|
`system_logs`. Check `routes/api.php` before assuming a resource has one;
|
|
a generated `bulk_*` against a missing route just 405s.
|
|
- Requests go through two chokepoints: `req(method, path)` builds them and is
|
|
the single place the optional `X-API-PASSWORD` header and the per-request
|
|
timeout are attached, and `execute(builder)` dispatches them and is the
|
|
single place retrying happens. **A new helper must use both** — calling
|
|
`self.http` directly would skip the header, the timeout, and the retry
|
|
policy. Only `req` and `execute` should touch `self.http`.
|
|
- **The request timeout is per attempt, not per call.** `RetryPolicy::
|
|
max_elapsed` is 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 `HeaderValue`s with
|
|
`set_sensitive(true)`.** `reqwest`'s `Client` Debug 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` — `RetryPolicy` plus 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 to `POST` on 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 the `url` crate follows decode `%2E` back to `.` before
|
|
resolving dot segments — so an escaped `..` still collapses the path. The
|
|
exceptions are `report` and `chart`, which take a multi-segment tail by
|
|
design (`chart("project_burnup/<id>", …)`) and must stay unescaped.
|
|
- `entity_action_methods!` generates three methods — `{x}_action` and
|
|
`download_{x}` for the `GET /{resource}/{id}/{action}` route, plus
|
|
`download_{plural}` for the bulk merged-PDF action. **Only invoices,
|
|
quotes, credits, and purchase orders have the action route** — don't add it
|
|
elsewhere without checking `routes/api.php`. In particular the official PHP
|
|
SDK puts bulk `download` on *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 `impl` block rather than
|
|
macro-generated, because their request and response shapes are all
|
|
different.
|
|
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
|
|
terminates on `meta.pagination.total_pages` and only falls back to the
|
|
short-page heuristic when the server sends no metadata. **The heuristic is
|
|
not sound on its own**: `BaseController` caps `per_page` at
|
|
`min(abs(per_page), 5000)`, so a page can be shorter than requested while
|
|
records remain — which is why `per_page` is clamped to 5000 before the
|
|
walk starts. `per_page(0)` falls back to the default size (it would make
|
|
the short-page check `got < 0`, which never terminates), the page counter
|
|
advances locally rather than trusting the echoed `current_page`, an empty
|
|
page always ends the walk, and a `MAX_PAGES` backstop raises
|
|
`Error::PaginationLimit` rather than quietly returning a truncated list.
|
|
- `src/models/` — one file per resource, plus `common.rs` for 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_type` field distinguishes them), so `Quote` and
|
|
`Credit` alias `Invoice` (`src/models/invoice.rs`); likewise
|
|
`RecurringQuote` aliases `RecurringInvoice`
|
|
(`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 a `get`
|
|
followed by an `update` round-trips without silently dropping data. Keep
|
|
this pattern when adding fields or new models.
|
|
- **Every non-`Option` field carries
|
|
`#[serde(default, deserialize_with = "super::common::null_to_default")]`,
|
|
not a bare `#[serde(default)]`.** `default` alone only covers a *missing*
|
|
key; a key present with an explicit `null` fails with `invalid type: null,
|
|
expected a string`. Invoice Ninja returns `null` for many
|
|
nominally-string fields, so a bare `default` would break decoding of the
|
|
whole response. `Option<T>` fields keep the plain `#[serde(default)]` —
|
|
they already map `null` to `None`, and that distinction matters (an unset
|
|
`archived_at` should stay `None`, not become `Some(0)`).
|
|
- Models deliberately cover a curated subset of each schema and let `extra`
|
|
carry the rest — `custom_value1..4` and `custom_surcharge1..4` are
|
|
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 to `extra`, since a
|
|
wrong concrete type is a hard deserialize error whereas `extra` is lossless.
|
|
- Field names and types come from Invoice Ninja's own OpenAPI spec
|
|
(`openapi/api-docs.yaml` on the `v5-stable` branch of the invoiceninja
|
|
repo). Check it before adding or retyping a field; the same name can differ
|
|
in type across resources (`frequency_id` is 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.php` in 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` — `Error` enum (`thiserror`) covering transport, JSON
|
|
decode, API error responses (with per-field validation `errors` map), and
|
|
client construction errors. `ApiErrorBody` mirrors 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](https://github.com/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's `line_items`.
|
|
The API accepts both.
|
|
- **We keep the whole `errors` map on `Error::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.
|