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>
This commit is contained in:
@@ -15,14 +15,33 @@ nix develop
|
||||
```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://invoicing.co/api/v1", // or your self-hosted base URL
|
||||
"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:
|
||||
|
||||
@@ -30,6 +49,16 @@ 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:
|
||||
|
||||
```rust
|
||||
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`):
|
||||
|
||||
```sh
|
||||
@@ -38,7 +67,74 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
|
||||
## Coverage
|
||||
|
||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
||||
and `delete`. Add more resources by adding a model under `src/models/` and a
|
||||
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](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?;
|
||||
```
|
||||
|
||||
Reports return their raw export body (CSV for most of them), and the
|
||||
schemaless endpoints decode 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: Value = client.statics().await?;
|
||||
```
|
||||
|
||||
Add more resources by adding a model under `src/models/` and a
|
||||
`resource_methods!` invocation in `src/client.rs`.
|
||||
|
||||
Reference in New Issue
Block a user