# 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 # 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. ## 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` / `ItemResponse` 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. - Every request is built through the private `req(method, path)` helper, which is the single place the optional `X-API-PASSWORD` header and the per-request timeout are attached. **Don't call `self.http` directly in a new helper** — it would silently skip both. Only `req` itself should touch `self.http`. - **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/", …)`) 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` 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` 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.