Files
invoiceninja-sdk/CLAUDE.md
T
awkawbandClaude Opus 5 23c660d2f9 Add retry with backoff, and narrow the untyped responses
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>
2026-10-23 18:18:12 -04:00

202 lines
11 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
```
## 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`.
- `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.