awkawbandClaude Opus 5 9976249210 Wrap all 34 resource groups, then harden the request plumbing
Expands coverage to every `Route::resource` group in the v5 API plus
`users` (265 methods), and fixes several defects in the shared request
path that the new breadth made more exposed.

Coverage:
- 25 new models, each keeping the `extra` flatten map and the
  `null_to_default` convention so unmodeled fields round-trip.
- `crud_methods!` split out of `resource_methods!` for the three groups
  with no `/bulk` route, where a generated `bulk_*` would only 405.
- COVERAGE.md added as the detailed inventory, verified against
  `routes/api.php` on v5-stable.

Fixes, each confirmed against upstream source or an observed request:
- `list_all_*` silently truncated. `BaseController` caps page size at
  `min(abs(per_page), 5000)`, so the "short page means last page" rule
  stopped early and returned a partial list as if complete. Termination
  now prefers `meta.pagination.total_pages` (decoded but previously
  discarded), and `per_page` is clamped so the fallback stays sound.
- `per_page(0)` paged forever: the check `got < 0` has no solution. It
  now falls back to the default size, with a MAX_PAGES backstop that
  raises `Error::PaginationLimit` rather than quietly truncating.
- Ids were interpolated into paths raw, so a `/` or `?` in an id
  retargeted the request. All sites now use `seg(id)?`.
  Escaping alone is insufficient: the WHATWG rules `url` implements
  decode `%2E` to `.` before resolving dot segments, so an escaped `..`
  still collapses the path. Empty and dot-only ids are rejected.
- No request timeout. reqwest applies none, so a stalled connection hung
  the caller indefinitely. Adds a 60s default and `with_timeout`.
- Base URL is validated at construction; a missing scheme used to 404
  on every call instead of failing once.

Adds tests/client_requests.rs: 26 tests over the request plumbing
against a wiremock loopback server, including regressions for the
truncation and non-termination bugs. No network or credentials needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 18:08:38 -04:00

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. Override it for slow report exports or large list_all_* walks:

use std::time::Duration;

let client = InvoiceNinjaClient::hosted(token)?.with_timeout(Duration::from_secs(300));

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.

S
Description
An async Rust client for the Invoice Ninja v5 API.
Readme MIT
922 KiB
Languages
Rust 99%
Nix 1%