Compare commits
6
Commits
446617a728
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ac306c26 | ||
|
|
1a8e4aad26 | ||
|
|
ab696a910b | ||
|
|
fb670cbb08 | ||
|
|
48244e8390 | ||
|
|
d51de3fc63 |
@@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Logout and passkey management: `logout`, `list_passkeys`, and
|
||||
`delete_passkey`. `logout` regenerates every system-issued token on the
|
||||
whole company, not just the caller's, so it ends every session sharing
|
||||
it. Passkey *registration* is not wrapped — it needs a WebAuthn ceremony
|
||||
only a browser can drive. Adds `Passkey`.
|
||||
- `InvoiceNinjaClient::login`, an associated function (not a method, since
|
||||
there is no token yet to build a client with) that exchanges an email and
|
||||
password for an API token, optionally with a one-time password for
|
||||
two-factor accounts. Adds `LoginResponse` and `LoginCompanyUser`; the
|
||||
nested company/user/account data the server also returns is left in
|
||||
`extra` rather than modelled. Does not cover passkey login, SSO, or
|
||||
`/login/precheck`.
|
||||
|
||||
## [0.2.0] - 2026-10-25
|
||||
|
||||
### Added
|
||||
|
||||
- E-invoicing, seventeen methods: `validate_einvoice`,
|
||||
`einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`,
|
||||
`einvoice_delivery_map`, `update_einvoice_token`, and eleven `peppol_*`.
|
||||
@@ -112,6 +129,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- **The crate is renamed from `invoiceninjars` to `invoiceninja-sdk`**, to
|
||||
match the repository it has always lived in
|
||||
(`gitea.awkawb.cloud/awkawb/invoiceninja-sdk`). Depend on it as
|
||||
`invoiceninja-sdk` in `Cargo.toml`, and import it as `invoiceninja_sdk`
|
||||
(Cargo lowercases a package name's hyphens to underscores for the crate
|
||||
identifier) — every `use invoiceninjars::...` becomes
|
||||
`use invoiceninja_sdk::...`. Not yet published to crates.io, so this
|
||||
lands before the name was ever claimed there.
|
||||
- `statics`, `search`, and `health_check` return concrete types instead of
|
||||
being generic over `T: DeserializeOwned`.
|
||||
- `send_email` now unwraps the `data` envelope, so `T` is the entity
|
||||
|
||||
@@ -22,6 +22,14 @@ This project uses Nix for toolchain management (Rust via `rust-overlay`):
|
||||
nix develop
|
||||
```
|
||||
|
||||
`flake.nix` also has `packages.default`: `nix build` compiles with the
|
||||
pinned dev toolchain and runs the full test suite, including doctests,
|
||||
inside the Nix sandbox. The crate has no `[[bin]]` target (see the "No
|
||||
CLI" non-goal in `GOALS.md`), so the resulting store path is empty —
|
||||
`cargoInstallHook` has no binary to copy out of `target/release`. Treat
|
||||
`nix build` as a build-and-test gate, not a source of an installable
|
||||
artifact.
|
||||
|
||||
### Rust version
|
||||
|
||||
Two versions are pinned, and **three places must agree** when either moves:
|
||||
@@ -353,24 +361,28 @@ changes, doc tweaks) does not need an entry.
|
||||
## Coverage
|
||||
|
||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||
(which isn't one). 371 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
(which isn't one). 375 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
only, 4 with entity actions, 4 with invitation-key downloads, 16 with
|
||||
document uploads, and 107 hand-written.
|
||||
document uploads, and 111 hand-written.
|
||||
|
||||
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||
the four action-capable resources, their invitation-key PDF and e-document
|
||||
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
|
||||
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
|
||||
routes, and the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
||||
integrations.
|
||||
routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
||||
integrations, `logout`, passkey list/delete, and a standalone `login`.
|
||||
|
||||
**`GOALS.md`'s roadmap is closed** — every route an API-token client can
|
||||
reach is wrapped. What is left out is left out for a reason, not pending:
|
||||
the auth routes (a different auth model, and an open question in `GOALS.md`),
|
||||
the client-portal family and the OAuth and provider-webhook routes (all
|
||||
outside `/api/v1`, so they don't fit the client's `base_url` or its token),
|
||||
`POST /quickbooks/settings` (a registered route with no controller method,
|
||||
so it 500s), and a few company and user extras.
|
||||
passkey registration and passkey login (WebAuthn ceremonies only a browser
|
||||
can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`,
|
||||
`/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open
|
||||
question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail
|
||||
OAuth linking — an integration, not login/logout), the client-portal family
|
||||
and the OAuth and provider-webhook routes (all outside `/api/v1`, so they
|
||||
don't fit the client's `base_url` or its token), `POST /quickbooks/settings`
|
||||
(a registered route with no controller method, so it 500s), and a few
|
||||
company and user extras.
|
||||
|
||||
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||
gap, and the reason for each gap.
|
||||
|
||||
+61
-4
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
|
||||
> exists or what it returns.
|
||||
|
||||
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
||||
not a resource group). 371 methods in total.
|
||||
not a resource group). 375 methods in total.
|
||||
|
||||
## Resources with full CRUD + bulk
|
||||
|
||||
@@ -434,6 +434,60 @@ established that a purged record reads back as a **400**, not a 404. A
|
||||
password containing a control character cannot be a header value and goes out
|
||||
as `X-API-PASSWORD-BASE64` instead.
|
||||
|
||||
## Logout, passkeys, and login
|
||||
|
||||
`/login`, `/logout`, and the passkey routes used to sit together under one
|
||||
"Auth" gap. They don't share a shape: `/logout` and the passkey *settings*
|
||||
routes are plain `token_auth` endpoints like everything else this crate
|
||||
wraps, while `/login` sits in a different, unauthenticated middleware group
|
||||
because the caller has no token yet to prove who they are.
|
||||
|
||||
| Endpoint | Method | Needs an existing client? |
|
||||
| --- | --- | --- |
|
||||
| `POST /logout` | `logout` | yes |
|
||||
| `GET /settings/passkeys` | `list_passkeys` | yes |
|
||||
| `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes |
|
||||
| `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** |
|
||||
|
||||
**`logout` has a bigger blast radius than its name suggests.** The
|
||||
controller doesn't revoke the caller's token — it regenerates *every*
|
||||
`is_system` token belonging to the whole company, logging out every user
|
||||
and device that shares it, including the one making the call.
|
||||
|
||||
**Passkey registration is not wrapped.** `POST /settings/passkeys/options`
|
||||
and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a
|
||||
browser talking to a platform authenticator can complete — there is no
|
||||
attestation object for this crate to produce standalone. Listing and
|
||||
deleting an existing credential need no such ceremony, so those are the two
|
||||
operations offered.
|
||||
|
||||
**`login` returns the server's full "first load" payload** — nested
|
||||
company, clients, invoices, designs, and more under each `CompanyUser`
|
||||
entry — because it reuses the same transformer as a browser's initial page
|
||||
load. This crate does not model that nested tree; [`LoginCompanyUser`]
|
||||
types only `is_owner`, `is_admin`, and the `token` it exists to produce,
|
||||
and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`]
|
||||
is what a caller actually needs — feed it to
|
||||
[`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`].
|
||||
|
||||
`login` optionally takes a one-time password for accounts with two-factor
|
||||
auth enabled. It does not support passkey login, SSO (`oauth_login`,
|
||||
`oidc/*`), `/login/precheck`, or a self-hosted instance's optional
|
||||
`X-API-SECRET` header — each needs a ceremony, a redirect, or a header this
|
||||
single email/password call does not send.
|
||||
|
||||
`/refresh` and `/refresh_react` were in the old "Auth" bucket too, but
|
||||
they're not an authentication concept: `BaseController::refreshResponse`
|
||||
is the same bulk, `updated_at`-filtered account dump as `/login`'s first
|
||||
load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as
|
||||
a separate open question, not something to bolt onto auth — not
|
||||
implemented, for the same reason.
|
||||
|
||||
`/connected_account` (linking a Gmail OAuth account) was also filed under
|
||||
"Auth". It's an integration-linking endpoint, not login/logout, so it
|
||||
belongs with Stripe/QuickBooks under [Integrations](#integrations) above —
|
||||
also not implemented.
|
||||
|
||||
## Per-resource extras
|
||||
|
||||
Routes that sit beside a resource's CRUD set, each with its own response
|
||||
@@ -491,8 +545,11 @@ check `routes/api.php`).
|
||||
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
||||
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
||||
`POST /user/{id}/reconfirm`
|
||||
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
||||
passkey routes
|
||||
- **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
|
||||
above for what's covered (`login`, `logout`, passkey list/delete) and
|
||||
what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey
|
||||
registration and passkey login, `/refresh`, `/refresh_react`, and
|
||||
`/connected_account`
|
||||
- **Migration** — `/migration/start`. Moves a company between instances and
|
||||
is driven by the v4 migration tool, not by an SDK caller.
|
||||
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
||||
@@ -526,7 +583,7 @@ check `routes/api.php`).
|
||||
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
||||
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
||||
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
|
||||
`/smtp/check`, `/password_timeout`, `/refresh_react`, `/webcron`,
|
||||
`/smtp/check`, `/password_timeout`, `/webcron`,
|
||||
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
|
||||
`/protected_download/{hash}`, `/confirm_purchase`,
|
||||
`/exports/preview/{hash}`, `/templates/preview/{hash}`
|
||||
|
||||
Generated
+2
-2
@@ -528,8 +528,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "invoiceninjars"
|
||||
version = "0.1.0"
|
||||
name = "invoiceninja-sdk"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "invoiceninjars"
|
||||
version = "0.1.0"
|
||||
name = "invoiceninja-sdk"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
# Set by the dependency graph rather than by this crate's own code: reqwest
|
||||
# pulls in url -> idna -> idna_adapter -> icu_*, and the icu crates require
|
||||
@@ -10,6 +10,10 @@ rust-version = "1.88"
|
||||
description = "An async Rust client for the Invoice Ninja v5 API"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
||||
# Slugs checked against https://crates.io/api/v1/category_slugs — crates.io
|
||||
# rejects a publish with an unrecognised one, so these aren't freeform.
|
||||
categories = ["api-bindings", "web-programming::http-client", "asynchronous"]
|
||||
keywords = ["invoiceninja", "invoicing", "billing", "async", "sdk"]
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
|
||||
|
||||
@@ -85,6 +85,38 @@ Invoice Ninja account to contribute.
|
||||
comparison in `COVERAGE.md`. The divergences — JSON instead of
|
||||
`form_params`, keeping the whole validation `errors` map, exposing bulk
|
||||
`download` only on PDF-rendering entities — are deliberate.
|
||||
- **No CLI, and no binary at all, in this package.** A command-line client
|
||||
over this library is a reasonable thing to want — it is the obvious way to
|
||||
ask "what does this endpoint actually return", which matters here because
|
||||
the e-invoicing and integration methods are typed as
|
||||
`serde_json::Value` and were verified against mocks rather than a live
|
||||
service. It belongs in a sibling crate (`invoiceninja-sdk-cli`) in a
|
||||
workspace, not in this one, for three reasons:
|
||||
|
||||
- *Dependencies.* Every entry in `Cargo.toml` is there because the library
|
||||
needs it, and three of the five are already in the tree via `reqwest`.
|
||||
An argument parser would be the first dependency that is not
|
||||
load-bearing for the library, along with the project's first optional
|
||||
dependency and first `[features]` section.
|
||||
- *MSRV.* Not a break today — `clap` 4.6.7 declares 1.85, under this
|
||||
crate's 1.88 (checked 2026-10-25). The cost is the coupling: a second
|
||||
floor that moves on someone else's release schedule, in a project where
|
||||
three places must agree on the number and a documented procedure exists
|
||||
for rechecking it. A separate crate keeps it out of the library's graph.
|
||||
- *Versioning.* Pre-1.0 this crate tells callers to pin an exact version
|
||||
and expect breaking changes in minor releases. A CLI's flags are a
|
||||
compatibility surface too, and one version number would force the two to
|
||||
move in lockstep. `cargo install invoiceninja-sdk` silently needing
|
||||
`--features cli` is a smaller version of the same problem.
|
||||
|
||||
For ad-hoc probing, `examples/` already covers it and costs nothing:
|
||||
`capture_shapes`, `seed_sample_data` and `smoke_test` are CLI-shaped
|
||||
programs configured from environment variables, with the
|
||||
`IN_SMOKE_WRITE_HOST` guard to copy for anything that writes.
|
||||
|
||||
Same shape of decision as the `updated_at` sync engine in the open
|
||||
questions: a different product from this crate, recorded so the option is
|
||||
not forgotten.
|
||||
- **No sync/blocking API.** Async only.
|
||||
- **No identity map, lazy relationship loading, or transparent entity
|
||||
caching.** Three separate refusals with one theme — the client stays
|
||||
@@ -196,6 +228,24 @@ last item, so **the roadmap is closed**: every route in `routes/api.php` that
|
||||
an API-token client can reach is now wrapped. It did land in pieces, as the
|
||||
item predicted — import/export first, then the integrations.
|
||||
|
||||
**Done:** the auth routes, once split apart — the single "auth routes"
|
||||
label had quietly merged four unrelated things. `logout` and the passkey
|
||||
list/delete routes sit behind the same `token_auth` middleware as every
|
||||
other wrapped endpoint, so they needed no new client type, just methods
|
||||
(and a documented warning that `logout` rotates every token on the whole
|
||||
company, not just the caller's). `login` *does* need a different entry
|
||||
point — there is no token yet to build an `InvoiceNinjaClient` with — but
|
||||
that turned out to be a standalone associated function, not a second
|
||||
client type: `InvoiceNinjaClient::login` trades an email and password for
|
||||
a `LoginResponse` carrying a token, which then goes to `new` or `hosted`
|
||||
as normal. Passkey *registration* and passkey *login* stay unwrapped
|
||||
because they're WebAuthn ceremonies a browser has to drive; `/refresh` and
|
||||
`/refresh_react` turned out to be the bulk `updated_at` sync dump already
|
||||
parked below, not an auth concept, so they stay out for that reason
|
||||
instead; and `/connected_account` (Gmail OAuth linking) was filed under
|
||||
the wrong heading — it belongs with the Integrations done item above. See
|
||||
`COVERAGE.md`'s "Logout, passkeys, and login" section for the detail.
|
||||
|
||||
As the item also predicted, it is the least SDK-shaped part of the API, and
|
||||
the typing reflects that: most of these return `serde_json::Value`, because
|
||||
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
|
||||
@@ -212,22 +262,41 @@ a registered route with no controller method behind it, so it 500s — there is
|
||||
nothing to wrap until upstream adds the handler.
|
||||
|
||||
What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap
|
||||
list explains: the auth routes (an open question below), the client-portal
|
||||
family, the provider webhooks, and a few company and user extras. Keep that
|
||||
file current — it names exact counts, so it goes stale quietly.
|
||||
list explains: the passkey and SSO ceremonies and `/login/precheck`
|
||||
(each needs something — a browser, a redirect — this client doesn't have),
|
||||
`/refresh`/`/refresh_react` (the sync-engine shape parked below), the
|
||||
client-portal family, the provider webhooks, and a few company and user
|
||||
extras. Keep that file current — it names exact counts, so it goes stale
|
||||
quietly.
|
||||
|
||||
**Decided: `chart` stays generic.** The premise in the old open question —
|
||||
"eleven chart endpoints share no response shape" — undersold the problem.
|
||||
Reading `ChartController`/`ChartService` directly: the eleven routes are
|
||||
nine distinct bodies (`totals`/`totalsV2` and `chart_summary`/
|
||||
`chart_summaryV2` are pairs of the same implementation), and six of those
|
||||
nine *do* share an outer idiom — an object keyed by a company's own
|
||||
currency ids, plus a sentinel `999` key for the cross-currency aggregate.
|
||||
But the leaf rows inside that shape are raw `DB::select` results with no
|
||||
model behind them, and they don't agree with each other: within `totals()`
|
||||
alone, the `invoices` leaf carries `invoiced_amount`, `revenue` carries
|
||||
`paid_to_date`, `outstanding` carries `amount` *and* `outstanding_count`,
|
||||
and `chart_summary`'s four leaves differ again. That is goal 2's
|
||||
"ambiguous upstream type" problem — compounded by `SUM()` often coming
|
||||
back from the DB driver as a numeric string — repeated dozens of times
|
||||
over. The remaining three bodies (`cashflow_forecast`,
|
||||
`client_payment_analytics`, `project_analytics`/`project_burnup`) delegate
|
||||
to standalone service classes with no shape in common with each other or
|
||||
with the currency-keyed family, and `calculated_fields` returns whichever
|
||||
scalar or array type its ~20-way `field` match picks, so there is no fixed
|
||||
shape to type at all. Modeling the shared outer envelope would still leave
|
||||
dozens of hand-maintained, DB-column-shaped leaf structs for marginal
|
||||
benefit over a caller defining their own `T` for the one endpoint they
|
||||
use — which `chart::<T>` already lets them do.
|
||||
|
||||
## Open questions
|
||||
|
||||
These are genuinely undecided. Deciding one is a change to this file.
|
||||
|
||||
- **Auth routes** (`/login`, `/logout`, `/refresh`, passkeys) are in the gap
|
||||
list, but they are a different authentication model from the API token
|
||||
this client is built around. Including them may mean a second client type
|
||||
rather than more methods on this one. Not obviously in scope.
|
||||
- **`chart` is still generic** over `T: DeserializeOwned`, because the
|
||||
eleven chart endpoints share no response shape. Per-chart structs would
|
||||
be eleven small models of unstable payloads; unclear whether that earns
|
||||
its keep.
|
||||
- **Large bodies are buffered entirely into memory.** PDF downloads and CSV
|
||||
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
||||
its whole contents until the request finishes. Streaming would be better
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# invoiceninjars
|
||||
# invoiceninja-sdk
|
||||
|
||||
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||
|
||||
@@ -33,7 +33,7 @@ nix develop .#msrv --command cargo test
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
||||
use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
|
||||
|
||||
// Hosted app:
|
||||
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
|
||||
@@ -75,7 +75,7 @@ with exponential backoff. Both are configurable:
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
use invoiceninjars::RetryPolicy;
|
||||
use invoiceninja_sdk::RetryPolicy;
|
||||
|
||||
let client = InvoiceNinjaClient::hosted(token)?
|
||||
.with_timeout(Duration::from_secs(300))
|
||||
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
|
||||
## Coverage
|
||||
|
||||
Every one of the API's 34 resource groups, plus users — 371 methods. Each
|
||||
Every one of the API's 34 resource groups, plus users — 375 methods. Each
|
||||
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||
(where the API offers one) `bulk`:
|
||||
|
||||
@@ -116,7 +116,9 @@ resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||
|
||||
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.
|
||||
charts, search, statics, email sending, refunds, health checks, logout,
|
||||
passkey management, and a standalone `login` to exchange credentials for
|
||||
a token.
|
||||
|
||||
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
|
||||
inventory**, including everything that is deliberately *not* wrapped and why.
|
||||
@@ -164,7 +166,7 @@ only way the API creates one. Sixteen resources support it, as
|
||||
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
|
||||
|
||||
```rust
|
||||
use invoiceninjars::FileUpload;
|
||||
use invoiceninja_sdk::FileUpload;
|
||||
|
||||
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
|
||||
.mime("application/pdf");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use invoiceninjars::models::ListParams;
|
||||
use invoiceninjars::InvoiceNinjaClient;
|
||||
use invoiceninja_sdk::models::ListParams;
|
||||
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> invoiceninjars::Result<()> {
|
||||
async fn main() -> invoiceninja_sdk::Result<()> {
|
||||
let base_url =
|
||||
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into());
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
use std::future::Future;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use invoiceninjars::{Error, InvoiceNinjaClient};
|
||||
use invoiceninja_sdk::{Error, InvoiceNinjaClient};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -34,7 +34,7 @@ fn describe(error: &Error) -> String {
|
||||
}
|
||||
|
||||
/// Runs one creation, reporting the outcome and returning the new id.
|
||||
async fn step<T>(name: &str, call: impl Future<Output = invoiceninjars::Result<T>>) -> Option<T> {
|
||||
async fn step<T>(name: &str, call: impl Future<Output = invoiceninja_sdk::Result<T>>) -> Option<T> {
|
||||
match call.await {
|
||||
Ok(created) => {
|
||||
println!(" created {name}");
|
||||
|
||||
@@ -37,10 +37,10 @@ use std::fmt::Display;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use invoiceninjars::models::{
|
||||
use invoiceninja_sdk::models::{
|
||||
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
||||
};
|
||||
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient};
|
||||
use invoiceninja_sdk::{Error, FileUpload, InvoiceNinjaClient};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "invoiceninjars: a Rust client for the Invoice Ninja v5 API";
|
||||
description = "invoiceninja-sdk: a Rust client for the Invoice Ninja v5 API";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -43,6 +43,14 @@
|
||||
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
|
||||
# Built with the pinned dev toolchain, not nixpkgs' own rustc, so a
|
||||
# `nix build` matches what `nix develop` and CI compile with rather
|
||||
# than whatever stable nixpkgs happens to carry.
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = shellFor rustToolchain;
|
||||
@@ -50,5 +58,25 @@
|
||||
# Verifies the declared MSRV still builds:
|
||||
# nix develop .#msrv --command cargo test
|
||||
devShells.msrv = shellFor msrvToolchain;
|
||||
|
||||
# This is a library with no `[[bin]]` target (see the "No CLI, and
|
||||
# no binary at all" non-goal in GOALS.md), so the output directory
|
||||
# ends up empty: `cargoInstallHook` only copies binaries matching a
|
||||
# `[[bin]]` target out of `target/release`, and there are none to
|
||||
# copy. `nix build` is still meaningful as a build-and-test gate —
|
||||
# it compiles with the pinned toolchain and runs the full suite,
|
||||
# including doctests, inside the Nix sandbox (no network, matching
|
||||
# the "no credentials needed" property in CLAUDE.md) — just not as
|
||||
# a source of an installable artifact.
|
||||
packages.default = rustPlatform.buildRustPackage {
|
||||
pname = "invoiceninja-sdk";
|
||||
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version;
|
||||
src = self;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
# The test suite needs no network (see CLAUDE.md), so the default
|
||||
# `cargo test` checkPhase runs unmodified in the sandboxed build.
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+141
-27
@@ -10,10 +10,11 @@ use crate::models::{
|
||||
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
|
||||
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse,
|
||||
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
|
||||
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep,
|
||||
SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse,
|
||||
MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote,
|
||||
RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription,
|
||||
SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
||||
Webhook,
|
||||
};
|
||||
use crate::retry::{self, RetryPolicy};
|
||||
use crate::upload::FileUpload;
|
||||
@@ -63,6 +64,32 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
|
||||
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
||||
}
|
||||
|
||||
/// Validates a base URL and strips its trailing slash.
|
||||
///
|
||||
/// Shared by [`InvoiceNinjaClient::new`] and
|
||||
/// [`InvoiceNinjaClient::login`], the one other place a base URL arrives
|
||||
/// from a caller rather than from an existing client. Parsing up front
|
||||
/// means a malformed URL — most often a missing scheme, as in
|
||||
/// `invoicing.co/api/v1` — fails here instead of as a confusing per-request
|
||||
/// error.
|
||||
fn validate_base_url(base_url: String) -> Result<String> {
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Error::InvalidBaseUrl(base_url));
|
||||
}
|
||||
|
||||
let parsed = reqwest::Url::parse(trimmed)
|
||||
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(Error::InvalidBaseUrl(format!(
|
||||
"{trimmed}: expected an http or https url, got scheme `{}`",
|
||||
parsed.scheme()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(trimmed.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// Standard base64 with padding. Only used for the one header that needs it,
|
||||
/// which is not worth a dependency.
|
||||
fn base64(bytes: &[u8]) -> String {
|
||||
@@ -135,25 +162,7 @@ pub struct InvoiceNinjaClient {
|
||||
|
||||
impl InvoiceNinjaClient {
|
||||
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
|
||||
let base_url = base_url.into();
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Error::InvalidBaseUrl(base_url));
|
||||
}
|
||||
|
||||
// Parse up front rather than letting a malformed base URL surface as a
|
||||
// confusing per-request failure. A missing scheme is the common typo
|
||||
// (`invoicing.co/api/v1`), and it fails here instead of on every call.
|
||||
let parsed = reqwest::Url::parse(trimmed)
|
||||
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(Error::InvalidBaseUrl(format!(
|
||||
"{trimmed}: expected an http or https url, got scheme `{}`",
|
||||
parsed.scheme()
|
||||
)));
|
||||
}
|
||||
|
||||
let base_url = trimmed.trim_end_matches('/').to_string();
|
||||
let base_url = validate_base_url(base_url.into())?;
|
||||
|
||||
let mut headers = header::HeaderMap::new();
|
||||
let mut token_value = header::HeaderValue::from_str(&api_token.into())
|
||||
@@ -219,8 +228,8 @@ impl InvoiceNinjaClient {
|
||||
/// It used to be refused locally with a misleading `InvalidToken`.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # fn main() -> invoiceninjars::Result<()> {
|
||||
/// use invoiceninjars::InvoiceNinjaClient;
|
||||
/// # fn main() -> invoiceninja_sdk::Result<()> {
|
||||
/// use invoiceninja_sdk::InvoiceNinjaClient;
|
||||
///
|
||||
/// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())?
|
||||
/// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?;
|
||||
@@ -338,6 +347,76 @@ impl InvoiceNinjaClient {
|
||||
Self::new(HOSTED_BASE_URL, api_token)
|
||||
}
|
||||
|
||||
/// Exchanges an email and password for an API token, for the one case
|
||||
/// `new` cannot cover: there is no token yet to build a client with.
|
||||
///
|
||||
/// `POST /login` sits outside the `token_auth` middleware every other
|
||||
/// wrapped endpoint uses, so this is a standalone async function rather
|
||||
/// than a method — nothing in `self` could be used to make the request.
|
||||
/// It builds its own one-shot `reqwest::Client` and does not go through
|
||||
/// `req` or `execute`, and, like every other `POST` in this crate, is
|
||||
/// not retried on a 5xx or a timeout.
|
||||
///
|
||||
/// On success the server answers with one entry per company the account
|
||||
/// has access to, each carrying that company's token
|
||||
/// ([`LoginCompanyUser::token`](crate::models::LoginCompanyUser::token))
|
||||
/// — feed one into [`new`](Self::new) (or [`hosted`](Self::hosted)) to
|
||||
/// get a working client:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn run() -> invoiceninja_sdk::Result<()> {
|
||||
/// use invoiceninja_sdk::InvoiceNinjaClient;
|
||||
///
|
||||
/// let login = InvoiceNinjaClient::login(
|
||||
/// "https://invoicing.co/api/v1",
|
||||
/// "me@example.com",
|
||||
/// "correct-horse-battery-staple",
|
||||
/// None,
|
||||
/// )
|
||||
/// .await?;
|
||||
/// let token = &login.data[0].token.as_ref().expect("token include").token;
|
||||
/// let client = InvoiceNinjaClient::hosted(token)?;
|
||||
/// # let _ = client;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// `one_time_password` is only consulted when the account has
|
||||
/// two-factor authentication enabled; pass `None` otherwise. Passkey
|
||||
/// login, SSO (`oauth_login`, `oidc/*`), and a self-hosted instance's
|
||||
/// optional `X-API-SECRET` are not handled — each needs a ceremony or a
|
||||
/// header this function does not send, and none fits a single
|
||||
/// email/password call.
|
||||
pub async fn login(
|
||||
base_url: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
one_time_password: Option<&str>,
|
||||
) -> Result<LoginResponse> {
|
||||
let base_url = validate_base_url(base_url.to_string())?;
|
||||
|
||||
let mut body = serde_json::json!({ "email": email, "password": password });
|
||||
if let Some(otp) = one_time_password {
|
||||
body["one_time_password"] = serde_json::Value::String(otp.to_string());
|
||||
}
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.build()?;
|
||||
let response = http
|
||||
.post(format!("{base_url}/login"))
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.header(
|
||||
header::ACCEPT,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
Self::decode(response).await
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
||||
}
|
||||
@@ -1367,8 +1446,8 @@ impl InvoiceNinjaClient {
|
||||
/// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]:
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn run(client: &invoiceninjars::InvoiceNinjaClient) -> invoiceninjars::Result<()> {
|
||||
/// use invoiceninjars::models::Invoice;
|
||||
/// # async fn run(client: &invoiceninja_sdk::InvoiceNinjaClient) -> invoiceninja_sdk::Result<()> {
|
||||
/// use invoiceninja_sdk::models::Invoice;
|
||||
/// use serde_json::json;
|
||||
///
|
||||
/// let invoice: Invoice = client
|
||||
@@ -1909,6 +1988,41 @@ impl InvoiceNinjaClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout and passkey management. Unlike the credential exchange in
|
||||
/// [`login`](Self::login), these sit behind the normal `token_auth`
|
||||
/// middleware — the same one every other wrapped endpoint uses — so they
|
||||
/// are plain methods here rather than a standalone function.
|
||||
impl InvoiceNinjaClient {
|
||||
/// Regenerates every system-issued API token on the **whole company**,
|
||||
/// not just the one this client is using.
|
||||
///
|
||||
/// `LogoutController::index` looks up the company behind the caller's
|
||||
/// token and replaces every `is_system` token belonging to it with a
|
||||
/// fresh random value — one call logs out every user and every device
|
||||
/// sharing that company. The token this client was built with is among
|
||||
/// them, so a subsequent call through it will fail; building a new
|
||||
/// client with a fresh token is the only way back in.
|
||||
pub async fn logout(&self) -> Result<MessageResponse> {
|
||||
self.post_unwrapped("logout", &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
/// Lists the WebAuthn passkeys registered to the current user.
|
||||
pub async fn list_passkeys(&self) -> Result<Vec<Passkey>> {
|
||||
self.get_list("settings/passkeys", &ListParams::default())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deletes a passkey credential. The server refuses with a 403 if
|
||||
/// `id` belongs to a different user than the one the token
|
||||
/// authenticates.
|
||||
///
|
||||
/// Registering a new passkey is not wrapped — see [`Passkey`] for why.
|
||||
pub async fn delete_passkey(&self, id: &str) -> Result<()> {
|
||||
self.delete_one(&format!("settings/passkeys/{}", seg(id)?))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Activity log, email history, and document previews. Each has a response
|
||||
/// shape of its own (read from the upstream controllers, and for the activity
|
||||
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
//! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
||||
//! use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
|
||||
//!
|
||||
//! # async fn run() -> invoiceninjars::Result<()> {
|
||||
//! # async fn run() -> invoiceninja_sdk::Result<()> {
|
||||
//! let client = InvoiceNinjaClient::new(
|
||||
//! "https://invoicing.co/api/v1",
|
||||
//! std::env::var("IN_API_TOKEN").unwrap(),
|
||||
|
||||
@@ -104,7 +104,7 @@ impl ListParams {
|
||||
/// not cover.
|
||||
///
|
||||
/// ```
|
||||
/// use invoiceninjars::models::ListParams;
|
||||
/// use invoiceninja_sdk::models::ListParams;
|
||||
///
|
||||
/// let params = ListParams::new()
|
||||
/// .per_page(100)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::CompanyToken;
|
||||
|
||||
/// Response of `POST /login` (via [`crate::InvoiceNinjaClient::login`]).
|
||||
///
|
||||
/// One entry per company the account can access. The full upstream payload
|
||||
/// also nests the whole company (clients, invoices, designs, ...), the
|
||||
/// user, and the account under each entry — this crate does not model that;
|
||||
/// it stays reachable, losslessly, through [`LoginCompanyUser::extra`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub data: Vec<LoginCompanyUser>,
|
||||
}
|
||||
|
||||
/// One company membership returned by a login, carrying that company's API
|
||||
/// token.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginCompanyUser {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_owner: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_admin: bool,
|
||||
/// The token to pass to [`InvoiceNinjaClient::new`](crate::InvoiceNinjaClient::new)
|
||||
/// for this company. Only absent if the server ever omits the `token`
|
||||
/// include; every server-side `first_load` request requests it.
|
||||
#[serde(default)]
|
||||
pub token: Option<CompanyToken>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
@@ -17,6 +17,8 @@ mod health;
|
||||
mod import_export;
|
||||
mod invoice;
|
||||
mod location;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod payment;
|
||||
mod payment_term;
|
||||
mod product;
|
||||
@@ -58,6 +60,8 @@ pub use health::{HealthCheck, PhpVersion};
|
||||
pub use import_export::{ExportJob, ImportPreview};
|
||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||
pub use location::Location;
|
||||
pub use login::{LoginCompanyUser, LoginResponse};
|
||||
pub use passkey::Passkey;
|
||||
pub use payment::{Payment, PaymentInvoice};
|
||||
pub use payment_term::PaymentTerm;
|
||||
pub use product::Product;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A WebAuthn passkey credential registered against the current user.
|
||||
/// Served from `GET /settings/passkeys`.
|
||||
///
|
||||
/// Registering a new passkey is not wrapped: the server's `/settings/
|
||||
/// passkeys/options` and `/settings/passkeys` routes exist to drive a
|
||||
/// WebAuthn ceremony that only a browser, talking to its platform
|
||||
/// authenticator, can complete — there is no attestation for this crate to
|
||||
/// produce on its own. Listing and deleting an existing credential need no
|
||||
/// such ceremony, so those are the two passkey operations this crate
|
||||
/// offers.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Passkey {
|
||||
/// Hashed id — pass to [`crate::InvoiceNinjaClient::delete_passkey`] to
|
||||
/// remove this credential.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub created_at: String,
|
||||
/// Unix timestamp of the credential's last successful authentication,
|
||||
/// or `None` if it has never been used to log in.
|
||||
#[serde(default)]
|
||||
pub last_used_at: Option<i64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ use crate::error::{Error, Result};
|
||||
/// question in `GOALS.md`.
|
||||
///
|
||||
/// ```
|
||||
/// use invoiceninjars::FileUpload;
|
||||
/// use invoiceninja_sdk::FileUpload;
|
||||
///
|
||||
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
|
||||
/// .mime("application/pdf");
|
||||
|
||||
+131
-5
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use invoiceninjars::{models::ListParams, Error, InvoiceNinjaClient};
|
||||
use invoiceninja_sdk::{models::ListParams, Error, InvoiceNinjaClient};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
@@ -547,7 +547,7 @@ async fn ping_accepts_an_empty_success_body() {
|
||||
|
||||
// --- retry and backoff --------------------------------------------------
|
||||
|
||||
use invoiceninjars::RetryPolicy;
|
||||
use invoiceninja_sdk::RetryPolicy;
|
||||
|
||||
/// Fast policy so the tests do not actually sleep for seconds.
|
||||
fn quick_retry() -> RetryPolicy {
|
||||
@@ -751,7 +751,7 @@ async fn retried_requests_keep_their_body_and_headers() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let body = invoiceninjars::models::Client {
|
||||
let body = invoiceninja_sdk::models::Client {
|
||||
name: "Acme".into(),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -965,7 +965,7 @@ async fn send_email_unwraps_the_data_envelope() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let invoice: invoiceninjars::models::Invoice = client(&server)
|
||||
let invoice: invoiceninja_sdk::models::Invoice = client(&server)
|
||||
.send_email(&json!({ "entity": "invoice", "entity_id": "inv1" }))
|
||||
.await
|
||||
.expect("should return the refreshed entity");
|
||||
@@ -1179,7 +1179,7 @@ async fn no_budget_means_only_max_retries_bounds_the_call() {
|
||||
|
||||
// --- document uploads ---------------------------------------------------
|
||||
|
||||
use invoiceninjars::FileUpload;
|
||||
use invoiceninja_sdk::FileUpload;
|
||||
|
||||
fn pdf(name: &str) -> FileUpload {
|
||||
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
|
||||
@@ -2931,3 +2931,129 @@ extra_test!(
|
||||
|c| c.nordigen_institutions(),
|
||||
|got| assert_eq!(got[0]["id"], "BANK_X")
|
||||
);
|
||||
|
||||
// --- logout, passkeys, login --------------------------------------------
|
||||
|
||||
extra_test!(
|
||||
logout_posts_and_returns_the_message,
|
||||
"POST",
|
||||
"/logout",
|
||||
message(),
|
||||
|c| c.logout(),
|
||||
|got| assert_eq!(got.message, "ok")
|
||||
);
|
||||
|
||||
extra_test!(
|
||||
list_passkeys_decodes_the_data_envelope,
|
||||
"GET",
|
||||
"/settings/passkeys",
|
||||
json!({ "data": [
|
||||
{ "id": "p1", "name": "Work laptop", "created_at": "2026-01-01T00:00:00.000000Z", "last_used_at": 1700000000 },
|
||||
{ "id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z", "last_used_at": null },
|
||||
] }),
|
||||
|c| c.list_passkeys(),
|
||||
|got| {
|
||||
assert_eq!(got.len(), 2);
|
||||
assert_eq!(got[0].last_used_at, Some(1700000000));
|
||||
assert_eq!(got[1].last_used_at, None);
|
||||
}
|
||||
);
|
||||
|
||||
/// The server answers a delete with `{"message": ...}`, not an empty body,
|
||||
/// but `delete_passkey` discards it like every other `delete_*`.
|
||||
#[tokio::test]
|
||||
async fn delete_passkey_discards_the_message_body() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/settings/passkeys/p1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(message()))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
client(&server).delete_passkey("p1").await.unwrap();
|
||||
}
|
||||
|
||||
/// `login` has no client yet, so it is an associated function taking the
|
||||
/// base url directly rather than a method.
|
||||
#[tokio::test]
|
||||
async fn login_posts_credentials_and_decodes_the_company_token() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/login"))
|
||||
.and(wiremock::matchers::body_json(json!({
|
||||
"email": "me@example.com",
|
||||
"password": "correct-horse-battery-staple",
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"is_owner": true,
|
||||
"is_admin": true,
|
||||
"token": { "id": "t1", "token": "new-api-token" },
|
||||
"company": { "id": "c1" },
|
||||
}
|
||||
]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let login = InvoiceNinjaClient::login(
|
||||
&server.uri(),
|
||||
"me@example.com",
|
||||
"correct-horse-battery-staple",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login should succeed");
|
||||
|
||||
assert_eq!(login.data.len(), 1);
|
||||
let token = login.data[0].token.as_ref().expect("token include present");
|
||||
assert_eq!(token.token, "new-api-token");
|
||||
// The nested company blob is not modelled, but it is not dropped either.
|
||||
assert!(login.data[0].extra.contains_key("company"));
|
||||
}
|
||||
|
||||
/// An optional one-time password goes out alongside the credentials when
|
||||
/// the caller supplies one, and is left out entirely otherwise.
|
||||
#[tokio::test]
|
||||
async fn login_sends_one_time_password_only_when_given() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/login"))
|
||||
.and(wiremock::matchers::body_json(json!({
|
||||
"email": "me@example.com",
|
||||
"password": "pw",
|
||||
"one_time_password": "123456",
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": [] })))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
InvoiceNinjaClient::login(&server.uri(), "me@example.com", "pw", Some("123456"))
|
||||
.await
|
||||
.expect("login should succeed");
|
||||
}
|
||||
|
||||
/// A failed login surfaces the server's validation message like any other
|
||||
/// non-2xx response.
|
||||
#[tokio::test]
|
||||
async fn login_failure_is_an_api_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/login"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
|
||||
"message": "These credentials do not match our records."
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = InvoiceNinjaClient::login(&server.uri(), "me@example.com", "wrong", None)
|
||||
.await
|
||||
.expect_err("400");
|
||||
assert!(
|
||||
matches!(&err, Error::Api { status, .. } if status.as_u16() == 400),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! A wrong *type* is deliberately not covered: that is meant to be a hard
|
||||
//! error, so it is not something to absorb.
|
||||
|
||||
use invoiceninjars::models::*;
|
||||
use invoiceninja_sdk::models::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -72,6 +72,9 @@ fn every_model_absorbs_a_null_or_missing_field() {
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Location,
|
||||
LoginCompanyUser,
|
||||
LoginResponse,
|
||||
Passkey,
|
||||
Payment,
|
||||
PaymentInvoice,
|
||||
PaymentTerm,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use invoiceninjars::models::{
|
||||
use invoiceninja_sdk::models::{
|
||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
||||
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project,
|
||||
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults,
|
||||
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
||||
Webhook,
|
||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey,
|
||||
PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||
SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate,
|
||||
User, Vendor, Webhook,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -763,3 +763,42 @@ fn statics_ids_accept_both_strings_and_numbers() {
|
||||
assert_eq!(statics.industries[0].id, "3");
|
||||
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_response_types_the_token_and_preserves_the_rest() {
|
||||
let payload = json!({
|
||||
"data": [{
|
||||
"is_owner": true,
|
||||
"is_admin": true,
|
||||
"token": { "id": "t1", "token": "abc" },
|
||||
"company": { "id": "c1", "settings": {} },
|
||||
"user": { "id": "u1", "email": "me@example.com" }
|
||||
}]
|
||||
});
|
||||
|
||||
let login: LoginResponse = serde_json::from_value(payload).unwrap();
|
||||
assert!(login.data[0].is_owner);
|
||||
let token = login.data[0].token.as_ref().unwrap();
|
||||
assert_eq!(token.token, "abc");
|
||||
|
||||
let back = serde_json::to_value(&login).unwrap();
|
||||
assert_eq!(back["data"][0]["company"]["id"], "c1");
|
||||
assert_eq!(back["data"][0]["user"]["email"], "me@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passkey_round_trip_preserves_last_used_at() {
|
||||
let used = json!({
|
||||
"id": "p1", "name": "Laptop", "created_at": "2026-01-01T00:00:00.000000Z",
|
||||
"last_used_at": 1700000000
|
||||
});
|
||||
let never_used = json!({
|
||||
"id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z",
|
||||
"last_used_at": null
|
||||
});
|
||||
|
||||
let used: Passkey = serde_json::from_value(used).unwrap();
|
||||
let never_used: Passkey = serde_json::from_value(never_used).unwrap();
|
||||
assert_eq!(used.last_used_at, Some(1700000000));
|
||||
assert_eq!(never_used.last_used_at, None);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use invoiceninjars::models::*;
|
||||
use invoiceninja_sdk::models::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user