`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>
193 lines
6.6 KiB
Markdown
193 lines
6.6 KiB
Markdown
# invoiceninjars
|
|
|
|
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) 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.md` records them.
|
|
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
|
|
|
|
> **Development happens on
|
|
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja-sdk); the
|
|
> [GitHub copy](https://github.com/awkawb1/invoiceninja-sdk) 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`):
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
nix develop .#msrv --command cargo test
|
|
```
|
|
|
|
## Usage
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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`):
|
|
|
|
```sh
|
|
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](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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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`.
|