`GOALS.md` listed these as "eight routes that take no auth token, so they do
not fit the authenticated request path and need a different entry point."
That was wrong on every count, and wrong in the way goal 6 exists to catch:
the premise came from the route names rather than the route file.
All eight sit inside `routes/api.php`'s `api/v1` group, under its
`token_auth` middleware, and the controllers' own OpenAPI blocks declare
`X-API-TOKEN`. So they needed no new entry point — just a macro, alongside
`entity_action_methods!` and for the same four entities. What the item had
actually described is the client-portal family in `routes/client.php`, which
is genuinely token-less, is not under `/api/v1`, and stays unwrapped for
that reason; it is now the gap entry in `COVERAGE.md`.
`download_{invoice,quote,credit,purchase_order}_by_invitation_key` returns
the PDF and `download_e_*_by_invitation_key` the XML e-document. These take
a contact's invitation key rather than a record id, so the server renders in
that contact's locale — a client with contacts in two languages gets a
different document per key, which the id-based `download_*` cannot express.
Two things here are easy to get wrong, so both are pinned by tests. The
entity segment is singular (`invoice/{key}/download`), the only route family
in the API that departs from the plural CRUD path. And an unknown key is a
400 carrying `no record found`: the controllers return that directly instead
of raising `ModelNotFoundException`, so the status matches the rule for a
missing record but the message does not.
The smoke test's read tier now downloads an invoice by invitation key,
taking the key from the `invitations` array the list route already returns.
It is there to re-check the token claim against a real server rather than
against a second reading of the source. The `download_e_*` four are left
out: they render from e-invoice settings a test instance need not have.
Also in here, unrelated: a rustfmt fix to a `match` arm in
`examples/smoke_test.rs` that was already off-format on main, so
`cargo fmt --check` — and with it CI's `test` job — was failing before this
change. It could not be split out cleanly, being in a file this change also
touches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.6 KiB
invoiceninjars
An async Rust client for the Invoice Ninja v5 API.
Requires Rust 1.88 or newer.
Pre-1.0: no API stability promise. Pin an exact version and expect breaking changes in minor releases;
CHANGELOG.mdrecords them.GOALS.mdcovers what the crate is for and what it is not.
Development happens on Gitea; the GitHub copy is a read-only mirror. Anything pushed to GitHub is overwritten by the next sync, so please open issues and pull requests on Gitea.
Development
Enter the dev shell (Rust toolchain via rust-overlay):
nix develop
The dev toolchain is pinned in flake.nix rather than tracking latest
stable, so it only moves deliberately. To check the crate still builds on
the declared minimum of 1.88:
nix develop .#msrv --command cargo test
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));
Note that the timeout applies to each attempt, not to the call as a whole;
RetryPolicy::max_elapsed (120 seconds by default) is what bounds the total.
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 — 333 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 — by record id, or by invitation key for the PDF and XML a given contact receives — 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?;
Documents are attached through the record they belong to, since that is the
only way the API creates one. Sixteen resources support it, as
upload_client, upload_invoice, upload_expense, and so on:
use invoiceninjars::FileUpload;
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
.mime("application/pdf");
// `Some(true)` makes the document visible to the client in the portal.
let expense = client.upload_expense("abc123", vec![receipt], Some(false)).await?;
Files are held in memory while uploading, and an upload is never retried, since a replay after a lost response would attach the file twice.
Reports return their raw export body (CSV for most of them). statics,
search, and health_check return typed results; chart has no common
response shape, so it decodes 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 = client.statics().await?;
Add more resources by adding a model under src/models/ and a
resource_methods! invocation in src/client.rs.