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>
5.0 KiB
invoiceninjars
An async Rust client for the Invoice Ninja v5 API.
Development
Enter the dev shell (Rust toolchain via rust-overlay):
nix develop
Usage
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
// Hosted app:
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
// Self-hosted — note the base URL must include the `/api/v1` suffix:
let client = InvoiceNinjaClient::new(
"https://your-domain.example/api/v1",
std::env::var("IN_API_TOKEN")?,
)?;
let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
ListParams has typed setters for the parameters every list endpoint
accepts, plus param for the per-resource filters:
let unpaid = client
.list_invoices(
&ListParams::new()
.per_page(100)
.sort("date|desc")
.param("client_id", "abc123")
.param("client_status", "unpaid"),
)
.await?;
List endpoints are paginated. list_* returns a single page; list_all_*
follows pagination and returns every matching record:
let all_clients = client.list_all_clients(&ListParams::new()).await?;
Every request carries a 60-second deadline by default, since reqwest
applies none of its own, and transient failures are retried up to 3 times
with exponential backoff. Both are configurable:
use std::time::Duration;
use invoiceninjars::RetryPolicy;
let client = InvoiceNinjaClient::hosted(token)?
.with_timeout(Duration::from_secs(300))
.with_retry_policy(RetryPolicy::new(5));
Retrying is deliberately conservative about POST. A POST that fails with
a 5xx or times out is not replayed, because the server may have applied
it before the response was lost and a repeat would create a second invoice
or payment. A 429 is retried for any method, since the server states it
refused the request without processing it. Pass RetryPolicy::none() to
disable retrying entirely.
Run the example (requires IN_API_TOKEN, optionally IN_BASE_URL):
IN_API_TOKEN=... cargo run --example list_clients
Coverage
Every one of the API's 34 resource groups, plus users — 265 methods. Each
resource has list, list_all, get, create, update, delete, and
(where the API offers one) bulk:
| Area | Resources |
|---|---|
| Billing | Clients, Invoices, Quotes, Credits, Payments, Products, Purchase Orders |
| Recurring | Recurring Invoices, Recurring Quotes, Recurring Expenses, Subscriptions |
| Work | Projects, Tasks, Task Statuses, Task Schedulers |
| Spend | Expenses, Expense Categories, Vendors, Bank Transactions |
| Banking | Bank Integrations, Bank Transaction Rules, Company Gateways, Client Gateway Tokens |
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
Plus documents, entity actions and PDF downloads, reports, charts, search, statics, email sending, refunds, and health checks.
See COVERAGE.md for the full endpoint-by-endpoint inventory, including everything that is deliberately not wrapped and why.
Models cover a curated subset of each schema; any field the API returns that
isn't modeled explicitly (including custom_value1..4 and
custom_surcharge1..4) is preserved in that model's extra map, so a get
followed by an update round-trips without dropping data.
Beyond CRUD
Bulk actions return the affected records. They're available on every
resource the API gives a /bulk route — all but companies, system logs, and
client gateway tokens:
let archived = client.bulk_invoices("archive", &["abc123", "def456"]).await?;
Some endpoints — reading or writing an individual user, /users/bulk,
purging a client or company — sit behind the API's password_protected
middleware and need the account password on top of a valid token:
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?
.with_api_password(std::env::var("IN_API_PASSWORD")?)?;
let user = client.get_user("abc123").await?;
Invoices, quotes, credits, and purchase orders also support per-entity actions and PDF rendering:
let invoice = client.mark_invoice_sent("abc123").await?;
let pdf: Vec<u8> = client.download_invoice("abc123").await?;
// The bulk `download` action streams one merged PDF. Available for quotes,
// credits, and purchase orders too.
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
Reports return their raw export body (CSV for most of them), and the schemaless endpoints decode into any type you choose:
use serde_json::{json, Value};
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
let totals: Value = client.chart("totals_v2", &json!({})).await?;
let statics: Value = client.statics().await?;
Add more resources by adding a model under src/models/ and a
resource_methods! invocation in src/client.rs.