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
|
### 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`,
|
- E-invoicing, seventeen methods: `validate_einvoice`,
|
||||||
`einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`,
|
`einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`,
|
||||||
`einvoice_delivery_map`, `update_einvoice_token`, and eleven `peppol_*`.
|
`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
|
### 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
|
- `statics`, `search`, and `health_check` return concrete types instead of
|
||||||
being generic over `T: DeserializeOwned`.
|
being generic over `T: DeserializeOwned`.
|
||||||
- `send_email` now unwraps the `data` envelope, so `T` is the entity
|
- `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
|
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
|
### Rust version
|
||||||
|
|
||||||
Two versions are pinned, and **three places must agree** when either moves:
|
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
|
## Coverage
|
||||||
|
|
||||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
**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
|
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
|
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||||
the four action-capable resources, their invitation-key PDF and e-document
|
the four action-capable resources, their invitation-key PDF and e-document
|
||||||
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
|
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
|
||||||
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
|
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
|
||||||
routes, and the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
||||||
integrations.
|
integrations, `logout`, passkey list/delete, and a standalone `login`.
|
||||||
|
|
||||||
**`GOALS.md`'s roadmap is closed** — every route an API-token client can
|
**`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:
|
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`),
|
passkey registration and passkey login (WebAuthn ceremonies only a browser
|
||||||
the client-portal family and the OAuth and provider-webhook routes (all
|
can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`,
|
||||||
outside `/api/v1`, so they don't fit the client's `base_url` or its token),
|
`/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open
|
||||||
`POST /quickbooks/settings` (a registered route with no controller method,
|
question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail
|
||||||
so it 500s), and a few company and user extras.
|
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
|
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||||
gap, and the reason for each gap.
|
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.
|
> exists or what it returns.
|
||||||
|
|
||||||
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
**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
|
## 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
|
password containing a control character cannot be a header value and goes out
|
||||||
as `X-API-PASSWORD-BASE64` instead.
|
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
|
## Per-resource extras
|
||||||
|
|
||||||
Routes that sit beside a resource's CRUD set, each with its own response
|
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`) —
|
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
||||||
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
||||||
`POST /user/{id}/reconfirm`
|
`POST /user/{id}/reconfirm`
|
||||||
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
- **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
|
||||||
passkey routes
|
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
|
- **Migration** — `/migration/start`. Moves a company between instances and
|
||||||
is driven by the v4 migration tool, not by an SDK caller.
|
is driven by the v4 migration tool, not by an SDK caller.
|
||||||
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
||||||
@@ -526,7 +583,7 @@ check `routes/api.php`).
|
|||||||
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
||||||
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
||||||
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
|
`/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}`,
|
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
|
||||||
`/protected_download/{hash}`, `/confirm_purchase`,
|
`/protected_download/{hash}`, `/confirm_purchase`,
|
||||||
`/exports/preview/{hash}`, `/templates/preview/{hash}`
|
`/exports/preview/{hash}`, `/templates/preview/{hash}`
|
||||||
|
|||||||
Generated
+2
-2
@@ -528,8 +528,8 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "invoiceninjars"
|
name = "invoiceninja-sdk"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "invoiceninjars"
|
name = "invoiceninja-sdk"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
# Set by the dependency graph rather than by this crate's own code: reqwest
|
# 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
|
# 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"
|
description = "An async Rust client for the Invoice Ninja v5 API"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
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]
|
[dependencies]
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
|
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
|
comparison in `COVERAGE.md`. The divergences — JSON instead of
|
||||||
`form_params`, keeping the whole validation `errors` map, exposing bulk
|
`form_params`, keeping the whole validation `errors` map, exposing bulk
|
||||||
`download` only on PDF-rendering entities — are deliberate.
|
`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 sync/blocking API.** Async only.
|
||||||
- **No identity map, lazy relationship loading, or transparent entity
|
- **No identity map, lazy relationship loading, or transparent entity
|
||||||
caching.** Three separate refusals with one theme — the client stays
|
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
|
an API-token client can reach is now wrapped. It did land in pieces, as the
|
||||||
item predicted — import/export first, then the integrations.
|
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
|
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
|
the typing reflects that: most of these return `serde_json::Value`, because
|
||||||
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
|
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.
|
nothing to wrap until upstream adds the handler.
|
||||||
|
|
||||||
What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap
|
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
|
list explains: the passkey and SSO ceremonies and `/login/precheck`
|
||||||
family, the provider webhooks, and a few company and user extras. Keep that
|
(each needs something — a browser, a redirect — this client doesn't have),
|
||||||
file current — it names exact counts, so it goes stale quietly.
|
`/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
|
## Open questions
|
||||||
|
|
||||||
These are genuinely undecided. Deciding one is a change to this file.
|
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
|
- **Large bodies are buffered entirely into memory.** PDF downloads and CSV
|
||||||
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
||||||
its whole contents until the request finishes. Streaming would be better
|
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.
|
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
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
|
||||||
|
|
||||||
// Hosted app:
|
// Hosted app:
|
||||||
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
|
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
|
||||||
@@ -75,7 +75,7 @@ with exponential backoff. Both are configurable:
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use invoiceninjars::RetryPolicy;
|
use invoiceninja_sdk::RetryPolicy;
|
||||||
|
|
||||||
let client = InvoiceNinjaClient::hosted(token)?
|
let client = InvoiceNinjaClient::hosted(token)?
|
||||||
.with_timeout(Duration::from_secs(300))
|
.with_timeout(Duration::from_secs(300))
|
||||||
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
|
|||||||
|
|
||||||
## Coverage
|
## 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
|
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||||
(where the API offers one) `bulk`:
|
(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
|
Plus documents, entity actions and PDF downloads — by record id, or by
|
||||||
invitation key for the PDF and XML a given contact receives — reports,
|
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
|
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
|
||||||
inventory**, including everything that is deliberately *not* wrapped and why.
|
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:
|
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use invoiceninjars::FileUpload;
|
use invoiceninja_sdk::FileUpload;
|
||||||
|
|
||||||
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
|
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
|
||||||
.mime("application/pdf");
|
.mime("application/pdf");
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use invoiceninjars::models::ListParams;
|
use invoiceninja_sdk::models::ListParams;
|
||||||
use invoiceninjars::InvoiceNinjaClient;
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> invoiceninjars::Result<()> {
|
async fn main() -> invoiceninja_sdk::Result<()> {
|
||||||
let base_url =
|
let base_url =
|
||||||
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into());
|
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");
|
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use invoiceninjars::{Error, InvoiceNinjaClient};
|
use invoiceninja_sdk::{Error, InvoiceNinjaClient};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::{json, Value};
|
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.
|
/// 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 {
|
match call.await {
|
||||||
Ok(created) => {
|
Ok(created) => {
|
||||||
println!(" created {name}");
|
println!(" created {name}");
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ use std::fmt::Display;
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use invoiceninjars::models::{
|
use invoiceninja_sdk::models::{
|
||||||
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
||||||
};
|
};
|
||||||
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient};
|
use invoiceninja_sdk::{Error, FileUpload, InvoiceNinjaClient};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
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 = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
@@ -43,6 +43,14 @@
|
|||||||
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
||||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
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
|
in
|
||||||
{
|
{
|
||||||
devShells.default = shellFor rustToolchain;
|
devShells.default = shellFor rustToolchain;
|
||||||
@@ -50,5 +58,25 @@
|
|||||||
# Verifies the declared MSRV still builds:
|
# Verifies the declared MSRV still builds:
|
||||||
# nix develop .#msrv --command cargo test
|
# nix develop .#msrv --command cargo test
|
||||||
devShells.msrv = shellFor msrvToolchain;
|
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,
|
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||||
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||||
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
|
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
|
||||||
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse,
|
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse,
|
||||||
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
|
MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote,
|
||||||
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep,
|
RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription,
|
||||||
SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
||||||
|
Webhook,
|
||||||
};
|
};
|
||||||
use crate::retry::{self, RetryPolicy};
|
use crate::retry::{self, RetryPolicy};
|
||||||
use crate::upload::FileUpload;
|
use crate::upload::FileUpload;
|
||||||
@@ -63,6 +64,32 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
|
|||||||
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
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,
|
/// Standard base64 with padding. Only used for the one header that needs it,
|
||||||
/// which is not worth a dependency.
|
/// which is not worth a dependency.
|
||||||
fn base64(bytes: &[u8]) -> String {
|
fn base64(bytes: &[u8]) -> String {
|
||||||
@@ -135,25 +162,7 @@ pub struct InvoiceNinjaClient {
|
|||||||
|
|
||||||
impl InvoiceNinjaClient {
|
impl InvoiceNinjaClient {
|
||||||
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
|
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
|
||||||
let base_url = base_url.into();
|
let base_url = validate_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 mut headers = header::HeaderMap::new();
|
let mut headers = header::HeaderMap::new();
|
||||||
let mut token_value = header::HeaderValue::from_str(&api_token.into())
|
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`.
|
/// It used to be refused locally with a misleading `InvalidToken`.
|
||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # fn main() -> invoiceninjars::Result<()> {
|
/// # fn main() -> invoiceninja_sdk::Result<()> {
|
||||||
/// use invoiceninjars::InvoiceNinjaClient;
|
/// use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
///
|
///
|
||||||
/// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())?
|
/// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())?
|
||||||
/// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?;
|
/// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?;
|
||||||
@@ -338,6 +347,76 @@ impl InvoiceNinjaClient {
|
|||||||
Self::new(HOSTED_BASE_URL, api_token)
|
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 {
|
fn url(&self, path: &str) -> String {
|
||||||
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
||||||
}
|
}
|
||||||
@@ -1367,8 +1446,8 @@ impl InvoiceNinjaClient {
|
|||||||
/// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]:
|
/// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]:
|
||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # async fn run(client: &invoiceninjars::InvoiceNinjaClient) -> invoiceninjars::Result<()> {
|
/// # async fn run(client: &invoiceninja_sdk::InvoiceNinjaClient) -> invoiceninja_sdk::Result<()> {
|
||||||
/// use invoiceninjars::models::Invoice;
|
/// use invoiceninja_sdk::models::Invoice;
|
||||||
/// use serde_json::json;
|
/// use serde_json::json;
|
||||||
///
|
///
|
||||||
/// let invoice: Invoice = client
|
/// 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
|
/// Activity log, email history, and document previews. Each has a response
|
||||||
/// shape of its own (read from the upstream controllers, and for the activity
|
/// 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.
|
/// 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.
|
//! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```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(
|
//! let client = InvoiceNinjaClient::new(
|
||||||
//! "https://invoicing.co/api/v1",
|
//! "https://invoicing.co/api/v1",
|
||||||
//! std::env::var("IN_API_TOKEN").unwrap(),
|
//! std::env::var("IN_API_TOKEN").unwrap(),
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ impl ListParams {
|
|||||||
/// not cover.
|
/// not cover.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use invoiceninjars::models::ListParams;
|
/// use invoiceninja_sdk::models::ListParams;
|
||||||
///
|
///
|
||||||
/// let params = ListParams::new()
|
/// let params = ListParams::new()
|
||||||
/// .per_page(100)
|
/// .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 import_export;
|
||||||
mod invoice;
|
mod invoice;
|
||||||
mod location;
|
mod location;
|
||||||
|
mod login;
|
||||||
|
mod passkey;
|
||||||
mod payment;
|
mod payment;
|
||||||
mod payment_term;
|
mod payment_term;
|
||||||
mod product;
|
mod product;
|
||||||
@@ -58,6 +60,8 @@ pub use health::{HealthCheck, PhpVersion};
|
|||||||
pub use import_export::{ExportJob, ImportPreview};
|
pub use import_export::{ExportJob, ImportPreview};
|
||||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||||
pub use location::Location;
|
pub use location::Location;
|
||||||
|
pub use login::{LoginCompanyUser, LoginResponse};
|
||||||
|
pub use passkey::Passkey;
|
||||||
pub use payment::{Payment, PaymentInvoice};
|
pub use payment::{Payment, PaymentInvoice};
|
||||||
pub use payment_term::PaymentTerm;
|
pub use payment_term::PaymentTerm;
|
||||||
pub use product::Product;
|
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`.
|
/// question in `GOALS.md`.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use invoiceninjars::FileUpload;
|
/// use invoiceninja_sdk::FileUpload;
|
||||||
///
|
///
|
||||||
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
|
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
|
||||||
/// .mime("application/pdf");
|
/// .mime("application/pdf");
|
||||||
|
|||||||
+131
-5
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use invoiceninjars::{models::ListParams, Error, InvoiceNinjaClient};
|
use invoiceninja_sdk::{models::ListParams, Error, InvoiceNinjaClient};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use wiremock::matchers::{header, method, path, query_param};
|
use wiremock::matchers::{header, method, path, query_param};
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
@@ -547,7 +547,7 @@ async fn ping_accepts_an_empty_success_body() {
|
|||||||
|
|
||||||
// --- retry and backoff --------------------------------------------------
|
// --- retry and backoff --------------------------------------------------
|
||||||
|
|
||||||
use invoiceninjars::RetryPolicy;
|
use invoiceninja_sdk::RetryPolicy;
|
||||||
|
|
||||||
/// Fast policy so the tests do not actually sleep for seconds.
|
/// Fast policy so the tests do not actually sleep for seconds.
|
||||||
fn quick_retry() -> RetryPolicy {
|
fn quick_retry() -> RetryPolicy {
|
||||||
@@ -751,7 +751,7 @@ async fn retried_requests_keep_their_body_and_headers() {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let body = invoiceninjars::models::Client {
|
let body = invoiceninja_sdk::models::Client {
|
||||||
name: "Acme".into(),
|
name: "Acme".into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -965,7 +965,7 @@ async fn send_email_unwraps_the_data_envelope() {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let invoice: invoiceninjars::models::Invoice = client(&server)
|
let invoice: invoiceninja_sdk::models::Invoice = client(&server)
|
||||||
.send_email(&json!({ "entity": "invoice", "entity_id": "inv1" }))
|
.send_email(&json!({ "entity": "invoice", "entity_id": "inv1" }))
|
||||||
.await
|
.await
|
||||||
.expect("should return the refreshed entity");
|
.expect("should return the refreshed entity");
|
||||||
@@ -1179,7 +1179,7 @@ async fn no_budget_means_only_max_retries_bounds_the_call() {
|
|||||||
|
|
||||||
// --- document uploads ---------------------------------------------------
|
// --- document uploads ---------------------------------------------------
|
||||||
|
|
||||||
use invoiceninjars::FileUpload;
|
use invoiceninja_sdk::FileUpload;
|
||||||
|
|
||||||
fn pdf(name: &str) -> FileUpload {
|
fn pdf(name: &str) -> FileUpload {
|
||||||
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
|
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
|
||||||
@@ -2931,3 +2931,129 @@ extra_test!(
|
|||||||
|c| c.nordigen_institutions(),
|
|c| c.nordigen_institutions(),
|
||||||
|got| assert_eq!(got[0]["id"], "BANK_X")
|
|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
|
//! A wrong *type* is deliberately not covered: that is meant to be a hard
|
||||||
//! error, so it is not something to absorb.
|
//! error, so it is not something to absorb.
|
||||||
|
|
||||||
use invoiceninjars::models::*;
|
use invoiceninja_sdk::models::*;
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -72,6 +72,9 @@ fn every_model_absorbs_a_null_or_missing_field() {
|
|||||||
Invoice,
|
Invoice,
|
||||||
InvoiceItem,
|
InvoiceItem,
|
||||||
Location,
|
Location,
|
||||||
|
LoginCompanyUser,
|
||||||
|
LoginResponse,
|
||||||
|
Passkey,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentInvoice,
|
PaymentInvoice,
|
||||||
PaymentTerm,
|
PaymentTerm,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use invoiceninjars::models::{
|
use invoiceninja_sdk::models::{
|
||||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
||||||
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
||||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project,
|
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey,
|
||||||
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults,
|
PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||||
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate,
|
||||||
Webhook,
|
User, Vendor, Webhook,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
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.industries[0].id, "3");
|
||||||
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty");
|
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::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use invoiceninjars::models::*;
|
use invoiceninja_sdk::models::*;
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user