Compare commits

...
11 Commits
Author SHA1 Message Date
awkawbandClaude Opus 5 2532b83a32 Point the Gitea links at the renamed repository
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
The canonical repo is now `awkawb/invoiceninja-sdk`; the old
`awkawb/invoiceninja` path only resolves through a redirect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 22:07:47 -04:00
awkawbandClaude Opus 5 e7c9663621 Expect a 400, not a 404, for a record that is gone
Running the password tier against a disposable 5.13.50 instance for the
first time failed its last check. The purge itself was fine — the row really
was gone — but the assertion looked for a 404 and the server answers 400.

`Handler.php` maps Laravel's `ModelNotFoundException` to a 400 carrying
`No query results for model [App\Models\X].` and reserves 404 for a route
that does not exist, so a not-found check written against 404 never fires.
The assertion now matches the status together with the message, so a 400
raised for some other reason still fails, and `Error::Api` says so for
callers, who would otherwise write the same dead branch.

Also adds jq to the devshell, which the MSRV recheck in CLAUDE.md pipes
through but which was only ever resolving from the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:57:12 -04:00
awkawbandClaude Opus 5 0485272080 Document the new endpoints and the fixture workflow
Updates the three coverage summaries to 325 methods so they agree, moves the
per-resource extras out of the gap list in `COVERAGE.md` now that they are
wrapped, and records why the fixtures exist and how to refresh them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:46 -04:00
awkawbandClaude Opus 5 a7d9a2d9d6 Extend the smoke test with write and password tiers
The read tier now also fetches each row raw and fails if a typed row lost a
key, which is what catches a field the models drop.

The write tier creates a purchase order, location, webhook and recurring
invoice from bare `Default` models, since those are exactly what broke
against a real server, and exercises the uploads and the client extras. The
password tier merges two throwaway clients and purges the survivor.

Writes stay behind the existing guard: `IN_SMOKE_WRITE_HOST` must equal the
host in `IN_BASE_URL`, so a write only ever reaches a server that was named
explicitly. It prints PASS/FAIL per check and never the contents of a
record. `smoke.yml` runs it on demand only (`workflow_dispatch`), with the
write tier gated on the `write_host` input naming the instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:31 -04:00
awkawbandClaude Opus 5 46a6ea1322 Pin the response shapes captured from a real server
Twice now a mock written from the upstream source passed while a real server
disagreed — the system-log timestamps and the `/subscriptions/steps` shape —
and `frequency_id` and several empty-string fields were wrong the same way.
Reading the source harder was not going to find the next one.

`tests/fixtures/*.json` hold the *shapes* of real responses: every key, with
its JSON type, and no values at all. `real_shapes.rs` decodes each one and
checks that a decode plus re-serialise loses no key, so a field the models
silently drop now fails a test. It also refuses a fixture holding any real
value, and refuses a fixture that no test reads.

`seed_sample_data.rs` fills a disposable instance with one row of each kind
and `capture_shapes.rs` captures the shapes back out with GET requests only,
so the set can be refreshed when a divergence turns up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:17 -04:00
awkawbandClaude Opus 5 83270e8262 Check every model absorbs a null or missing field, mechanically
Invoice Ninja returns `null` for many nominally-string fields and omits
others, so one model that fails on either takes the whole response down with
it. Rather than trusting each field to carry the right serde attribute, this
serialises `Default`, then nulls and separately removes each field in turn,
and requires the result to still decode.

A wrong *type* is deliberately not covered: that is meant to be a hard
error, so it is not something to absorb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:09 -04:00
awkawbandClaude Opus 5 f616da08db Wrap the per-resource extras, activities, email history and previews
Forty-one hand-written endpoints, taking the crate from 284 methods to 325:
the per-resource extras (webhook retry, task sorting, default design,
subscription steps, the company gateway and bank integration actions, and
the client extras), the password-protected destructive actions (client and
company purges, client and vendor merges, user invite and purge), the
activity list, feed, per-entity feed and note, email history, and the six
preview routes.

The password-protected actions send `X-API-PASSWORD`, falling back to
`X-API-PASSWORD-BASE64` when the password holds a control character, which
cannot go in a header value. None of the destructive actions are replayed
on a 5xx, and neither are the notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:55:54 -04:00
awkawbandClaude Opus 5 bd2d34cae9 Correct the models against what a real server actually sends
Every one of these was wrong in a way the mock tests could not see, because
the mocks were written from the same upstream source the models were.

- `SystemLog.created_at`/`updated_at` are integer timestamps, not the date
  strings the OpenAPI spec declares.
- `frequency_id` arrives as a numeric string on recurring invoices and
  expenses, so both now decode through `int_or_numeric_string`.
- `Location.country_id`, `PurchaseOrder.status_id` and `Webhook.format` are
  omitted when empty. The server runs `ConvertEmptyStringsToNull`, so an
  unset `String` sent as `""` becomes NULL and then fails a non-null column
  or an `integer` rule — which meant a create from a `Default` model could
  not succeed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:55:46 -04:00
awkawbandClaude Opus 5 0002d32197 Add the Activity, SubscriptionStep and MessageResponse models
These are the types the per-resource extras and the activity endpoints
need, split out so they land before the code and tests that use them.

`MessageResponse` covers the `{"message": "..."}` body that the action
endpoints answer with, defaulting `message` so a reply carrying none still
decodes. `int_or_numeric_string` goes in alongside them: the spec calls
`frequency_id` a number, but a real 5.13.50 server sends the string `"5"`,
and a strict `i64` turned that into a hard decode error *after* the record
had already been created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:55:39 -04:00
awkawbandClaude Opus 5 9e7d81307d Add a smoke test for real servers, and show why transport errors happen
The suite runs against mock servers written from the upstream source, so
it cannot catch a place where a real server disagrees with that reading.
`examples/smoke_test.rs` can: it lists every resource, checks the
pagination walk against a single oversized request, probes the per_page
cap, and exercises statics, search and health_check. The upload wire
format (POST plus `_method=PUT`) was read from the framework source
rather than observed, so its write tier is the real verification of it.

It prints PASS/FAIL per check and never the contents of a record.

Writes are guarded. `IN_SMOKE_WRITE_HOST` must equal the host in
`IN_BASE_URL`, so a write can only reach a server that was named
explicitly. `IN_BASE_URL` has no default, unlike the other example,
because defaulting to the hosted app is the wrong failure for a tool
that can write. The write tier leaves a gap in the client number
sequence and a soft-deleted row even after cleanup, so it is documented
as disposable-instance only.

Running it against a dead port exposed a diagnosability gap:
`Error::Http` printed "error sending request for url (...)" and nothing
else. reqwest's Display stops at the outermost layer, and the reason
("Connection refused", a DNS failure, a TLS error) lives in `source()`.
The error now renders its whole cause chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 14:35:46 -04:00
awkawbandClaude Opus 5 e4f2373b53 Add multipart document uploads for the 16 resources that have the route
First item on the GOALS.md roadmap. `upload_{x}(id, files, is_public)`
attaches files through `/{resource}/{id}/upload` and returns the
refreshed record. Verified against upstream that all 16 controllers
answer with `itemResponse($x->fresh())`.

Three details that look wrong but are not, each checked in the upstream
source rather than taken from the published spec:

- The request is a POST carrying `_method=PUT`. The route is registered
  PUT, but PHP does not parse a multipart body on a real PUT, so the
  files never arrive. Laravel's kernel calls
  `enableHttpMethodParameterOverride()`, turning the spoofed method back
  into a PUT before routing. The spec documents POST with `_method=POST`,
  which would 405 against these routes.
- `is_public` is sent as 1/0. Laravel's `boolean` rule accepts only
  true, false, 0, 1, '0' and '1' under strict comparison, so "true"
  fails validation.
- Uploads are never retried. An upload adds a document, so it is not
  idempotent whatever its verb, and a replay after a lost response would
  attach the file twice. This falls out of reqwest's multipart body being
  un-cloneable, which nothing in the types enforces, so a test pins it
  for 500, 503 and 429.

An upload with no files, a nameless file or an unparseable MIME type is
refused locally (`Error::InvalidUpload`), since the server answers an
empty upload with a success that changed nothing.

Corrects two earlier claims:

- GOALS.md and COVERAGE.md said uploads would make `create_document`
  possible. They do not: `DocumentController::store` is an empty stub,
  and an upload is the only way a document is created. There is no
  `create_document`.
- The documented method count (265) had been stale since the typed
  `statics`/`search`/`health_check` commit, which added three `_as`
  methods without updating it. Recounted from the source: 284 = 210 +
  18 + 12 + 16 uploads + 28 hand-written.

Also fixes a README example, `let statics: Value = client.statics()`,
that stopped compiling when `statics` lost its type parameter.

`edocument/upload` is left out: it imports an expense from an
e-document rather than attaching a file, so it moves to the
import/export roadmap item.

Enabling `multipart` adds mime, mime_guess and unicase; MSRV stays 1.88,
re-verified by running the suite on it. 101 tests, up from 91.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 14:26:41 -04:00
63 changed files with 6705 additions and 113 deletions
+47
View File
@@ -0,0 +1,47 @@
name: Smoke test
# Runs `examples/smoke_test.rs` against a real Invoice Ninja instance. Manual
# only: it needs an instance the runner can reach and credentials for it, and
# the offline suite in `ci.yml` is what gates merges. This is for catching the
# places where the real server disagrees with the upstream source the mocks
# were written from.
#
# Repository secrets required: IN_BASE_URL (the API root, ending /api/v1) and
# IN_API_TOKEN. Point them at a disposable instance, never at production.
on:
workflow_dispatch:
inputs:
write_host:
description: >-
Leave empty for the read-only tier (GET requests only). To also run
the write tier, type the host of IN_BASE_URL (e.g. `localhost`). It
creates and deletes records, so the instance must be disposable.
required: false
default: ''
type: string
runner:
description: Runner label; use a self-hosted one if the instance is not public.
required: false
default: ubuntu-latest
type: string
permissions:
contents: read
jobs:
smoke:
runs-on: ${{ inputs.runner }}
steps:
- uses: actions/checkout@v4
# Same pin as `ci.yml` and `devVersion` in flake.nix.
- uses: dtolnay/rust-toolchain@1.99.1
- run: cargo run --example smoke_test
env:
IN_BASE_URL: ${{ secrets.IN_BASE_URL }}
IN_API_TOKEN: ${{ secrets.IN_API_TOKEN }}
# The program refuses to write unless this equals the host in
# IN_BASE_URL, so a mistyped host skips the write tier.
IN_SMOKE_WRITE_HOST: ${{ inputs.write_host }}
# Optional. Only used inside the write tier, where it enables the
# password tier (merge two throwaway clients, purge the survivor).
IN_API_PASSWORD: ${{ secrets.IN_API_PASSWORD }}
+59 -1
View File
@@ -9,6 +9,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Document uploads: `upload_client`, `upload_invoice`, `upload_expense`, and
thirteen more, one for each resource with an `/{id}/upload` route. They
take a `Vec<FileUpload>` and an optional `is_public`, and return the
refreshed record. Uploads are never retried, since a replay could attach
the file twice. This is the only way the API creates a document, so there
is no `create_document`. Enables reqwest's `multipart` feature, which adds
`mime`, `mime_guess`, and `unicase` and leaves the MSRV at 1.88.
- Per-resource extras, nineteen methods: `retry_webhook`, `sort_tasks`,
`set_default_design`, `set_default_client_gateway_token`,
`subscription_steps`, `check_subscription_steps`, the company-gateway
`test`/`clone`/`import_customers` trio, four bank-integration and
bank-transaction routes, and six client routes (`update_client_tax_data`,
`client_show_settings`, `client_peppol_discovery`, `check_client_vat`,
`client_vat_status`, `client_documents`). Each is a `POST` unless noted in
`COVERAGE.md`, so none is retried on a 5xx. Adds `MessageResponse` for the
`{"message": ...}` replies and `SubscriptionStep`.
- Password-protected actions, eight methods: `purge_client`, `merge_clients`,
`merge_vendors`, `purge_company`, `purge_company_save_settings`,
`invite_user`, `purge_user`, and `new_user_template`. They need
`with_api_password`, most cannot be undone, and none is retried on a 5xx.
A merge keeps the first id and folds the second into it. Checked against
the upstream controllers and mock servers; not yet against a real instance.
- Activity log, email history and previews, fourteen methods:
`list_activities`, `list_all_activities`, `activity_feed`,
`entity_activity`, `add_activity_note`, `download_activity_entity`,
`client_email_history`, `entity_email_history`, and the previews `preview`,
`preview_html`, `live_preview`, `live_design`, `preview_purchase_order` and
`live_preview_purchase_order`. Adds `Activity`, `ActivityFeedEntry` and
`ActivityRef`. Email history is returned as raw JSON. See `COVERAGE.md` for
which were confirmed against a real server and the easy mistakes (a
singular entity name in one route, a plural in the next).
- `FileUpload` and `Error::InvalidUpload`. An upload with no files, a nameless
file, or an unparseable MIME type is refused locally, because the server
answers an empty upload with a success that changed nothing.
- A declared minimum supported Rust version of **1.88**, verified by running
the test suite against it. The floor comes from the dependency graph
(`reqwest` → `url` → `idna` → `icu_*`), not from this crate's own code.
@@ -17,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
release. The advisory job fails visibly rather than silently; it is kept
non-blocking through branch protection rather than `continue-on-error`.
- Wrappers for every one of the API's 34 `Route::resource` groups, plus
`users` — 265 methods. See `COVERAGE.md` for the per-endpoint inventory.
`users` — 325 methods (including document uploads, per-resource extras, password-protected actions, and activity, email-history and preview routes). See `COVERAGE.md` for the per-endpoint inventory.
- `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why,
checked against `routes/api.php` on the `v5-stable` branch.
- Retry with full-jitter exponential backoff, configurable through
@@ -58,6 +92,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
from a proxy or a login redirect from a wrong base URL.
- API errors with an empty or message-less body now report the status reason
("Not Found") instead of rendering blank.
- `Error::Http` now renders its whole cause chain. `reqwest`'s own message
stops at "error sending request for url (...)", leaving out the part that
says why — "Connection refused", a DNS failure, a TLS handshake error — so
anyone printing the error had no way to tell which it was.
### Fixed
@@ -69,6 +107,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `list_all_*` with `per_page(0)` never terminated: the check `got < 0` has
no solution. It now falls back to the default page size, and a `MAX_PAGES`
backstop raises `Error::PaginationLimit` rather than quietly truncating.
- Creating a recurring invoice or recurring expense failed after the record
had been created: a real server returns `frequency_id` as the string `"5"`
and the field was a strict integer. It now accepts a number or a numeric
string.
- Creating a purchase order, location, or webhook from a default model was
rejected by a real server. An unset `PurchaseOrder.status_id`,
`Location.country_id`, or `Webhook.format` was sent as `""`, which the
server turns into `NULL` and then refuses (a 500 for the purchase order and
webhook, a 422 for the location). They are now left out of the request when
empty.
- `with_api_password` refused a password containing a control character (a
newline, say) with a misleading `InvalidToken` error. Such a password is
now sent base64-encoded as `X-API-PASSWORD-BASE64`, the form the server's
middleware accepts for exactly this.
- A payment whose `invoices` entry had a `null` or missing `invoice_id` or
`amount` failed to decode the whole payment. Both now default.
- `list_system_logs` failed to decode against a real server. `SystemLog`'s
`created_at` and `updated_at` were typed as strings, following the OpenAPI
spec, but the server sends integer timestamps. They are now `i64`, like
every other model.
- Requests could hang indefinitely. `reqwest` applies no timeout of its own
and none was configured.
- Ids were interpolated into request paths unescaped, so a `/`, `?`, or `#`
+108 -10
View File
@@ -84,10 +84,90 @@ IN_API_TOKEN=... cargo run --example list_clients
```
Tests live in `tests/` (integration-style, no network access required);
`src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the
serde layer, and `client_requests.rs` covers the request plumbing
(pagination, path escaping, headers, timeouts, error mapping) against a
`wiremock` server on loopback — no credentials or internet needed.
`src/` has no `#[test]` modules. Four files:
- `model_roundtrip.rs` — the serde layer, from hand-written payloads.
- `client_requests.rs` — the request plumbing (pagination, path escaping,
headers, timeouts, retry, error mapping, one test per endpoint extra)
against a `wiremock` server on loopback.
- `decode_tolerance.rs` — for every model, serialises `Default`, then nulls
and removes each field in turn and requires the result to still decode.
A new model belongs in its list; a new non-`Option` field without
`null_to_default` fails it.
- `real_shapes.rs` — decodes `tests/fixtures/*.json`, which are the *shapes*
(keys and JSON types, no values) of responses captured from a real server,
and checks a decode + re-serialise loses no key. It also refuses fixtures
that hold any real value, and fixtures with no test.
No credentials or internet needed for any of them.
**Why the fixtures matter:** twice, mocks written from the upstream source
passed while a real server disagreed (system-log timestamps; the
`/subscriptions/steps` shape), and `frequency_id` and several empty-string
fields were wrong the same way. When the smoke test finds a divergence,
refresh the fixtures so it stays caught:
```sh
# 1. Fill a DISPOSABLE instance with one row of each kind (writes!).
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
cargo run --example seed_sample_data
# 2. Capture shapes (GET only). Review `git diff tests/fixtures` before committing.
IN_BASE_URL=... IN_API_TOKEN=... cargo run --example capture_shapes
```
A resource with no rows is skipped, so the fixture set only covers what the
instance held. Bank integrations, company gateways, client gateway tokens,
subscriptions, task schedulers, and recurring quotes are not captured: they
need external accounts, or (recurring quotes on 5.13.50) the server's own
insert fails.
**Fields omitted when empty.** The server runs `ConvertEmptyStringsToNull`,
so an unset `String` serialised as `""` becomes `NULL`, which a non-null
column or an `integer` rule then rejects. `Location.country_id`,
`PurchaseOrder.status_id` and `Webhook.format` therefore carry
`skip_serializing_if = "String::is_empty"`. If a create from a `Default`
model 500s or 422s on an empty field, do the same — and add the path to
`OMITTED_WHEN_EMPTY` in `tests/real_shapes.rs` and `examples/smoke_test.rs`,
which is deliberately a list of decisions rather than a blanket exemption.
### Testing against a real server
Everything above is checked against mock servers written from the upstream
source, so it cannot catch a place where the real server disagrees with that
reading. `examples/smoke_test.rs` can:
```sh
# Read-only (GET requests only). IN_BASE_URL has no default, on purpose.
IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
cargo run --example smoke_test
# Adds a write tier: create a throwaway client, upload files to it, delete it.
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
cargo run --example smoke_test
```
**Never point the write tier at a production server.** The guard is that
`IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`, so a write only
reaches a server that was named explicitly. Even after its delete the write
tier leaves a gap in the client number sequence and a soft-deleted row (a
real purge needs the account password), and it may fire webhooks. Use a
disposable instance — a local VM or container — for it.
Beyond "does it decode", the read tier fetches the same rows raw and fails
if a typed row lost a key (`keys kept: <resource>`, names only, never
values), and the write tier creates a purchase order, location, webhook and
recurring invoice from bare `Default` models, since those are what broke.
With `IN_API_PASSWORD` set as well, a password tier merges two throwaway
clients and purges the survivor (a real, permanent delete); it was run
against a disposable 5.13.50 instance on 2026-10-24 and all four checks
pass. `.github/workflows/smoke.yml` runs it on demand (`workflow_dispatch` only,
needing `IN_BASE_URL` and `IN_API_TOKEN` secrets); the write tier runs only if
the `write_host` input names the instance's host.
It prints PASS/FAIL per check and never the contents of a record. The upload
wire format (`POST` plus `_method=PUT`) was read from the framework source,
then confirmed by running the write tier against a disposable Invoice Ninja
5.13.50 instance (2026-10-24): single and multi-file uploads both attached.
## Changelog
@@ -161,6 +241,18 @@ changes, doc tweaks) does not need an entry.
elsewhere without checking `routes/api.php`. In particular the official PHP
SDK puts bulk `download` on *every* resource, which is wrong; only
PDF-rendering entities support it.
- `upload_methods!` generates `upload_{x}` for the 16 resources with a
`/{id}/upload` route; documents can only be created this way, because
`DocumentController::store` is an empty stub upstream (hence no
`create_document`). Three things are easy to get wrong, all verified
against the upstream source: the request is a **`POST` with
`_method=PUT`** (PHP does not parse multipart on a real `PUT`, and the
spec's `_method=POST` would 405); `is_public` goes out as **`1`/`0`**
(Laravel's strict `boolean` rule rejects the string `"true"`); and uploads
are **never retried**, because reqwest's multipart body is not cloneable
and `execute` gives an un-cloneable request exactly one attempt — a test
pins this, since nothing in the types would notice if it changed. Don't
"fix" any of the three.
- Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
refunds, ping/health) are hand-written in the last `impl` block rather than
macro-generated, because their request and response shapes are all
@@ -220,22 +312,28 @@ changes, doc tweaks) does not need an entry.
decode, API error responses (with per-field validation `errors` map), and
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
JSON shape.
- **A missing record is a `400`, not a `404`.** `Handler.php` maps Laravel's
`ModelNotFoundException` to a 400 carrying `No query results for model
[App\Models\X].`, and keeps 404 for a route that does not exist or a method
it does not support. So don't write a not-found check against 404 — the
smoke test's post-purge assertion did, and passed only because it had
never been run. Match the status together with the message.
## Coverage
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, and 25 hand-written.
(which isn't one). 325 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, 16 with document uploads, and 69 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, `report`, `chart`, `search`, `statics`,
`send_email`, `refund_payment`, `ping`, and `health_check`.
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
gap, and the reason for each gap. The main remaining gaps are multipart
uploads (needs a reqwest feature), a handful of per-resource extras
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
integration/auth routes.
gap, and the reason for each gap. The main remaining gaps are the
integration and auth routes, import/export, the public invitation-key
downloads (which take no auth token, so they don't fit the client's
request path), and a few company and user extras.
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
exact counts and route groups, so it goes stale quietly. The three coverage
+156 -36
View File
@@ -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). 265 methods in total.
not a resource group). 325 methods in total.
## Resources with full CRUD + bulk
@@ -58,6 +58,10 @@ not a resource group). 265 methods in total.
| Task Schedulers | `/task_schedulers` | `TaskScheduler` |
| Tokens | `/tokens` | `CompanyToken` |
`create_recurring_quote` currently 500s against Invoice Ninja 5.13.50: the
server's own insert names a `design_id` column its `recurring_quotes` table
does not have. The request is well-formed; nothing here can work around it.
`bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and
`delete` work on all of them. An `ids` list matching nothing returns an empty
`Vec`, not an error.
@@ -123,6 +127,54 @@ same reason: the response isn't JSON.
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
## Document uploads
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
`upload_{singular}(id, files, is_public)` that returns the refreshed record:
| Resource | Method |
| --- | --- |
| Clients | `upload_client` |
| Companies | `upload_company` |
| Credits | `upload_credit` |
| Expenses | `upload_expense` |
| Group Settings | `upload_group_setting` |
| Invoices | `upload_invoice` |
| Payments | `upload_payment` |
| Products | `upload_product` |
| Projects | `upload_project` |
| Purchase Orders | `upload_purchase_order` |
| Quotes | `upload_quote` |
| Recurring Expenses | `upload_recurring_expense` |
| Recurring Invoices | `upload_recurring_invoice` |
| Recurring Quotes | `upload_recurring_quote` |
| Tasks | `upload_task` |
| Vendors | `upload_vendor` |
This is the *only* way the API creates a document:
`DocumentController::store` is an empty stub upstream, so `Document` has
`list`, `get`, `update`, `delete`, `download`, and `bulk`, but no
`create_document`, and there will not be one.
Three details that are easy to get wrong, all verified against the upstream
source rather than the published spec:
- **The request is a `POST` carrying `_method=PUT`.** The route is
registered as `PUT`, but PHP does not parse a multipart body on a `PUT`, so
the files would never arrive. Laravel's kernel turns the spoofed method back
into a `PUT` before routing. The OpenAPI spec documents `POST` with
`_method=POST`, which would 405 against these routes.
- **`is_public` is sent as `1`/`0`.** Laravel's `boolean` rule accepts only
`true`, `false`, `0`, `1`, `'0'`, and `'1'` under strict comparison, so the
string `"true"` fails validation.
- **Uploads are never retried**, even on a 429. An upload adds a document, so
it is not idempotent whatever its verb, and a replay after a lost response
would attach the file twice.
The server enforces file type and size limits; a refusal comes back as a 422
`Error::Api` with the per-field `errors` map. Files are held in memory while
uploading.
## Other wrapped endpoints
| Endpoint | Method |
@@ -164,16 +216,112 @@ the whole tail — `chart("project_burnup/<id>", &body)`.
Large report exports are queued and emailed rather than returned inline; in
that case the body is a short JSON message instead of CSV.
## Activities, email history and previews
Each has a response shape of its own. Marked *observed* where confirmed
against a 5.13.50 server.
| Endpoint | Method | Returns |
| --- | --- | --- |
| `GET /activities` | `list_activities`, `list_all_activities` | `Vec<Activity>` (*observed*) |
| `GET /activities?reactv2=1` | `activity_feed(rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
| `POST /activities/entity` | `entity_activity(entity, id, rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
| `POST /activities/notes` | `add_activity_note(entity, id, notes)` | `Activity` (*observed*) |
| `GET /activities/download_entity/{id}` | `download_activity_entity` | PDF bytes |
| `POST /emails/clientHistory/{client}` | `client_email_history` | `Vec<serde_json::Value>` |
| `POST /emails/entityHistory` | `entity_email_history` | `Vec<serde_json::Value>` |
| `POST /preview` | `preview`, `preview_html` | PDF bytes / HTML |
| `POST /live_preview` | `live_preview` | PDF bytes (*observed*) |
| `POST /live_design` | `live_design` | PDF bytes |
| `POST /preview/purchase_order` | `preview_purchase_order` | PDF bytes |
| `POST /live_preview/purchase_order` | `live_preview_purchase_order` | PDF bytes |
Things that are easy to get wrong:
- **The entity name is singular in one route and plural in the next.**
`entity_activity` takes `invoice`; `add_activity_note` takes `invoices`.
- **`rows`, not `per_page`, caps the activity log** (default 75). Pass it
through `ListParams::param`. `entity_activity` leaves `rows` out when not
given rather than sending `null`, because the server tests for the key and
a null would count as given and remove the cap.
- **The two activity shapes differ.** `Activity` carries hashed ids and a
string `activity_type_id`; the feed's `ActivityFeedEntry` carries a numeric
one and labelled references (`invoice`, `client`, … each `{label,
hashed_id}`).
- **Email history is raw JSON.** The objects mirror what the mail provider
reported, and the instance available had never sent mail, so there was no
shape to take.
- **Not yet run against a real server:** `preview`, `preview_html`, and both
purchase-order previews (they need a design object or a purchase order and a
PDF backend), and `live_design`, which answered a bare minimal request with
a 500 from the server on 5.13.50.
## Password-protected actions
Behind the `password_protected` middleware, so each needs
`with_api_password` (the server also lets a recent password-authenticated
session through without the header). Most are **irreversible**, and all are
`POST`s except one, so none is retried on a 5xx.
| Endpoint | Method | Returns |
| --- | --- | --- |
| `POST /clients/{id}/purge` | `purge_client` | `MessageResponse` |
| `POST /clients/{id}/{other}/merge` | `merge_clients(id, other)` | the surviving `Client` |
| `POST /vendors/{id}/{other}/merge` | `merge_vendors(id, other)` | the surviving `Vendor` |
| `POST /companies/purge/{id}` | `purge_company` | `MessageResponse` |
| `POST /companies/purge_save_settings/{id}` | `purge_company_save_settings` | `MessageResponse` |
| `POST /users/{id}/invite` | `invite_user` | `MessageResponse` |
| `POST /users/{id}/purge` | `purge_user` | `()` (the server answers 204) |
| `GET /users/create` | `new_user_template` | a blank `User`, nothing stored |
A merge keeps the first id and folds the second into it. The smoke test's
optional password tier (merge two throwaway clients, purge the survivor) was
run against a disposable 5.13.50 instance on 2026-10-24 and passes; it also
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.
## Per-resource extras
Routes that sit beside a resource's CRUD set, each with its own response
shape (read from the upstream controllers; the ones marked *observed* were
also confirmed against a 5.13.50 server). All are `POST` unless noted, so none
is retried on a 5xx or a timeout.
| Endpoint | Method | Returns |
| --- | --- | --- |
| `POST /webhooks/{id}/retry` | `retry_webhook` | `Webhook` |
| `POST /tasks/sort` | `sort_tasks` | `MessageResponse` |
| `POST /designs/set/default` | `set_default_design` | `MessageResponse` |
| `POST /client_gateway_tokens/{id}/setAsDefault` | `set_default_client_gateway_token` | `ClientGatewayToken` |
| `GET /subscriptions/steps` | `subscription_steps` | `Vec<SubscriptionStep>` (*observed*: an object keyed by class name, not the array the source suggests) |
| `POST /subscriptions/steps/check` | `check_subscription_steps` | `()` |
| `POST /company_gateways/{id}/test` | `test_company_gateway` | `MessageResponse` |
| `POST /company_gateways/{id}/clone` | `clone_company_gateway` | `CompanyGateway` |
| `POST /company_gateways/{id}/import_customers` | `import_company_gateway_customers` | `MessageResponse` |
| `POST /bank_integrations/refresh_accounts` | `refresh_bank_accounts` | `()` (the body is a serialised query builder) |
| `POST /bank_integrations/remove_account/{id}` | `remove_bank_account` | `BankIntegration` |
| `POST /bank_integrations/get_transactions/{id}` | `fetch_bank_transactions` | `MessageResponse` (the server ignores the id) |
| `POST /bank_transactions/match` | `match_bank_transactions` | `Vec<BankTransaction>` |
| `POST /clients/{id}/updateTaxData` | `update_client_tax_data` | `Client` |
| `POST /clients/{id}/show_settings` | `client_show_settings` | `serde_json::Value` (*observed*) |
| `POST /clients/{id}/peppol_discovery` | `client_peppol_discovery` | `serde_json::Value` |
| `POST /clients/{id}/check_vat` | `check_client_vat` | `MessageResponse` |
| `GET /clients/{id}/vat_status` | `client_vat_status` | `MessageResponse` (*observed*) |
| `POST /clients/{id}/documents` | `client_documents` | `Vec<Document>` (*observed*; one page) |
`updateTaxData` and `check_vat` call out to external tax services, so the
smoke test does not exercise them.
## Not covered
### Blocked on a dependency change
### Nothing is blocked on a dependency
**Multipart uploads** — `PUT /{resource}/{id}/upload` exists for clients,
companies, credits, expenses, group_settings, invoices, payments, products,
projects, purchase_orders, quotes, recurring_expenses, recurring_invoices,
recurring_quotes, tasks, and vendors. These need reqwest's `multipart`
feature, which isn't currently enabled. This is the main reason `Document`
has no `create_document`.
Multipart uploads used to be, and are now wrapped — see [Document
uploads](#document-uploads). One upload route is deliberately left out:
`PUT /edocument/upload` is not a document attachment. It imports an expense
from an e-document (`ExpenseController::edocument`), and is better treated as
an import endpoint alongside the others under *Import/export* below.
### Resource groups not wrapped
@@ -184,42 +332,14 @@ from `src/models/mod.rs`, and add a `resource_methods!` invocation in
`src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route —
check `routes/api.php`).
### Password-protected endpoints still unwrapped
`with_api_password` makes these reachable, but the methods themselves aren't
written yet:
- `POST /clients/{id}/purge`
- `POST /clients/{id}/{other}/merge`
- `POST /vendors/{id}/{other}/merge`
- `POST /companies/purge/{company}`,
`POST /companies/purge_save_settings/{company}`
- `POST /users/{id}/invite`, `POST /users/{id}/purge`, `GET /users/create`
### Individual endpoints not wrapped
- **Client extras** — `updateTaxData`, `show_settings`, `peppol_discovery`,
`check_vat`, `vat_status`, `documents`
- **Bank integration extras** — `refresh_accounts`, `remove_account/{id}`,
`get_transactions/{id}`, and `POST /bank_transactions/match`
- **Company gateway extras** — `test`, `clone`, `import_customers`
- **Company extras** — `GET /companies/{id}/logo`,
`POST /companies/{id}/default`, `updateOriginTaxData`,
`POST /companies/current`
- **Design extras** — `POST /designs/set/default`
- **Client gateway token extras** — `setAsDefault`
- **Task extras** — `POST /tasks/sort`
- **Webhook extras** — `POST /webhooks/{id}/retry`
- **Subscription extras** — `GET /subscriptions/steps`,
`POST /subscriptions/steps/check`
- **User extras** (not password-protected, unlike the rest of `/users`) —
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
`POST /user/{id}/reconfirm`
- **Preview** — `POST /preview`, `POST /preview/purchase_order`, live preview
- **Activities** — `GET /activities`, `POST /activities/entity`,
`POST /activities/notes`, `download_entity/{activity}`
- **Email history** — `POST /emails/clientHistory/{client}`,
`POST /emails/entityHistory`
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
passkey routes
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
Generated
+24
View File
@@ -600,6 +600,22 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.2.4"
@@ -776,6 +792,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -784,6 +801,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -1163,6 +1181,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "unicase"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-ident"
version = "1.0.27"
+1 -1
View File
@@ -12,7 +12,7 @@ license = "MIT"
repository = "https://github.com/awkawb1/invoiceninja-sdk"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
+42 -22
View File
@@ -121,7 +121,7 @@ releases. `CHANGELOG.md` records them, and breaking changes should be
legible there rather than discovered at compile time.
**Gitea is the source of truth; GitHub is a one-way mirror.** The canonical
repository is `gitea.awkawb.cloud/awkawb/invoiceninja`. A Gitea push mirror
repository is `gitea.awkawb.cloud/awkawb/invoiceninja-sdk`. A Gitea push mirror
copies every commit to <https://github.com/awkawb1/invoiceninja-sdk>, which
is what `Cargo.toml` points at. Nothing should be written to the GitHub
copy: the next sync overwrites it, so a merged pull request there would
@@ -141,27 +141,44 @@ All 34 `Route::resource` groups are wrapped. Full coverage is the target, so
`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
in order of value over effort:
1. **Multipart uploads** — 16 `PUT /{resource}/{id}/upload` routes plus
`edocument/upload`. Blocked only on enabling reqwest's `multipart`
feature. This is also what makes `create_document` possible, so it
unblocks the one obviously missing piece of an otherwise complete
resource.
2. **Per-resource extras** — mechanical, and they reuse existing patterns:
`webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`,
`client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the
client extras (`updateTaxData`, `show_settings`, `check_vat`, …), the
bank-integration and company-gateway extras.
3. **Password-protected actions** — `with_api_password` already makes these
reachable; only the methods are missing. Client purge and merge, vendor
merge, company purge, user invite and purge.
4. **Preview, activities, and email history** — distinct response shapes, so
each needs its own hand-written method and probably its own model.
**Done:** multipart document uploads, for the 16 resources that have the
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
also make `create_document` possible, which was wrong: upstream
`DocumentController::store` is an empty stub, and an upload is the *only*
way a document is created. There is no `create_document` and there will not
be one. `edocument/upload` was left out because it imports an expense from
an e-document rather than attaching a file, so it belongs with item 6.
**Done:** the per-resource extras — webhook retry, task sort, default
design, default gateway token, subscription steps, and the client,
bank-integration, and company-gateway routes (19 methods). The numbering
below keeps its original item numbers. Running the smoke test against a real
server overturned one reading of the source here: `/subscriptions/steps`
answers with an object keyed by class name, not the array the controller
suggests. That is the case for keeping the real-server tier.
**Done:** the password-protected actions — client purge and merge, vendor
merge, company purge, user invite and purge, and the blank-user template (8
methods). They are verified against the upstream controllers and mocks only;
the smoke test has a password tier for the first real-server run
(`IN_API_PASSWORD`), which has not happened yet. The claim here that
`with_api_password` already made them reachable was not quite true: a
password with a control character was refused locally.
**Done:** activities, email history and previews (14 methods, two new
models). The email history stays raw JSON, since no instance here had sent
mail to take a shape from; promoting it to a model needs a sample. Several
previews are unverified against a real server (see `COVERAGE.md`).
Still to do:
5. **Public invitation-key downloads** — eight routes that take no auth
token, so they do not fit the authenticated request path and need a
different entry point.
6. **Import/export and the integrations** — Stripe, QuickBooks, einvoice
(17 routes), Yodlee, Nordigen, calendar. The largest block and the least
SDK-shaped; worth doing last and possibly in pieces.
6. **Import/export and the integrations** — `/import`, `/export`,
`edocument/upload`, Stripe, QuickBooks, einvoice (17 routes), Yodlee,
Nordigen, calendar. The largest block and the least SDK-shaped; worth
doing last and possibly in pieces.
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
stale quietly.
@@ -178,9 +195,12 @@ These are genuinely undecided. Deciding one is a change to this file.
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 responses are buffered entirely into memory.** PDF downloads and
CSV report exports go through `Vec<u8>`. Streaming would be better for
big exports but changes those signatures.
- **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
for big files in both directions but changes those signatures — and for
uploads it would also change the retry story, which currently depends on
the multipart body being un-cloneable.
- **`Error::PaginationLimit` has no test**, because reaching it needs
10,001 mock round trips. Making `MAX_PAGES` injectable purely for a test
may be worse than the gap.
+23 -5
View File
@@ -9,7 +9,7 @@ Requires Rust 1.88 or newer.
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
> **Development happens on
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja); the
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja-sdk); the
> [GitHub copy](https://github.com/awkawb1/invoiceninja-sdk) is a read-only
> mirror.** Anything pushed to GitHub is overwritten by the next sync, so
> please open issues and pull requests on Gitea.
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage
Every one of the API's 34 resource groups, plus users — 265 methods. Each
Every one of the API's 34 resource groups, plus users — 325 methods. Each
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
(where the API offers one) `bulk`:
@@ -158,15 +158,33 @@ let pdf: Vec<u8> = client.download_invoice("abc123").await?;
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
```
Reports return their raw export body (CSV for most of them), and the
schemaless endpoints decode into any type you choose:
Documents are attached through the record they belong to, since that is the
only way the API creates one. Sixteen resources support it, as
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
```rust
use invoiceninjars::FileUpload;
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
.mime("application/pdf");
// `Some(true)` makes the document visible to the client in the portal.
let expense = client.upload_expense("abc123", vec![receipt], Some(false)).await?;
```
Files are held in memory while uploading, and an upload is never retried,
since a replay after a lost response would attach the file twice.
Reports return their raw export body (CSV for most of them). `statics`,
`search`, and `health_check` return typed results; `chart` has no common
response shape, so it decodes into any type you choose:
```rust
use serde_json::{json, Value};
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
let totals: Value = client.chart("totals_v2", &json!({})).await?;
let statics: Value = client.statics().await?;
let statics = client.statics().await?;
```
Add more resources by adding a model under `src/models/` and a
+167
View File
@@ -0,0 +1,167 @@
//! Records the *shape* of real Invoice Ninja responses as test fixtures.
//!
//! Mock servers written from the upstream source have been wrong about the
//! real thing before (system-log timestamps, `/subscriptions/steps`), so the
//! offline suite benefits from fixtures that came from a real server. This
//! writes them without writing any data: every string becomes `""`, every
//! number `0`, every bool `false`, and an array keeps only its first element.
//! What survives is the key names and the JSON types, which is what decoding
//! depends on. `tests/real_shapes.rs` refuses to pass if a fixture holds
//! anything else.
//!
//! GET requests only, so it is safe against any instance, but it still reads
//! real records in memory. Review the diff before committing.
//!
//! ```sh
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
//! cargo run --example capture_shapes
//! ```
//!
//! A resource with no rows on the instance writes nothing and is reported as
//! skipped, since an empty list says nothing about a row's shape. Populate a
//! disposable instance and re-run to capture more.
use std::fs;
use std::path::Path;
use serde_json::{json, Value};
/// Resources captured as a one-row list. The name doubles as the fixture
/// filename.
const LISTS: &[&str] = &[
"activities",
"bank_integrations",
"bank_transaction_rules",
"bank_transactions",
"client_gateway_tokens",
"clients",
"companies",
"company_gateways",
"credits",
"designs",
"documents",
"expense_categories",
"expenses",
"group_settings",
"invoices",
"locations",
"payment_terms",
"payments",
"products",
"projects",
"purchase_orders",
"quotes",
"recurring_expenses",
"recurring_invoices",
"recurring_quotes",
"subscriptions",
"system_logs",
"tags",
"task_schedulers",
"task_statuses",
"tasks",
"tax_rates",
"tokens",
"users",
"vendors",
"webhooks",
];
/// Endpoints with no `data` envelope, captured whole.
const BARE: &[(&str, &str)] = &[
("statics", "statics"),
("health_check", "health_check"),
("subscription_steps", "subscriptions/steps"),
("activity_feed", "activities?rows=1&reactv2=1"),
];
/// Replaces every value with a placeholder of the same JSON type.
fn shape(value: &Value) -> Value {
match value {
Value::Null => Value::Null,
Value::Bool(_) => json!(false),
Value::Number(n) if n.is_f64() => json!(0.0),
Value::Number(_) => json!(0),
Value::String(_) => json!(""),
// Only the first element: a list's rows share a shape, and keeping
// all of them would just be size.
Value::Array(items) => Value::Array(items.first().map(shape).into_iter().collect()),
Value::Object(map) => {
Value::Object(map.iter().map(|(k, v)| (k.clone(), shape(v))).collect())
}
}
}
#[tokio::main]
async fn main() {
let base = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
let base = base.trim_end_matches('/');
let out = Path::new("tests/fixtures");
fs::create_dir_all(out).expect("create tests/fixtures");
let http = reqwest::Client::new();
let get = |path: String| {
let http = http.clone();
let token = token.clone();
let url = format!("{base}/{path}");
async move {
let response = http
.get(url)
.header("X-Api-Token", token)
.header("Accept", "application/json")
.header("X-Requested-With", "XMLHttpRequest")
.send()
.await
.map_err(|e| e.to_string())?;
let status = response.status();
let body = response.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("HTTP {status}"));
}
serde_json::from_str::<Value>(&body).map_err(|e| e.to_string())
}
};
for name in LISTS {
// Deleted and archived rows included: on a disposable instance they
// are often all there is.
// `system_logs` answers the status filter with a 500, so fall back to
// the plain list when the filtered one fails.
let filtered = format!("{name}?per_page=1&status=active,archived,deleted");
let result = match get(filtered).await {
Ok(body) => Ok(body),
Err(_) => get(format!("{name}?per_page=1")).await,
};
match result {
Ok(body) => match body
.get("data")
.and_then(Value::as_array)
.and_then(|d| d.first())
{
Some(row) => {
write(out, name, &json!({ "data": [shape(row)] }));
println!(" captured {name}");
}
None => println!(" skipped {name}: no rows on this instance"),
},
Err(e) => println!(" FAILED {name}: {e}"),
}
}
for (name, path) in BARE {
match get((*path).to_string()).await {
Ok(body) => {
write(out, name, &shape(&body));
println!(" captured {name}");
}
Err(e) => println!(" FAILED {name}: {e}"),
}
}
}
fn write(dir: &Path, name: &str, value: &Value) {
let mut text = serde_json::to_string_pretty(value).expect("serialise fixture");
text.push('\n');
fs::write(dir.join(format!("{name}.json")), text).expect("write fixture");
}
+232
View File
@@ -0,0 +1,232 @@
//! Fills a **disposable** Invoice Ninja instance with one record of each
//! common kind, so `capture_shapes` has real rows to take shapes from.
//!
//! ```sh
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=http://localhost:8080/api/v1 \
//! IN_API_TOKEN=... cargo run --example seed_sample_data
//! ```
//!
//! **Never run this against production.** It creates records, advances
//! number sequences, and may fire webhooks. It uses the smoke test's guard:
//! `IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`. Nothing is
//! deleted afterwards; the instance is meant to be thrown away.
//!
//! Each step is independent: one that fails (a validation rule this script
//! did not anticipate) is reported and the rest carry on, with anything that
//! needed it skipped.
use std::future::Future;
use std::process::ExitCode;
use invoiceninjars::{Error, InvoiceNinjaClient};
use serde::de::DeserializeOwned;
use serde_json::{json, Value};
fn model<T: DeserializeOwned>(value: Value) -> T {
serde_json::from_value(value).expect("seed body matches the model")
}
fn describe(error: &Error) -> String {
match error {
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
_ => error.to_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> {
match call.await {
Ok(created) => {
println!(" created {name}");
Some(created)
}
Err(e) => {
println!(" FAILED {name}: {}", describe(&e));
None
}
}
}
#[tokio::main]
async fn main() -> ExitCode {
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
let host = reqwest::Url::parse(&base_url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_default();
if std::env::var("IN_SMOKE_WRITE_HOST").as_deref() != Ok(host.as_str()) {
eprintln!("refusing to write: set IN_SMOKE_WRITE_HOST={host} to confirm this instance is disposable");
return ExitCode::FAILURE;
}
let c = InvoiceNinjaClient::new(&base_url, token).expect("client");
println!("seeding {host}");
let line =
json!([{ "product_key": "seed-product", "quantity": 1, "cost": 10.0, "notes": "seed" }]);
let client_id = step(
"client",
c.create_client(&model(json!({
"name": "seed-client",
"contacts": [{ "first_name": "Seed", "last_name": "Contact", "email": "seed@example.com" }],
}))),
)
.await
.and_then(|x| x.id);
let vendor_id = step(
"vendor",
c.create_vendor(&model(json!({ "name": "seed-vendor" }))),
)
.await
.and_then(|x| x.id);
step(
"product",
c.create_product(&model(
json!({ "product_key": "seed-product", "price": 10.0 }),
)),
)
.await;
step(
"tax_rate",
c.create_tax_rate(&model(json!({ "name": "seed-tax", "rate": 5.0 }))),
)
.await;
step(
"payment_term",
c.create_payment_term(&model(json!({ "name": "seed-term", "num_days": 15 }))),
)
.await;
step(
"expense_category",
c.create_expense_category(&model(json!({ "name": "seed-category" }))),
)
.await;
step(
"tag",
c.create_tag(&model(
json!({ "name": "seed-tag", "entity_type": "client" }),
)),
)
.await;
step(
"group_setting",
c.create_group_setting(&model(json!({ "name": "seed-group" }))),
)
.await;
step(
"webhook",
c.create_webhook(&model(
json!({ "event_id": "1", "target_url": "https://example.com/seed-hook" }),
)),
)
.await;
step(
"expense",
c.create_expense(&model(
json!({ "amount": 5.0, "public_notes": "seed", "vendor_id": vendor_id }),
)),
)
.await;
step(
"recurring_expense",
c.create_recurring_expense(&model(
json!({ "amount": 5.0, "frequency_id": 5, "vendor_id": vendor_id }),
)),
)
.await;
if let Some(vendor_id) = &vendor_id {
step(
"purchase_order",
c.create_purchase_order(&model(
json!({ "vendor_id": vendor_id, "line_items": line }),
)),
)
.await;
}
let Some(client_id) = client_id else {
println!("no client, so the client-dependent records are skipped");
return ExitCode::FAILURE;
};
step(
"location",
c.create_location(&model(
json!({ "name": "seed-location", "client_id": client_id, "address1": "1 Seed St" }),
)),
)
.await;
step(
"project",
c.create_project(&model(
json!({ "name": "seed-project", "client_id": client_id }),
)),
)
.await;
step(
"task",
c.create_task(&model(
json!({ "description": "seed task", "client_id": client_id }),
)),
)
.await;
step(
"quote",
c.create_quote(&model(
json!({ "client_id": client_id, "line_items": line }),
)),
)
.await;
step(
"credit",
c.create_credit(&model(
json!({ "client_id": client_id, "line_items": line }),
)),
)
.await;
step(
"recurring_invoice",
c.create_recurring_invoice(&model(
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
)),
)
.await;
// Expected to 500 on 5.13.50: the `recurring_quotes` table has no
// `design_id` column, so the server's own insert fails. Not an SDK bug.
step(
"recurring_quote",
c.create_recurring_quote(&model(
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
)),
)
.await;
let invoice = step(
"invoice",
c.create_invoice(&model(
json!({ "client_id": client_id, "line_items": line }),
)),
)
.await;
if let Some(id) = invoice.and_then(|i| i.id) {
// A payment can only be applied to an invoice that has been sent.
if step("invoice mark_sent", c.mark_invoice_sent(&id))
.await
.is_some()
{
step(
"payment",
c.create_payment(&model(json!({
"client_id": client_id,
"amount": 5.0,
"invoices": [{ "invoice_id": id, "amount": 5.0 }],
}))),
)
.await;
}
}
ExitCode::SUCCESS
}
+861
View File
@@ -0,0 +1,861 @@
//! Smoke test against a real Invoice Ninja instance.
//!
//! The rest of the suite runs against mock servers written from the upstream
//! source, so it cannot catch a place where the real server disagrees with
//! that reading. This can. It prints PASS/FAIL per check, never the contents
//! of any record, and exits non-zero if anything failed.
//!
//! ```sh
//! # Read-only: GET requests only.
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
//! cargo run --example smoke_test
//!
//! # Adds the write tier. Naming the host is the guard: it must equal the
//! # host in IN_BASE_URL, so a write cannot reach a server you did not name.
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
//! cargo run --example smoke_test
//! ```
//!
//! The read tier also fetches the same rows raw and fails if a typed row lost
//! a key. The write tier creates a client, attaches files to it, creates a
//! purchase order, location, webhook, and recurring invoice from default
//! models, and deletes what it made.
//!
//! With `IN_API_PASSWORD` also set, a password tier runs inside the write
//! tier: it merges two throwaway clients and purges the survivor, which
//! really removes the row. Take the password from the environment, never from
//! a command line or chat.
//!
//! **Do not point the write tier at a production server.** Even after the delete that
//! leaves a gap in the client number sequence and a soft-deleted row (a real
//! purge needs the account password), and it may fire webhooks.
//!
//! `IN_BASE_URL` has no default, unlike the other example, because defaulting
//! to the hosted app is the wrong failure for a tool that can write.
use std::fmt::Display;
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
use invoiceninjars::models::{
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
};
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient};
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Default)]
struct Report {
passed: u32,
failed: u32,
}
impl Report {
fn pass(&mut self, name: &str, detail: impl Display) {
println!(" PASS {name:<34} {detail}");
self.passed += 1;
}
fn fail(&mut self, name: &str, error: impl Display) {
println!(" FAIL {name:<34} {error}");
self.failed += 1;
}
fn check(&mut self, name: &str, ok: bool, detail: impl Display) {
if ok {
self.pass(name, detail);
} else {
self.fail(name, detail);
}
}
}
/// Renders an error with its per-field validation map, which is usually what
/// explains a 422.
fn describe(error: &Error) -> String {
match error {
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
_ => error.to_string(),
}
}
/// Plain GETs for the raw JSON, so a typed result can be compared with what
/// the server actually sent. Decoding successfully proves little: a model
/// that quietly dropped a field would still decode.
struct Raw {
http: reqwest::Client,
base: String,
token: String,
}
impl Raw {
async fn rows(&self, path: &str) -> Option<Vec<Value>> {
let body: Value = self
.http
.get(format!("{}/{path}?per_page=2", self.base))
.header("X-Api-Token", &self.token)
.header("Accept", "application/json")
.header("X-Requested-With", "XMLHttpRequest")
.send()
.await
.ok()?
.json()
.await
.ok()?;
body.get("data")?.as_array().cloned()
}
}
/// Keys that are left out of a request when empty on purpose (the server
/// rejects the empty value), so an empty one is not a lost key.
const OMITTED_WHEN_EMPTY: &[&str] = &["country_id", "status_id", "format"];
/// Key paths in `raw` that `typed` no longer has.
fn lost_keys(raw: &Value, typed: &Value, prefix: &str, into: &mut Vec<String>) {
match (raw, typed) {
(Value::Object(raw), Value::Object(typed)) => {
for (key, raw_value) in raw {
match typed.get(key) {
Some(typed_value) => {
lost_keys(raw_value, typed_value, &format!("{prefix}.{key}"), into)
}
None if raw_value == "" && OMITTED_WHEN_EMPTY.contains(&key.as_str()) => {}
None => into.push(format!("{prefix}.{key}")),
}
}
}
(Value::Array(raw), Value::Array(typed)) => {
if let (Some(raw), Some(typed)) = (raw.first(), typed.first()) {
lost_keys(raw, typed, &format!("{prefix}[]"), into);
}
}
_ => {}
}
}
/// Fetches the same rows raw and requires the typed ones to keep every key.
/// Only key names are printed, never values.
async fn key_loss(report: &mut Report, raw: &Raw, path: &str, typed: &[impl Serialize]) {
let Some(raw_rows) = raw.rows(path).await else {
report.fail(
&format!("keys kept: {path}"),
"could not fetch the raw rows",
);
return;
};
let mut lost = Vec::new();
for (raw_row, typed_row) in raw_rows.iter().zip(typed) {
let typed_row = serde_json::to_value(typed_row).unwrap_or(Value::Null);
lost_keys(raw_row, &typed_row, path, &mut lost);
}
lost.sort();
lost.dedup();
report.check(
&format!("keys kept: {path}"),
lost.is_empty(),
if lost.is_empty() {
format!("{} row(s), no key lost", raw_rows.len().min(typed.len()))
} else {
format!("lost {}", lost.join(", "))
},
);
}
#[tokio::main]
async fn main() -> ExitCode {
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL (no default, on purpose)");
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
let host = host_of(&base_url);
let raw = Raw {
http: reqwest::Client::new(),
base: base_url.trim_end_matches('/').to_string(),
token: token.clone(),
};
let client = match InvoiceNinjaClient::new(&base_url, token) {
Ok(client) => client,
Err(e) => {
eprintln!("could not build client: {e}");
return ExitCode::FAILURE;
}
};
println!("target: {host}");
let mut report = Report::default();
println!("\nread-only checks");
read_checks(&client, &raw, &mut report).await;
// Empty counts as unset: CI passes the variable through even when blank.
match std::env::var("IN_SMOKE_WRITE_HOST")
.ok()
.filter(|v| !v.is_empty())
{
Some(allowed) if allowed == host => {
println!("\nwrite checks (IN_SMOKE_WRITE_HOST matches {host})");
write_checks(&client, &mut report).await;
match std::env::var("IN_API_PASSWORD")
.ok()
.filter(|v| !v.is_empty())
{
Some(password) => {
println!("\npassword checks (IN_API_PASSWORD is set)");
password_checks(&client, &mut report, password).await;
}
None => println!("\npassword checks skipped (set IN_API_PASSWORD to enable)"),
}
}
Some(allowed) => {
println!(
"\nwrite checks SKIPPED: IN_SMOKE_WRITE_HOST is `{allowed}` but the target is `{host}`"
);
}
None => println!("\nwrite checks skipped (set IN_SMOKE_WRITE_HOST to enable)"),
}
println!("\n{} passed, {} failed", report.passed, report.failed);
if report.failed == 0 {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn host_of(base_url: &str) -> String {
reqwest::Url::parse(base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_default()
}
async fn read_checks(client: &InvoiceNinjaClient, raw: &Raw, report: &mut Report) {
let small = ListParams::new().per_page(2);
// Decodes one page, then (if there are rows) checks none of their keys
// were lost. The REST path is the method name without `list_`.
macro_rules! list {
($report:expr, $name:literal, $call:expr) => {
match $call.await {
Ok(rows) => {
$report.pass($name, format!("{} row(s) decoded", rows.len()));
if !rows.is_empty() {
key_loss($report, raw, $name.trim_start_matches("list_"), &rows).await;
}
}
Err(e) => $report.fail($name, describe(&e)),
}
};
}
match client.ping().await {
Ok(()) => report.pass("ping", "authenticated"),
Err(e) => report.fail("ping", describe(&e)),
}
// Hosted returns a bare message instead of a report; both must decode.
match client.health_check().await {
Ok(health) => report.pass(
"health_check",
match health.system_health {
Some(ok) => format!("self-hosted report, system_health={ok}"),
None => "bare message (no report on this deployment)".to_string(),
},
),
Err(e) => report.fail("health_check", describe(&e)),
}
match client.statics().await {
Ok(s) => report.check(
"statics",
!s.currencies.is_empty() && !s.countries.is_empty(),
format!(
"{} currencies, {} countries, {} unmodeled key(s) kept in extra",
s.currencies.len(),
s.countries.len(),
s.extra.len()
),
),
Err(e) => report.fail("statics", describe(&e)),
}
match client.search(&json!({ "search": "a" })).await {
Ok(results) => report.pass(
"search",
format!(
"{} client hit(s), {} unmodeled bucket(s)",
results.clients.len(),
results.extra.len()
),
),
Err(e) => report.fail("search", describe(&e)),
}
match client.subscription_steps().await {
Ok(steps) => report.check(
"subscription_steps",
!steps.is_empty() && steps.iter().all(|s| !s.id.is_empty()),
format!("{} step(s) decoded", steps.len()),
),
Err(e) => report.fail("subscription_steps", describe(&e)),
}
match client.activity_feed(Some(5)).await {
Ok(feed) => report.check(
"activity_feed",
feed.iter().all(|e| e.activity_type_id > 0),
format!("{} entr(ies), numeric type ids", feed.len()),
),
Err(e) => report.fail("activity_feed", describe(&e)),
}
// A POST, but it only renders: nothing is stored. Needs an invoice to
// render, so it is skipped on an empty instance.
match client.list_invoices(&small).await {
Ok(rows) => match rows
.first()
.and_then(|i| i.id.clone().map(|id| (id, i.client_id.clone())))
{
Some((id, client_id)) => {
let body = json!({ "entity": "invoice", "entity_id": id, "client_id": client_id });
match client.live_preview(&body).await {
Ok(pdf) => report.check(
"live_preview",
pdf.starts_with(b"%PDF"),
format!("{} byte(s), starts with the PDF magic", pdf.len()),
),
Err(e) => report.fail("live_preview", describe(&e)),
}
}
None => report.pass("live_preview", "skipped: no invoices on this instance"),
},
Err(e) => report.fail("live_preview", describe(&e)),
}
list!(report, "list_clients", client.list_clients(&small));
list!(report, "list_invoices", client.list_invoices(&small));
list!(report, "list_quotes", client.list_quotes(&small));
list!(report, "list_credits", client.list_credits(&small));
list!(report, "list_payments", client.list_payments(&small));
list!(report, "list_products", client.list_products(&small));
list!(report, "list_expenses", client.list_expenses(&small));
list!(report, "list_projects", client.list_projects(&small));
list!(report, "list_tasks", client.list_tasks(&small));
list!(report, "list_vendors", client.list_vendors(&small));
list!(
report,
"list_recurring_invoices",
client.list_recurring_invoices(&small)
);
list!(
report,
"list_recurring_quotes",
client.list_recurring_quotes(&small)
);
list!(
report,
"list_recurring_expenses",
client.list_recurring_expenses(&small)
);
list!(
report,
"list_purchase_orders",
client.list_purchase_orders(&small)
);
list!(report, "list_tax_rates", client.list_tax_rates(&small));
list!(
report,
"list_expense_categories",
client.list_expense_categories(&small)
);
list!(
report,
"list_task_statuses",
client.list_task_statuses(&small)
);
list!(
report,
"list_payment_terms",
client.list_payment_terms(&small)
);
list!(
report,
"list_subscriptions",
client.list_subscriptions(&small)
);
list!(
report,
"list_bank_transactions",
client.list_bank_transactions(&small)
);
list!(report, "list_webhooks", client.list_webhooks(&small));
list!(
report,
"list_bank_integrations",
client.list_bank_integrations(&small)
);
list!(
report,
"list_bank_transaction_rules",
client.list_bank_transaction_rules(&small)
);
list!(
report,
"list_company_gateways",
client.list_company_gateways(&small)
);
list!(report, "list_designs", client.list_designs(&small));
list!(
report,
"list_group_settings",
client.list_group_settings(&small)
);
list!(report, "list_locations", client.list_locations(&small));
list!(report, "list_tags", client.list_tags(&small));
list!(
report,
"list_task_schedulers",
client.list_task_schedulers(&small)
);
list!(report, "list_tokens", client.list_tokens(&small));
list!(
report,
"list_client_gateway_tokens",
client.list_client_gateway_tokens(&small)
);
list!(report, "list_companies", client.list_companies(&small));
list!(report, "list_system_logs", client.list_system_logs(&small));
list!(report, "list_documents", client.list_documents(&small));
list!(report, "list_users", client.list_users(&small));
list!(report, "list_activities", client.list_activities(&small));
// The walk follows `meta.pagination.total_pages`, which only a real server
// can show we are reading correctly. Compare it to one oversized request.
let everything = ListParams::new().per_page(5000);
match (
client
.list_all_clients(&ListParams::new().per_page(2))
.await,
client.list_clients(&everything).await,
) {
(Ok(walked), Ok(single)) if single.len() < 5000 => report.check(
"pagination walk == single request",
walked.len() == single.len(),
format!(
"walked {} at per_page=2, single request returned {}",
walked.len(),
single.len()
),
),
(Ok(_), Ok(_)) => report.pass("pagination walk", "skipped: 5000+ clients"),
(Err(e), _) | (_, Err(e)) => report.fail("pagination walk", describe(&e)),
}
// The server caps per_page. Ask for far more than the cap and see how many
// rows come back, which is the fact the clamp in `get_all_pages` rests on.
match client
.list_clients(&ListParams::new().per_page(100_000))
.await
{
Ok(rows) => report.pass(
"per_page cap",
format!("asked for 100000, got {} row(s) (cap is 5000)", rows.len()),
),
Err(e) => report.fail("per_page cap", describe(&e)),
}
// A single fetch by id, to exercise `get_one` and `seg`.
match client.list_clients(&small).await {
Ok(rows) => match rows.first().and_then(|c| c.id.clone()) {
Some(id) => match client.get_client(&id).await {
Ok(c) => report.pass(
"get_client",
format!("fetched one, {} unmodeled field(s) in extra", c.extra.len()),
),
Err(e) => report.fail("get_client", describe(&e)),
},
None => report.pass("get_client", "skipped: no clients on this instance"),
},
Err(e) => report.fail("get_client", describe(&e)),
}
}
async fn write_checks(client: &InvoiceNinjaClient, report: &mut Report) {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let name = format!("sdk-smoke-{nanos}");
let created = match client
.create_client(&Client {
name: name.clone(),
..Default::default()
})
.await
{
Ok(created) => created,
Err(e) => {
report.fail("create_client", describe(&e));
return;
}
};
let Some(id) = created.id.clone() else {
report.fail("create_client", "response carried no id");
return;
};
report.pass("create_client", format!("created `{name}`"));
upload_checks(client, report, &id).await;
round_trip_checks(client, report, &id, created.extra.len()).await;
extras_checks(client, report, &id).await;
creation_checks(client, report, &id).await;
activity_checks(client, report, &id).await;
// Always runs, including after a failed check above.
match client.delete_client(&id).await {
Ok(()) => report.pass(
"delete_client",
"soft-deleted (a purge needs the account password)",
),
Err(e) => report.fail(
"delete_client",
format!("{} -- remove `{name}` by hand", describe(&e)),
),
}
}
async fn upload_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
let text = |label: &str| {
FileUpload::new(
format!("{label}.txt"),
format!("smoke {label}").into_bytes(),
)
.mime("text/plain")
};
let count_documents = |c: &Client| {
c.extra
.get("documents")
.and_then(|d| d.as_array())
.map(Vec::len)
};
// POST + `_method=PUT`: read from the framework source, then observed
// working on 5.13.50.
match client
.upload_client(id, vec![text("first")], Some(false))
.await
{
Ok(c) => report.check(
"upload_client (1 file, private)",
count_documents(&c) == Some(1),
format!("documents on record: {:?}", count_documents(&c)),
),
Err(e) => report.fail("upload_client (1 file, private)", describe(&e)),
}
match client
.upload_client(id, vec![text("second"), text("third")], Some(true))
.await
{
Ok(c) => report.check(
"upload_client (2 files, public)",
count_documents(&c) == Some(3),
format!(
"documents on record: {:?} (expected 3)",
count_documents(&c)
),
),
Err(e) => report.fail("upload_client (2 files, public)", describe(&e)),
}
}
async fn round_trip_checks(
client: &InvoiceNinjaClient,
report: &mut Report,
id: &str,
extra_keys_at_create: usize,
) {
// `get` then `update` must not drop fields the crate does not model.
let fetched = match client.get_client(id).await {
Ok(c) => c,
Err(e) => {
report.fail("get_client (round trip)", describe(&e));
return;
}
};
let before = fetched.extra.len();
let mut edited = fetched;
edited.private_notes = "edited by smoke test".into();
let updated = match client.update_client(id, &edited).await {
Ok(c) => c,
Err(e) => {
report.fail("update_client", describe(&e));
return;
}
};
report.check(
"update_client preserves extra",
updated.extra.len() >= before.min(extra_keys_at_create),
format!(
"extra keys: {extra_keys_at_create} at create, {before} on get, {} after update",
updated.extra.len()
),
);
report.check(
"update_client applied the edit",
updated.private_notes == "edited by smoke test",
"private_notes round-tripped",
);
}
/// Per-client extras that only read or reshape data already on the record.
/// `updateTaxData` and `check_vat` are left out: they call external services.
async fn extras_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
match client.client_show_settings(id).await {
Ok(settings) => report.check(
"client_show_settings",
settings.is_object(),
format!("{} setting(s)", settings.as_object().map_or(0, |m| m.len())),
),
Err(e) => report.fail("client_show_settings", describe(&e)),
}
match client.client_vat_status(id).await {
Ok(status) => report.pass(
"client_vat_status",
format!("message of {} char(s)", status.message.len()),
),
Err(e) => report.fail("client_vat_status", describe(&e)),
}
// The two uploads in `upload_checks` should be visible here.
match client.client_documents(id, &json!({})).await {
Ok(docs) => report.check(
"client_documents",
docs.len() == 3,
format!("{} document(s) (expected 3)", docs.len()),
),
Err(e) => report.fail("client_documents", describe(&e)),
}
}
/// Creates from bare `Default` models the records that broke against a real
/// server: an empty `status_id`, `country_id` or `format` was rejected, and a
/// string `frequency_id` failed to decode after the record existed. Each is
/// deleted again straight away.
async fn creation_checks(client: &InvoiceNinjaClient, report: &mut Report, client_id: &str) {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let vendor = match client
.create_vendor(&Vendor {
name: format!("sdk-smoke-vendor-{stamp}"),
..Default::default()
})
.await
{
Ok(vendor) => vendor,
Err(e) => {
report.fail("create_vendor", describe(&e));
return;
}
};
let vendor_id = vendor.id.clone().unwrap_or_default();
match client
.create_purchase_order(&PurchaseOrder {
vendor_id: vendor_id.clone(),
..Default::default()
})
.await
{
Ok(po) => {
report.pass("create_purchase_order (default)", "empty status_id omitted");
if let Some(id) = po.id {
let _ = client.delete_purchase_order(&id).await;
}
}
Err(e) => report.fail("create_purchase_order (default)", describe(&e)),
}
match client
.create_location(&Location {
name: format!("sdk-smoke-location-{stamp}"),
client_id: client_id.to_string(),
..Default::default()
})
.await
{
Ok(location) => {
report.pass("create_location (default)", "empty country_id omitted");
if let Some(id) = location.id {
let _ = client.delete_location(&id).await;
}
}
Err(e) => report.fail("create_location (default)", describe(&e)),
}
match client
.create_webhook(&Webhook {
event_id: "1".into(),
target_url: "https://example.com/sdk-smoke".into(),
..Default::default()
})
.await
{
Ok(hook) => {
report.pass("create_webhook (default)", "empty format omitted");
if let Some(id) = hook.id {
let _ = client.delete_webhook(&id).await;
}
}
Err(e) => report.fail("create_webhook (default)", describe(&e)),
}
// The create succeeds either way; decoding the reply is what failed.
match client
.create_recurring_invoice(&RecurringInvoice {
client_id: client_id.to_string(),
frequency_id: 5,
..Default::default()
})
.await
{
Ok(recurring) => {
report.check(
"create_recurring_invoice",
recurring.frequency_id == 5,
format!("frequency_id decoded as {}", recurring.frequency_id),
);
if let Some(id) = recurring.id {
let _ = client.delete_recurring_invoice(&id).await;
}
}
Err(e) => report.fail("create_recurring_invoice", describe(&e)),
}
if let Err(e) = client.delete_vendor(&vendor_id).await {
report.fail(
"delete_vendor",
format!("{} -- remove the vendor by hand", describe(&e)),
);
}
}
/// Password tier: needs `IN_API_PASSWORD`, and only runs inside the write
/// tier's host guard. Merges two throwaway clients, then purges the survivor,
/// which unlike `delete_client` really removes the row. Users, companies and
/// vendors are left alone: inviting a user sends mail, and a company purge
/// takes data that was not created here.
async fn password_checks(client: &InvoiceNinjaClient, report: &mut Report, password: String) {
let client = match client.clone().with_api_password(password) {
Ok(client) => client,
Err(e) => {
report.fail("with_api_password", describe(&e));
return;
}
};
match client.new_user_template().await {
Ok(user) => report.check(
"new_user_template",
user.id.as_deref().unwrap_or("").is_empty(),
"blank user, nothing stored",
),
Err(e) => report.fail("new_user_template", describe(&e)),
}
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut ids = Vec::new();
for label in ["keep", "drop"] {
match client
.create_client(&Client {
name: format!("sdk-smoke-merge-{label}-{stamp}"),
..Default::default()
})
.await
{
Ok(created) => ids.extend(created.id),
Err(e) => {
report.fail("create_client (merge)", describe(&e));
return;
}
}
}
let [keep, drop] = [ids[0].clone(), ids[1].clone()];
match client.merge_clients(&keep, &drop).await {
Ok(merged) => report.check(
"merge_clients",
merged.id.as_deref() == Some(keep.as_str()),
"the first id is the survivor",
),
Err(e) => report.fail("merge_clients", describe(&e)),
}
match client.purge_client(&keep).await {
Ok(message) => report.pass("purge_client", format!("`{}`", message.message)),
Err(e) => report.fail(
"purge_client",
format!(
"{} -- remove the sdk-smoke-merge clients by hand",
describe(&e)
),
),
}
// A purge is permanent, so the record must now be gone, not just flagged.
// A missing record answers 400, not 404: the server maps Laravel's
// ModelNotFoundException to a 400 and keeps 404 for a missing route. The
// message is checked too, so a 400 raised for some other reason fails.
match client.get_client(&keep).await {
Err(Error::Api { status, message, .. })
if status.as_u16() == 400 && message.contains("No query results") =>
{
report.pass("get_client after purge", "400 no query results, the row is gone")
}
Err(e) => report.fail("get_client after purge", describe(&e)),
Ok(_) => report.fail("get_client after purge", "still readable after a purge"),
}
}
/// Notes, the per-record feed and email history, against the throwaway
/// client. Nothing here sends mail: the history is read, and is expected to
/// be empty because no mail has gone out for this client.
async fn activity_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
let note = "sdk smoke note";
match client.add_activity_note("clients", id, note).await {
Ok(activity) => report.check(
"add_activity_note",
activity.notes == note && activity.client_id == id,
"note stored against the client",
),
Err(e) => report.fail("add_activity_note", describe(&e)),
}
match client.entity_activity("client", id, Some(10)).await {
Ok(feed) => report.check(
"entity_activity",
feed.iter().any(|e| e.notes == note),
format!("{} entr(ies), including the note", feed.len()),
),
Err(e) => report.fail("entity_activity", describe(&e)),
}
match client.client_email_history(id).await {
Ok(history) => report.check(
"client_email_history",
history.is_empty(),
"no mail sent, so no history",
),
Err(e) => report.fail("client_email_history", describe(&e)),
}
}
+4 -1
View File
@@ -37,7 +37,10 @@
msrvToolchain = toolchainFor msrvVersion [ ];
shellFor = toolchain: pkgs.mkShell {
packages = [ toolchain pkgs.pkg-config pkgs.openssl ];
# jq is for the MSRV recheck in CLAUDE.md, which pipes `cargo
# metadata` through it; the rest is the toolchain plus what
# openssl-sys needs to link.
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
};
in
+607 -17
View File
@@ -1,19 +1,21 @@
use std::time::Duration;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use reqwest::{header, Method, StatusCode};
use reqwest::{header, multipart, Method, StatusCode};
use serde::{de::DeserializeOwned, Serialize};
use crate::error::{ApiErrorBody, Error, Result};
use crate::models::{
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client as ClientModel,
ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense,
ExpenseCategory, GroupSetting, HealthCheck, Invoice, ItemResponse, ListParams, ListResponse,
Location, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SystemLog, Tag, Task,
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, 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,
};
use crate::retry::{self, RetryPolicy};
use crate::upload::FileUpload;
/// API root of the hosted Invoice Ninja app.
pub const HOSTED_BASE_URL: &str = "https://invoicing.co/api/v1";
@@ -60,6 +62,28 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
Ok(utf8_percent_encode(value, PATH_SEGMENT))
}
/// Standard base64 with padding. Only used for the one header that needs it,
/// which is not worth a dependency.
fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk
.iter()
.enumerate()
.fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
for i in 0..4 {
if i <= chunk.len() {
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
/// Renders the start of a response body for an error message.
///
/// Collapsed to one line and truncated, so a stray HTML error page or a
@@ -101,9 +125,9 @@ fn excerpt(bytes: &[u8]) -> String {
pub struct InvoiceNinjaClient {
http: reqwest::Client,
base_url: String,
/// Marked sensitive, so it renders as `Sensitive` rather than the value
/// in the derived `Debug`.
api_password: Option<header::HeaderValue>,
/// The header to send it under, and its value. Marked sensitive, so it
/// renders as `Sensitive` rather than the value in the derived `Debug`.
api_password: Option<(&'static str, header::HeaderValue)>,
timeout: Duration,
retry: RetryPolicy,
}
@@ -184,7 +208,14 @@ impl InvoiceNinjaClient {
/// middleware and reject requests without the account password, even
/// when the token is valid: reading or writing an individual user,
/// `/users/bulk`, purging a client or company, and merging clients.
/// Without this, those calls fail rather than returning data.
/// Without this, those calls fail rather than returning data. (The
/// server also lets a recent password-authenticated session through
/// without the header, so an omitted password is not always refused.)
///
/// A password that fits a header value is sent as `X-API-PASSWORD`. One
/// that does not (it contains a control character such as a newline) is
/// sent as `X-API-PASSWORD-BASE64`, which the server's middleware decodes.
/// It used to be refused locally with a misleading `InvalidToken`.
///
/// ```no_run
/// # fn main() -> invoiceninjars::Result<()> {
@@ -196,10 +227,17 @@ impl InvoiceNinjaClient {
/// # }
/// ```
pub fn with_api_password(mut self, password: impl Into<String>) -> Result<Self> {
let mut value = header::HeaderValue::from_str(&password.into())
.map_err(|e| Error::InvalidToken(e.to_string()))?;
let password = password.into();
let (name, mut value) = match header::HeaderValue::from_str(&password) {
Ok(value) => ("X-API-PASSWORD", value),
Err(_) => (
"X-API-PASSWORD-BASE64",
header::HeaderValue::from_str(&base64(password.as_bytes()))
.expect("base64 output is always a valid header value"),
),
};
value.set_sensitive(true);
self.api_password = Some(value);
self.api_password = Some((name, value));
Ok(self)
}
@@ -211,7 +249,7 @@ impl InvoiceNinjaClient {
.request(method, self.url(path))
.timeout(self.timeout);
match &self.api_password {
Some(password) => builder.header("X-API-PASSWORD", password.clone()),
Some((name, password)) => builder.header(*name, password.clone()),
None => builder,
}
}
@@ -220,8 +258,16 @@ impl InvoiceNinjaClient {
/// configured [`RetryPolicy`].
///
/// Every helper goes through here, so retrying is uniform. The request is
/// cloned before each attempt; a body that cannot be cloned (none of ours
/// today, since all payloads are in-memory JSON) simply gets one attempt.
/// cloned before each attempt; a body that cannot be cloned simply gets
/// one attempt.
///
/// Multipart uploads rely on that. `reqwest` builds their body as a
/// stream, so `try_clone` returns `None` and they are never replayed —
/// not even on a 429. That is wanted: an upload *adds* a document, so it
/// is not idempotent whatever its HTTP verb, and a replay after a lost
/// response would attach the file twice. A test pins this, because
/// nothing in the types would stop a future change from making the body
/// cloneable and silently breaking it.
///
/// Note what is *not* retried: a `POST` that fails with a 5xx or times
/// out is surfaced, because the server may already have applied it and a
@@ -545,10 +591,52 @@ impl InvoiceNinjaClient {
Self::decode(response).await
}
/// Attaches files to a record through its `/upload` route.
///
/// The route is registered as `PUT`, but PHP does not parse a multipart
/// body on a `PUT`, so the files would never arrive. The working form is
/// a `POST` carrying `_method=PUT`, which Laravel's kernel turns back
/// into a `PUT` before routing. The published OpenAPI spec documents
/// `POST` with `_method=POST`, which would 405 against these routes.
///
/// `is_public` goes out as `1`/`0`: Laravel's `boolean` rule accepts only
/// `true`, `false`, `0`, `1`, `'0'` and `'1'`, so the string `"true"`
/// fails validation.
async fn upload_files<T: DeserializeOwned>(
&self,
path: &str,
files: Vec<FileUpload>,
is_public: Option<bool>,
) -> Result<T> {
// The server answers an upload with no files with a success that
// changed nothing, which is exactly the quiet failure to avoid.
if files.is_empty() {
return Err(Error::InvalidUpload("no files to upload".into()));
}
let mut form = multipart::Form::new().text("_method", "PUT");
if let Some(is_public) = is_public {
form = form.text("is_public", if is_public { "1" } else { "0" });
}
for file in files {
form = form.part("documents[]", file.into_part()?);
}
let response = self
.execute(self.req(Method::POST, path).multipart(form))
.await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
/// Issues a request whose body is discarded, failing on non-2xx.
async fn get_discard(&self, path: &str) -> Result<()> {
self.get_raw(path).await.map(|_| ())
}
async fn post_discard<B: Serialize>(&self, path: &str, payload: &B) -> Result<()> {
self.post_raw(path, payload).await.map(|_| ())
}
}
/// Generates typed list/list_all/get/create/update/delete methods for a
@@ -609,6 +697,34 @@ macro_rules! resource_methods {
};
}
/// Generates `upload_{x}`, which attaches files through
/// `PUT /{resource}/{id}/upload`. Sixteen resources expose this route; check
/// `routes/api.php` before adding one.
///
/// The upload route is how a document is created at all —
/// `DocumentController::store` is an empty stub upstream, which is why there
/// is no `create_document`.
macro_rules! upload_methods {
($upload:ident, $path:literal, $model:ty) => {
/// Attaches files to the record and returns it as refreshed by the
/// server. `is_public` sets whether the documents are visible to the
/// client in the portal; `None` leaves the server default.
///
/// The server enforces file type and size limits, which surface as a
/// 422 [`Error::Api`](crate::Error::Api). Uploads are never retried,
/// since a replay could attach the file twice.
pub async fn $upload(
&self,
id: &str,
files: Vec<FileUpload>,
is_public: Option<bool>,
) -> Result<$model> {
self.upload_files(&format!("{}/{}/upload", $path, seg(id)?), files, is_public)
.await
}
};
}
/// Generates the `GET /{resource}/{id}/{action}` helpers. Only invoices,
/// quotes, credits, and purchase orders expose this route.
macro_rules! entity_action_methods {
@@ -977,6 +1093,33 @@ impl InvoiceNinjaClient {
.await
}
// --- document uploads -----------------------------------------------
upload_methods!(upload_client, "clients", ClientModel);
upload_methods!(upload_company, "companies", Company);
upload_methods!(upload_credit, "credits", Credit);
upload_methods!(upload_expense, "expenses", Expense);
upload_methods!(upload_group_setting, "group_settings", GroupSetting);
upload_methods!(upload_invoice, "invoices", Invoice);
upload_methods!(upload_payment, "payments", Payment);
upload_methods!(upload_product, "products", Product);
upload_methods!(upload_project, "projects", Project);
upload_methods!(upload_purchase_order, "purchase_orders", PurchaseOrder);
upload_methods!(upload_quote, "quotes", Quote);
upload_methods!(
upload_recurring_expense,
"recurring_expenses",
RecurringExpense
);
upload_methods!(
upload_recurring_invoice,
"recurring_invoices",
RecurringInvoice
);
upload_methods!(upload_recurring_quote, "recurring_quotes", RecurringQuote);
upload_methods!(upload_task, "tasks", Task);
upload_methods!(upload_vendor, "vendors", Vendor);
// --- entity actions with non-entity responses -----------------------
/// Converts an invoice into a purchase order. Separate from
@@ -1317,3 +1460,450 @@ impl InvoiceNinjaClient {
.await
}
}
/// Per-resource routes that sit beside the CRUD set. Each answers with its
/// own shape (checked against the upstream controllers), so none of them is
/// macro-generated.
///
/// All are `POST` unless noted, so none is retried on a 5xx or a timeout.
/// Several are no-body triggers; they send `{}` rather than an empty body.
impl InvoiceNinjaClient {
// --- webhooks, tasks, designs ---------------------------------------
/// Re-sends a webhook for one entity. `body` takes `entity` (e.g.
/// `invoice`) and `entity_id`. Answers with the webhook itself, not the
/// delivery result.
pub async fn retry_webhook<B: Serialize>(&self, id: &str, body: &B) -> Result<Webhook> {
self.post_one(&format!("webhooks/{}/retry", seg(id)?), body)
.await
}
/// Reorders task-board columns and the tasks in them. `body` takes
/// `status_ids` (task status ids, in order) and `task_ids` (a map from
/// task status id to the ordered task ids in that column).
pub async fn sort_tasks<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("tasks/sort", body).await
}
/// Makes a design the default for an entity. `body` takes `design_id`
/// and `entity`, and optionally `settings_level` (`company`,
/// `group_settings`, or `client`) with the matching `group_settings_id`
/// or `client_id`.
pub async fn set_default_design<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("designs/set/default", body).await
}
// --- client gateway tokens ------------------------------------------
/// Makes a stored payment method the client's default.
pub async fn set_default_client_gateway_token(&self, id: &str) -> Result<ClientGatewayToken> {
self.post_one(
&format!("client_gateway_tokens/{}/setAsDefault", seg(id)?),
&serde_json::json!({}),
)
.await
}
// --- subscriptions --------------------------------------------------
/// The steps of the purchase flow and what each depends on.
///
/// There is no `data` wrapper. The server sends a JSON object keyed by
/// the PHP class behind each step (the controller's `collect()` keeps
/// those keys), though the upstream source reads as if it were a list.
/// Both shapes decode here; the keys are dropped, since each step's `id`
/// says the same thing, and the order of the result is not meaningful.
pub async fn subscription_steps(&self) -> Result<Vec<SubscriptionStep>> {
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Steps {
List(Vec<SubscriptionStep>),
Keyed(std::collections::BTreeMap<String, SubscriptionStep>),
}
Ok(match self.get_unwrapped("subscriptions/steps").await? {
Steps::List(steps) => steps,
Steps::Keyed(steps) => steps.into_values().collect(),
})
}
/// Validates a proposed `steps` list against the dependency rules.
/// Succeeds on a valid list; an invalid one is a 422
/// ([`Error::Api`]) carrying the reason.
pub async fn check_subscription_steps<B: Serialize>(&self, body: &B) -> Result<()> {
self.post_discard("subscriptions/steps/check", body).await
}
// --- company gateways -----------------------------------------------
/// Asks the gateway to verify its credentials. The outcome is in the
/// returned message; a failed check is still an HTTP 200.
pub async fn test_company_gateway(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("company_gateways/{}/test", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Duplicates a company gateway, returning the copy.
pub async fn clone_company_gateway(&self, id: &str) -> Result<CompanyGateway> {
self.post_one(
&format!("company_gateways/{}/clone", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Starts importing the gateway's customers as clients. The import runs
/// in the background; the message only confirms it was queued.
pub async fn import_company_gateway_customers(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("company_gateways/{}/import_customers", seg(id)?),
&serde_json::json!({}),
)
.await
}
// --- bank integrations and transactions -----------------------------
/// Asks the server to refresh every linked bank account. The response
/// body is a serialised query builder with no usable content, so it is
/// discarded; list the integrations afterwards to see the result.
/// Throttled to 30 calls a minute.
pub async fn refresh_bank_accounts(&self) -> Result<()> {
self.post_discard("bank_integrations/refresh_accounts", &serde_json::json!({}))
.await
}
/// Disconnects a bank account, returning the updated integration.
/// `account_id` is the provider's account id, not the integration's.
pub async fn remove_bank_account(&self, account_id: &str) -> Result<BankIntegration> {
self.post_one(
&format!("bank_integrations/remove_account/{}", seg(account_id)?),
&serde_json::json!({}),
)
.await
}
/// Starts fetching transactions for every auto-sync integration. Despite
/// the `account_id` in the route, the server ignores it. Throttled to one
/// call a minute.
pub async fn fetch_bank_transactions(&self, account_id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("bank_integrations/get_transactions/{}", seg(account_id)?),
&serde_json::json!({}),
)
.await
}
/// Matches bank transactions to invoices, payments, or expenses. `body`
/// takes a `transactions` array; the matched transactions come back.
pub async fn match_bank_transactions<B: Serialize>(
&self,
body: &B,
) -> Result<Vec<BankTransaction>> {
self.post_list("bank_transactions/match", body).await
}
// --- client extras --------------------------------------------------
/// Re-fetches the client's tax data from the tax provider. Throttled to
/// 10 calls a minute.
pub async fn update_client_tax_data(&self, id: &str) -> Result<ClientModel> {
self.post_one(
&format!("clients/{}/updateTaxData", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// The client's effective settings, each tagged with the level it came
/// from. Left as raw JSON: the map's keys are the settings themselves.
pub async fn client_show_settings(&self, id: &str) -> Result<serde_json::Value> {
self.post_unwrapped(
&format!("clients/{}/show_settings", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Looks the client up on the Peppol network. Left as raw JSON: the
/// controller returns whatever the discovery service produced.
pub async fn client_peppol_discovery(&self, id: &str) -> Result<serde_json::Value> {
self.post_unwrapped(
&format!("clients/{}/peppol_discovery", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Validates the client's VAT number. Updates the client's VAT status as
/// a side effect.
pub async fn check_client_vat(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("clients/{}/check_vat", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// The client's VAT validation status. A `GET`, and the only read-only
/// method in this block.
pub async fn client_vat_status(&self, id: &str) -> Result<MessageResponse> {
self.get_unwrapped(&format!("clients/{}/vat_status", seg(id)?))
.await
}
/// Every document attached to the client or to its invoices, quotes,
/// credits, expenses, payments, tasks, recurring invoices, recurring
/// expenses, and projects. `body` takes `filter` (a name substring) and
/// `sort` (`column|asc` or `column|desc`). Returns one page.
pub async fn client_documents<B: Serialize>(
&self,
id: &str,
body: &B,
) -> Result<Vec<Document>> {
self.post_list(&format!("clients/{}/documents", seg(id)?), body)
.await
}
}
/// Destructive actions behind the API's `password_protected` middleware.
///
/// Every method here needs
/// [`with_api_password`](InvoiceNinjaClient::with_api_password); without it
/// the server normally refuses the request. Most are **irreversible**: a purge
/// force-deletes rows rather than archiving them, and a merge removes one of
/// the two records.
///
/// All are `POST`s, so none is retried on a 5xx or a timeout. That matters
/// most here: replaying a merge after a lost response could act on a record
/// that no longer exists.
impl InvoiceNinjaClient {
/// Permanently deletes a client and its documents. Unlike
/// [`delete_client`](Self::delete_client), which soft-deletes, this cannot
/// be undone.
pub async fn purge_client(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("clients/{}/purge", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Merges `mergeable_id` into `id`, returning the client that is kept.
///
/// The route is `clients/{id}/{mergeable_id}/merge` and the controller
/// calls `merge` on the first, so `id` is the survivor. Merging a client
/// into itself, or naming one that does not exist, is a 400.
pub async fn merge_clients(&self, id: &str, mergeable_id: &str) -> Result<ClientModel> {
self.post_one(
&format!("clients/{}/{}/merge", seg(id)?, seg(mergeable_id)?),
&serde_json::json!({}),
)
.await
}
/// Merges `mergeable_id` into `id`, returning the vendor that is kept.
/// Same semantics as [`merge_clients`](Self::merge_clients).
pub async fn merge_vendors(&self, id: &str, mergeable_id: &str) -> Result<Vendor> {
self.post_one(
&format!("vendors/{}/{}/merge", seg(id)?, seg(mergeable_id)?),
&serde_json::json!({}),
)
.await
}
/// Deletes a company. The hosted app refuses to purge its default
/// company with a 400.
pub async fn purge_company(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("companies/purge/{}", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Force-deletes a company's clients, products, projects, tasks, vendors,
/// expenses, purchase orders, bank transactions and activity, and resets
/// its number counters. The company, its settings, and its invoices
/// remain as the server leaves them.
pub async fn purge_company_save_settings(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("companies/purge_save_settings/{}", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Re-sends a user's invitation email.
pub async fn invite_user(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("users/{}/invite", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Permanently deletes a user. Only the account owner may, and not
/// themselves. The server answers 204 with no body.
pub async fn purge_user(&self, id: &str) -> Result<()> {
self.post_discard(&format!("users/{}/purge", seg(id)?), &serde_json::json!({}))
.await
}
/// A blank user as the server would create it, for filling in and
/// passing to [`create_user`](Self::create_user). Nothing is stored.
pub async fn new_user_template(&self) -> Result<User> {
self.get_one("users/create").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.
impl InvoiceNinjaClient {
// --- activities -----------------------------------------------------
/// One page of the activity log, newest first.
///
/// The server takes its row limit from a `rows` parameter (default 75)
/// rather than `per_page`, though it paginates the result as well; use
/// [`ListParams::param`] to pass `rows`.
pub async fn list_activities(&self, params: &ListParams) -> Result<Vec<Activity>> {
self.get_list("activities", params).await
}
pub async fn list_all_activities(&self, params: &ListParams) -> Result<Vec<Activity>> {
self.get_all_pages("activities", params).await
}
/// The activity log as the web app shows it: each entry's records are
/// resolved into labelled references, newest first. Non-admins see only
/// their own activity. `rows` defaults to 75 on the server.
pub async fn activity_feed(&self, rows: Option<u32>) -> Result<Vec<ActivityFeedEntry>> {
let mut params = ListParams::new().param("reactv2", "1");
if let Some(rows) = rows {
params = params.param("rows", rows.to_string());
}
self.get_list("activities", &params).await
}
/// The feed for one record. `entity` is the **singular** name (`invoice`,
/// `quote`, `credit`, `purchase_order`, `payment`, `client`, `vendor`,
/// `expense`, `task`, `project`, `subscription`, `recurring_invoice`).
/// Contrast [`add_activity_note`](Self::add_activity_note), which wants
/// the plural.
pub async fn entity_activity(
&self,
entity: &str,
entity_id: &str,
rows: Option<u32>,
) -> Result<Vec<ActivityFeedEntry>> {
let mut body = serde_json::json!({ "entity": entity, "entity_id": entity_id });
// Left out rather than sent as null: the server tests for the key's
// presence, so a null `rows` would count as given and remove the cap.
if let Some(rows) = rows {
body["rows"] = rows.into();
}
self.post_list("activities/entity", &body).await
}
/// Adds a note to a record's history. `entity` is the **plural** table
/// name (`invoices`, `quotes`, `credits`, `recurring_invoices`,
/// `clients`, `vendors`, `payments`, `projects`, `tasks`, `expenses`,
/// `recurring_expenses`, `bank_transactions`, `purchase_orders`), unlike
/// [`entity_activity`](Self::entity_activity).
///
/// Not idempotent: each call adds another note, so it is not retried on a
/// 5xx.
pub async fn add_activity_note(
&self,
entity: &str,
entity_id: &str,
notes: &str,
) -> Result<Activity> {
self.post_one(
"activities/notes",
&serde_json::json!({ "entity": entity, "entity_id": entity_id, "notes": notes }),
)
.await
}
/// The PDF of an invoice, quote or credit as it was when the activity was
/// recorded. A 404 means that activity kept no backup.
pub async fn download_activity_entity(&self, activity_id: &str) -> Result<Vec<u8>> {
self.get_raw(&format!("activities/download_entity/{}", seg(activity_id)?))
.await
}
// --- email history --------------------------------------------------
/// The client's email delivery history, newest first: one object per sent
/// message that has delivery events. Left as raw JSON, since the objects
/// mirror whatever the mail provider reported and no instance available
/// here had sent mail to take a shape from.
pub async fn client_email_history(&self, client_id: &str) -> Result<Vec<serde_json::Value>> {
self.post_unwrapped(
&format!("emails/clientHistory/{}", seg(client_id)?),
&serde_json::json!({}),
)
.await
}
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
/// [`client_email_history`](Self::client_email_history).
pub async fn entity_email_history(
&self,
entity: &str,
entity_id: &str,
) -> Result<Vec<serde_json::Value>> {
self.post_unwrapped(
"emails/entityHistory",
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
)
.await
}
// --- previews -------------------------------------------------------
//
// These render a document without saving it and answer with a PDF, so
// they return the bytes. `body` is the entity to render plus, for the
// design routes, the `design` object.
/// Renders a preview of an existing record or of an unsaved design.
/// Takes `entity`, `entity_id` (`-1` for a mock record) and `design`.
pub async fn preview<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("preview", body).await
}
/// [`preview`](Self::preview) as the HTML the PDF is made from.
pub async fn preview_html<B: Serialize>(&self, body: &B) -> Result<String> {
let bytes = self.post_raw("preview?html=true", body).await?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// Renders an unsaved invoice, quote, credit or recurring invoice as a
/// PDF: the body is the entity's own fields, plus `entity` and
/// `entity_id`.
pub async fn live_preview<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("live_preview", body).await
}
/// Renders a design against mock data, for editing it.
pub async fn live_design<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("live_design", body).await
}
/// [`preview`](Self::preview) for a purchase order.
pub async fn preview_purchase_order<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("preview/purchase_order", body).await
}
/// [`live_preview`](Self::live_preview) for a purchase order.
pub async fn live_preview_purchase_order<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("live_preview/purchase_order", body).await
}
}
+42 -1
View File
@@ -15,7 +15,11 @@ pub struct ApiErrorBody {
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("http transport error: {0}")]
/// Rendered with its whole cause chain, because `reqwest`'s own message
/// stops at "error sending request for url (...)" and leaves out the part
/// that says why: "Connection refused", a DNS failure, a TLS handshake
/// error, a timeout.
#[error("http transport error: {}", cause_chain(.0))]
Http(#[from] reqwest::Error),
/// The response was not the JSON this endpoint is documented to return.
@@ -31,6 +35,15 @@ pub enum Error {
excerpt: String,
},
/// A non-2xx response: the server's message, plus any per-field
/// validation errors.
///
/// **A missing record is `400`, not `404`.** Invoice Ninja's exception
/// handler maps Laravel's `ModelNotFoundException` to a 400 whose message
/// is `No query results for model [App\Models\Client].`, and reserves 404
/// for a route that does not exist or a method that route does not
/// support. Matching on 404 to mean "not found" will never fire — match
/// the status together with the message.
#[error("api returned {status}: {message}")]
Api {
status: reqwest::StatusCode,
@@ -54,6 +67,14 @@ pub enum Error {
#[error("`{0}` cannot be used as a url path segment")]
InvalidPathSegment(String),
/// An upload was refused before any request was sent: no files, a file
/// with no name, or a MIME type that does not parse.
///
/// Raised locally because the server would otherwise answer an empty
/// upload with a success that changed nothing.
#[error("invalid upload: {0}")]
InvalidUpload(String),
/// A `list_all_*` call kept receiving full pages past the point where any
/// plausible result set would have ended.
///
@@ -66,3 +87,23 @@ pub enum Error {
}
pub type Result<T> = std::result::Result<T, Error>;
/// Joins an error and every error beneath it with `: `.
///
/// `reqwest::Error`'s `Display` prints only the outermost layer, so the
/// reason a request failed lives in `source()` and is otherwise invisible to
/// anyone who just prints the error.
fn cause_chain(error: &(dyn std::error::Error + 'static)) -> String {
let mut message = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
let text = cause.to_string();
// Some layers repeat their cause's text; don't print it twice.
if !message.contains(&text) {
message.push_str(": ");
message.push_str(&text);
}
source = cause.source();
}
message
}
+2
View File
@@ -18,7 +18,9 @@ mod client;
mod error;
pub mod models;
mod retry;
mod upload;
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
pub use error::{ApiErrorBody, Error, Result};
pub use retry::RetryPolicy;
pub use upload::FileUpload;
+122
View File
@@ -0,0 +1,122 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// One row of the activity log, as `GET /activities` returns it.
///
/// Every reference is a hashed id, or an empty string when the activity does
/// not concern that kind of record. `activity_type_id` arrives as a string
/// here but as a number in the [feed](ActivityFeedEntry).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Activity {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub activity_type_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub company_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub contact_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_contact_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub quote_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub credit_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub payment_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub recurring_invoice_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub recurring_expense_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub purchase_order_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub expense_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub task_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub token_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_system: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub ip: String,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// A named record an activity refers to: the display label and its hashed id.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ActivityRef {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub label: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub hashed_id: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// One entry of the activity feed (`activity_feed`, `entity_activity`).
///
/// A different shape from [`Activity`]: the server resolves the records an
/// activity mentions into labelled references. Which references are present
/// depends on the activity type, so each is optional, and any the server adds
/// that this does not name lands in `extra`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ActivityFeedEntry {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub hashed_id: String,
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
pub activity_type_id: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub ip: String,
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
pub created_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendor: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invoice: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quote: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credit: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payment: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expense: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purchase_order: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recurring_invoice: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recurring_expense: Option<ActivityRef>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+38
View File
@@ -154,6 +154,32 @@ where
}
}
/// Deserializes an integer that may arrive as a JSON number or as a numeric
/// string.
///
/// The OpenAPI spec calls `frequency_id` a number on recurring invoices and
/// expenses, but a real 5.13.50 server returns the string `"5"`. A strict
/// `i64` made that a hard decode error *after* the record had already been
/// created. A string that is not a whole number is still an error.
pub(crate) fn int_or_numeric_string<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: Deserializer<'de>,
{
match Option::<Value>::deserialize(deserializer)? {
None | Some(Value::Null) => Ok(0),
Some(Value::Number(n)) => n
.as_i64()
.ok_or_else(|| serde::de::Error::custom(format!("expected a whole number, got {n}"))),
Some(Value::String(s)) if s.is_empty() => Ok(0),
Some(Value::String(s)) => s.trim().parse().map_err(|_| {
serde::de::Error::custom(format!("expected a whole number, got string `{s}`"))
}),
Some(other) => Err(serde::de::Error::custom(format!(
"expected a whole number or numeric string, got {other}"
))),
}
}
/// Body for a bulk action (`POST /{resource}/bulk`).
///
/// The action set varies by resource; `archive`, `restore`, and `delete` work
@@ -174,3 +200,15 @@ impl BulkRequest {
}
}
}
/// The `{"message": "..."}` body that action endpoints answer with.
///
/// `message` is defaulted so a reply that carries none still decodes.
/// Anything else the server adds is kept in `extra`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MessageResponse {
#[serde(default, deserialize_with = "null_to_default")]
pub message: String,
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
+8 -1
View File
@@ -18,7 +18,14 @@ pub struct Location {
pub state: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub postal_code: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// Omitted when empty: the server validates it as an integer, so an unset
/// `""` is rejected with a 422 and a default `Location` could not be
/// created.
#[serde(
default,
deserialize_with = "super::common::null_to_default",
skip_serializing_if = "String::is_empty"
)]
pub country_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
+6 -2
View File
@@ -1,3 +1,4 @@
mod activity;
mod bank_integration;
mod bank_transaction;
mod bank_transaction_rule;
@@ -35,12 +36,15 @@ mod user;
mod vendor;
mod webhook;
pub use activity::{Activity, ActivityFeedEntry, ActivityRef};
pub use bank_integration::BankIntegration;
pub use bank_transaction::BankTransaction;
pub use bank_transaction_rule::BankTransactionRule;
pub use client::{Client, ClientContact};
pub use client_gateway_token::ClientGatewayToken;
pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination};
pub use common::{
BulkRequest, ItemResponse, ListParams, ListResponse, MessageResponse, Meta, Pagination,
};
pub use company::Company;
pub use company_gateway::CompanyGateway;
pub use company_token::CompanyToken;
@@ -61,7 +65,7 @@ pub use recurring_expense::RecurringExpense;
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
pub use search::{SearchHit, SearchResults};
pub use statics::{Currency, DateFormat, StaticItem, Statics};
pub use subscription::Subscription;
pub use subscription::{Subscription, SubscriptionStep};
pub use system_log::SystemLog;
pub use tag::Tag;
pub use task::Task;
+2
View File
@@ -38,7 +38,9 @@ pub struct Payment {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PaymentInvoice {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64,
#[serde(flatten)]
+8 -1
View File
@@ -23,7 +23,14 @@ pub struct PurchaseOrder {
pub po_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub quote_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// Omitted when empty: the server turns `""` into `NULL` and then fails
/// the insert on a non-null column with a 500, so a new purchase order
/// must leave it out and take the server's default.
#[serde(
default,
deserialize_with = "super::common::null_to_default",
skip_serializing_if = "String::is_empty"
)]
pub status_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String,
+1 -1
View File
@@ -28,7 +28,7 @@ pub struct RecurringExpense {
/// Integer enum matching [`RecurringInvoice::frequency_id`].
///
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
#[serde(default, deserialize_with = "super::common::null_to_default")]
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
pub frequency_id: i64,
/// Number of cycles left; `-1` means it recurs indefinitely.
#[serde(default, deserialize_with = "super::common::null_to_default")]
+3 -2
View File
@@ -25,8 +25,9 @@ pub struct RecurringInvoice {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String,
/// How often the invoice recurs. Invoice Ninja returns this as an integer
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string. A real
/// server sends it as a numeric string; both decode.
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
pub frequency_id: i64,
/// Number of cycles left to send; `-1` means it recurs indefinitely.
#[serde(default, deserialize_with = "super::common::null_to_default")]
+13
View File
@@ -55,3 +55,16 @@ pub struct Subscription {
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// One step of the subscription purchase flow and the steps it depends on,
/// as returned by `GET /subscriptions/steps`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubscriptionStep {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub dependencies: Vec<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+5 -4
View File
@@ -3,8 +3,9 @@ use serde_json::{Map, Value};
/// A server-side log entry (gateway responses, mail failures, and so on).
///
/// Note that unlike every other resource, this one's `created_at` and
/// `updated_at` are returned as date **strings**, not integer timestamps.
/// The OpenAPI spec declares `created_at` and `updated_at` as date strings,
/// but a real server (5.13.50) sends integer timestamps like every other
/// resource.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SystemLog {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -23,9 +24,9 @@ pub struct SystemLog {
#[serde(default)]
pub log: Value,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub created_at: String,
pub created_at: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub updated_at: String,
pub updated_at: i64,
#[serde(flatten)]
pub extra: Map<String, Value>,
+7 -2
View File
@@ -13,8 +13,13 @@ pub struct Webhook {
/// The URL Invoice Ninja posts to.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub target_url: String,
/// Payload format: `JSON` or `UBL`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// Payload format: `JSON` or `UBL`. Omitted when empty: the server turns
/// `""` into `NULL` and fails the insert on a non-null column (a 500).
#[serde(
default,
deserialize_with = "super::common::null_to_default",
skip_serializing_if = "String::is_empty"
)]
pub format: String,
#[serde(flatten)]
+70
View File
@@ -0,0 +1,70 @@
use std::fmt;
use reqwest::multipart::Part;
use crate::error::{Error, Result};
/// A file to attach to a record with the `upload_*` methods.
///
/// The contents are held in memory, so a large file costs its full size in
/// RAM for as long as the upload takes. Streaming uploads are an open
/// question in `GOALS.md`.
///
/// ```
/// use invoiceninjars::FileUpload;
///
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
/// .mime("application/pdf");
/// ```
#[derive(Clone)]
pub struct FileUpload {
file_name: String,
bytes: Vec<u8>,
mime: Option<String>,
}
impl FileUpload {
/// `file_name` is what the server records and shows against the
/// document, so it should carry the right extension.
pub fn new(file_name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
Self {
file_name: file_name.into(),
bytes: bytes.into(),
mime: None,
}
}
/// Sets the part's `Content-Type`. Optional: without it the part carries
/// none, and the server decides from the file's contents.
pub fn mime(mut self, mime: impl Into<String>) -> Self {
self.mime = Some(mime.into());
self
}
pub(crate) fn into_part(self) -> Result<Part> {
if self.file_name.trim().is_empty() {
return Err(Error::InvalidUpload(
"a file needs a non-empty file name".into(),
));
}
let part = Part::bytes(self.bytes).file_name(self.file_name);
match self.mime {
Some(mime) => part
.mime_str(&mime)
.map_err(|_| Error::InvalidUpload(format!("`{mime}` is not a valid MIME type"))),
None => Ok(part),
}
}
}
/// Elides the contents: the derived impl would print the entire file.
impl fmt::Debug for FileUpload {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FileUpload")
.field("file_name", &self.file_name)
.field("len", &self.bytes.len())
.field("mime", &self.mime)
.finish()
}
}
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
//! Goal 2, checked mechanically: every model must absorb a field that is
//! `null` or absent.
//!
//! Invoice Ninja returns `null` for many nominally-string fields and omits
//! others, so a model that fails on either takes the whole response down with
//! it. Rather than trusting each field to carry the right serde attribute,
//! this serialises `Default` (which emits every non-optional field), then for
//! each field in turn nulls it and, separately, removes it, and requires the
//! result to still decode.
//!
//! 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 serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
/// Returns one line per mutation that failed to decode.
fn failures<T: Serialize + DeserializeOwned + Default>(name: &str) -> Vec<String> {
let base = serde_json::to_value(T::default()).expect("default serialises");
let object = base
.as_object()
.unwrap_or_else(|| panic!("{name}: default did not serialise to an object"));
let mut out = Vec::new();
for key in object.keys() {
let mut nulled = base.clone();
nulled[key] = Value::Null;
if let Err(e) = serde_json::from_value::<T>(nulled) {
out.push(format!("{name}.{key} = null: {e}"));
}
let mut removed = base.clone();
removed.as_object_mut().unwrap().remove(key);
if let Err(e) = serde_json::from_value::<T>(removed) {
out.push(format!("{name}.{key} absent: {e}"));
}
}
out
}
macro_rules! tolerant {
($($ty:ty),+ $(,)?) => {{
let mut all = Vec::new();
$( all.extend(failures::<$ty>(stringify!($ty))); )+
all
}};
}
#[test]
fn every_model_absorbs_a_null_or_missing_field() {
let all = tolerant![
Activity,
ActivityFeedEntry,
ActivityRef,
BankIntegration,
BankTransaction,
BankTransactionRule,
Client,
ClientContact,
ClientGatewayToken,
Company,
CompanyGateway,
CompanyToken,
Design,
Document,
Expense,
ExpenseCategory,
GroupSetting,
Invoice,
InvoiceItem,
Location,
Payment,
PaymentInvoice,
PaymentTerm,
Product,
Project,
PurchaseOrder,
RecurringExpense,
RecurringInvoice,
Subscription,
SubscriptionStep,
SystemLog,
Tag,
Task,
TaskScheduler,
TaskStatus,
TaxRate,
User,
Vendor,
VendorContact,
Webhook,
];
assert!(
all.is_empty(),
"{} field(s) break decoding on null/absent:\n{}",
all.len(),
all.join("\n")
);
}
/// A guard on the test itself: if `Default` stopped emitting fields, the loop
/// above would pass vacuously.
#[test]
fn the_tolerance_test_actually_exercises_fields() {
let client = serde_json::to_value(Client::default()).unwrap();
assert!(
client.as_object().unwrap().len() > 10,
"Client::default() should serialise many fields"
);
}
/// The mutation really does bite: a field without `null_to_default` fails.
#[test]
fn a_bare_non_option_field_would_be_caught() {
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct Naive {
#[serde(default)]
name: String,
}
assert_eq!(
failures::<Naive>("Naive").len(),
1,
"only the null case fails"
);
}
+29
View File
@@ -0,0 +1,29 @@
{
"data": [
{
"activity_type_id": "",
"client_id": "",
"company_id": "",
"contact_id": "",
"created_at": 0,
"credit_id": "",
"expense_id": "",
"id": "",
"invoice_id": "",
"ip": "",
"is_system": false,
"notes": "",
"payment_id": "",
"purchase_order_id": "",
"quote_id": "",
"recurring_expense_id": "",
"recurring_invoice_id": "",
"task_id": "",
"token_id": "",
"updated_at": 0,
"user_id": "",
"vendor_contact_id": "",
"vendor_id": ""
}
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"data": [
{
"activity_type_id": 0,
"created_at": 0,
"hashed_id": "",
"id": "",
"ip": "",
"notes": "",
"purchase_order": {
"hashed_id": "",
"label": ""
},
"user": {
"hashed_id": "",
"label": ""
},
"vendor": {
"hashed_id": "",
"label": ""
}
}
]
}
+114
View File
@@ -0,0 +1,114 @@
{
"data": [
{
"address1": "",
"address2": "",
"archived_at": 0,
"assigned_user_id": "",
"balance": 0,
"city": "",
"classification": "",
"client_hash": "",
"contacts": [
{
"archived_at": 0,
"can_sign": false,
"cc_only": false,
"contact_key": "",
"created_at": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"email": "",
"first_name": "",
"id": "",
"is_locked": false,
"is_primary": false,
"last_login": 0,
"last_name": "",
"link": "",
"password": "",
"phone": "",
"send_email": false,
"updated_at": 0
}
],
"country_id": "",
"created_at": 0,
"credit_balance": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"display_name": "",
"documents": [
{
"archived_at": 0,
"assigned_user_id": "",
"created_at": 0,
"disk": "",
"documentable_id": "",
"documentable_type": "",
"hash": "",
"height": 0,
"id": "",
"is_default": false,
"is_deleted": false,
"is_public": false,
"link": "",
"name": "",
"preview": "",
"project_id": "",
"size": 0,
"type": "",
"updated_at": 0,
"url": "",
"user_id": "",
"vendor_id": "",
"width": 0
}
],
"e_invoice": {},
"gateway_tokens": [],
"group_settings_id": "",
"has_valid_vat_number": false,
"id": "",
"id_number": "",
"industry_id": "",
"is_deleted": false,
"is_tax_exempt": false,
"last_login": 0,
"locations": [],
"name": "",
"number": "",
"paid_to_date": 0,
"payment_balance": 0,
"phone": "",
"postal_code": "",
"private_notes": "",
"public_notes": "",
"routing_id": "",
"settings": {
"currency_id": "",
"entity": "",
"industry_id": null,
"size_id": null
},
"shipping_address1": "",
"shipping_address2": "",
"shipping_city": "",
"shipping_country_id": "",
"shipping_postal_code": "",
"shipping_state": "",
"size_id": "",
"state": "",
"tags": [],
"tax_info": {},
"updated_at": 0,
"user_id": "",
"vat_number": "",
"website": ""
}
]
}
+1059
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
{
"data": [
{
"amount": 0,
"archived_at": 0,
"assigned_user_id": "",
"backup": {
"adjustable_amount": 0,
"cancellation": {
"adjustment": 0,
"status_id": 0
},
"child_invoice_ids": [],
"document_type": null,
"e_invoice_cleared_at": null,
"e_invoice_status": null,
"guid": "",
"notes": null,
"parent_invoice_id": null,
"parent_invoice_number": null,
"redirect": null
},
"balance": 0,
"client_id": "",
"created_at": 0,
"custom_surcharge1": 0,
"custom_surcharge2": 0,
"custom_surcharge3": 0,
"custom_surcharge4": 0,
"custom_surcharge_tax1": false,
"custom_surcharge_tax2": false,
"custom_surcharge_tax3": false,
"custom_surcharge_tax4": false,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"design_id": "",
"discount": 0,
"documents": [],
"due_date": "",
"e_invoice": {},
"entity_type": "",
"exchange_rate": 0,
"footer": "",
"has_expenses": false,
"has_tasks": false,
"id": "",
"invitations": [
{
"archived_at": 0,
"can_sign": false,
"client_contact_id": "",
"created_at": 0,
"email_error": "",
"email_status": "",
"id": "",
"key": "",
"link": "",
"message_id": "",
"opened_date": "",
"sent_date": "",
"updated_at": 0,
"viewed_date": ""
}
],
"invoice_id": "",
"is_amount_discount": false,
"is_deleted": false,
"last_sent_date": "",
"line_items": [
{
"cost": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"discount": 0,
"expense_id": "",
"gross_line_total": 0,
"income_account_id": "",
"is_amount_discount": false,
"line_total": 0,
"net_cost": 0,
"notes": "",
"product_cost": 0,
"product_key": "",
"quantity": 0,
"sort_id": "",
"tags": "",
"task_id": "",
"tax_amount": 0,
"tax_id": "",
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"type_id": "",
"unit_code": ""
}
],
"location_id": "",
"next_send_date": "",
"number": "",
"paid_to_date": 0,
"partial": 0,
"partial_due_date": "",
"po_number": "",
"private_notes": "",
"project_id": "",
"public_notes": "",
"reminder1_sent": "",
"reminder2_sent": "",
"reminder3_sent": "",
"reminder_last_sent": "",
"status_id": "",
"subscription_id": "",
"sync": null,
"tags": [],
"tax_info": {},
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"terms": "",
"total_taxes": 0,
"updated_at": 0,
"user_id": "",
"uses_inclusive_taxes": false,
"vendor_id": ""
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"design": {
"body": "",
"footer": "",
"header": "",
"includes": "",
"product": "",
"task": ""
},
"entities": "",
"id": "",
"is_active": false,
"is_custom": false,
"is_deleted": false,
"is_free": false,
"is_template": false,
"name": "",
"updated_at": 0
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"data": [
{
"archived_at": 0,
"assigned_user_id": "",
"created_at": 0,
"disk": "",
"documentable_id": "",
"documentable_type": "",
"hash": "",
"height": 0,
"id": "",
"is_default": false,
"is_deleted": false,
"is_public": false,
"link": "",
"name": "",
"preview": "",
"project_id": "",
"size": 0,
"type": "",
"updated_at": 0,
"url": "",
"user_id": "",
"vendor_id": "",
"width": 0
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"data": [
{
"archived_at": 0,
"color": "",
"created_at": 0,
"id": "",
"is_deleted": false,
"name": "",
"updated_at": 0,
"user_id": ""
}
]
}
+55
View File
@@ -0,0 +1,55 @@
{
"data": [
{
"amount": 0,
"archived_at": 0,
"assigned_user_id": "",
"bank_id": "",
"calculate_tax_by_amount": false,
"category_id": "",
"client_id": "",
"created_at": 0,
"currency_id": "",
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"documents": [],
"e_invoice": {},
"entity_type": "",
"exchange_rate": 0,
"expense_currency_id": "",
"foreign_amount": 0,
"id": "",
"invoice_currency_id": "",
"invoice_documents": false,
"invoice_id": "",
"is_deleted": false,
"number": "",
"payment_date": "",
"payment_type_id": "",
"private_notes": "",
"project_id": "",
"public_notes": "",
"recurring_expense_id": "",
"should_be_invoiced": false,
"tags": [],
"tax_amount1": 0,
"tax_amount2": 0,
"tax_amount3": 0,
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"transaction_id": "",
"transaction_reference": "",
"updated_at": 0,
"user_id": "",
"uses_inclusive_taxes": false,
"vendor_id": ""
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"documents": [],
"id": "",
"is_deleted": false,
"name": "",
"settings": {
"entity": ""
},
"updated_at": 0
}
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"api_version": "",
"cache_enabled": false,
"env_writable": false,
"exchange_rate_api_not_configured": false,
"exec": false,
"extensions": [
{
"gd": false
}
],
"file_permissions": "",
"flutter_renderer": "",
"is_docker": false,
"jobs_pending": 0,
"mail_mailer": "",
"open_basedir": false,
"pdf_engine": "",
"pending_migrations": false,
"phantom_enabled": false,
"php_version": {
"current_php_cli_version": "",
"current_php_version": "",
"is_okay": false,
"memory_limit": "",
"minimum_php_version": ""
},
"queue": "",
"queue_data": {
"failed": 0,
"last_error": "",
"pending": 0
},
"simple_db_check": false,
"system_health": false,
"trailing_slash": false
}
+140
View File
@@ -0,0 +1,140 @@
{
"data": [
{
"amount": 0,
"archived_at": 0,
"assigned_user_id": "",
"auto_bill_enabled": false,
"backup": {
"adjustable_amount": 0,
"cancellation": {
"adjustment": 0,
"status_id": 0
},
"child_invoice_ids": [],
"document_type": null,
"e_invoice_cleared_at": null,
"e_invoice_status": null,
"guid": "",
"notes": null,
"parent_invoice_id": null,
"parent_invoice_number": null,
"redirect": null
},
"balance": 0,
"client_id": "",
"created_at": 0,
"custom_surcharge1": 0,
"custom_surcharge2": 0,
"custom_surcharge3": 0,
"custom_surcharge4": 0,
"custom_surcharge_tax1": false,
"custom_surcharge_tax2": false,
"custom_surcharge_tax3": false,
"custom_surcharge_tax4": false,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"design_id": "",
"discount": 0,
"documents": [],
"due_date": "",
"e_invoice": {},
"entity_type": "",
"exchange_rate": 0,
"footer": "",
"has_expenses": false,
"has_tasks": false,
"id": "",
"invitations": [
{
"archived_at": 0,
"can_sign": false,
"client_contact_id": "",
"created_at": 0,
"email_error": "",
"email_status": "",
"id": "",
"key": "",
"link": "",
"message_id": "",
"opened_date": "",
"sent_date": "",
"updated_at": 0,
"viewed_date": ""
}
],
"is_amount_discount": false,
"is_deleted": false,
"last_sent_date": "",
"line_items": [
{
"cost": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"discount": 0,
"expense_id": "",
"gross_line_total": 0,
"income_account_id": "",
"is_amount_discount": false,
"line_total": 0,
"net_cost": 0,
"notes": "",
"product_cost": 0,
"product_key": "",
"quantity": 0,
"sort_id": "",
"tags": "",
"task_id": "",
"tax_amount": 0,
"tax_id": "",
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"type_id": "",
"unit_code": ""
}
],
"location_id": "",
"next_send_date": "",
"number": "",
"paid_to_date": 0,
"partial": 0,
"partial_due_date": "",
"po_number": "",
"private_notes": "",
"project_id": "",
"public_notes": "",
"recurring_id": "",
"reminder1_sent": "",
"reminder2_sent": "",
"reminder3_sent": "",
"reminder_last_sent": "",
"status_id": "",
"subscription_id": "",
"sync": null,
"tags": [],
"tax_info": {},
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"terms": "",
"total_taxes": 0,
"updated_at": 0,
"user_id": "",
"uses_inclusive_taxes": false,
"vendor_id": ""
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"data": [
{
"address1": "",
"address2": "",
"archived_at": 0,
"city": "",
"client_id": "",
"country_id": "",
"created_at": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"id": "",
"is_deleted": false,
"is_shipping_location": false,
"name": "",
"postal_code": "",
"state": "",
"tax_data": {},
"updated_at": 0,
"user_id": "",
"vendor_id": ""
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"id": "",
"is_deleted": false,
"name": "",
"num_days": 0,
"updated_at": 0
}
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"data": [
{
"amount": 0,
"applied": 0,
"archived_at": 0,
"assigned_user_id": "",
"client_contact_id": "",
"client_id": "",
"company_gateway_id": "",
"created_at": 0,
"currency_id": "",
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"documents": [],
"exchange_currency_id": "",
"exchange_rate": 0,
"gateway_type_id": "",
"id": "",
"invitation_id": "",
"is_deleted": false,
"is_manual": false,
"number": "",
"paymentables": [
{
"amount": 0,
"archived_at": 0,
"created_at": 0,
"id": "",
"invoice_id": "",
"payment_id": "",
"refunded": 0,
"updated_at": 0
}
],
"private_notes": "",
"project_id": "",
"refunded": 0,
"status_id": "",
"tags": [],
"transaction_id": "",
"transaction_reference": "",
"type_id": "",
"updated_at": 0,
"user_id": "",
"vendor_id": ""
}
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"data": [
{
"archived_at": 0,
"assigned_user_id": "",
"cost": 0,
"created_at": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"documents": [],
"id": "",
"in_stock_quantity": 0,
"income_account_id": "",
"is_deleted": false,
"max_quantity": 0,
"notes": "",
"price": 0,
"product_image": "",
"product_key": "",
"quantity": 0,
"stock_notification": false,
"stock_notification_threshold": 0,
"tags": [],
"tax_id": "",
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"updated_at": 0,
"user_id": ""
}
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"data": [
{
"archived_at": 0,
"assigned_user_id": "",
"budgeted_amount": 0,
"budgeted_hours": 0,
"client_id": "",
"color": "",
"created_at": 0,
"current_hours": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"documents": [],
"due_date": "",
"id": "",
"is_deleted": false,
"name": "",
"number": "",
"private_notes": "",
"public_notes": "",
"tags": [],
"task_rate": 0,
"updated_at": 0,
"user_id": ""
}
]
}
+93
View File
@@ -0,0 +1,93 @@
{
"data": [
{
"amount": 0,
"archived_at": 0,
"assigned_user_id": "",
"balance": 0,
"client_id": "",
"created_at": 0,
"currency_id": "",
"custom_surcharge1": 0,
"custom_surcharge2": 0,
"custom_surcharge3": 0,
"custom_surcharge4": 0,
"custom_surcharge_tax1": false,
"custom_surcharge_tax2": false,
"custom_surcharge_tax3": false,
"custom_surcharge_tax4": false,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"design_id": "",
"discount": 0,
"documents": [],
"due_date": "",
"e_invoice": {},
"entity_type": "",
"exchange_rate": 0,
"expense_id": "",
"footer": "",
"has_expenses": false,
"has_tasks": false,
"id": "",
"invitations": [
{
"archived_at": 0,
"can_sign": false,
"created_at": 0,
"email_error": "",
"email_status": "",
"id": "",
"key": "",
"link": "",
"message_id": "",
"opened_date": "",
"sent_date": "",
"updated_at": 0,
"vendor_contact_id": "",
"viewed_date": ""
}
],
"invoice_id": "",
"is_amount_discount": false,
"is_deleted": false,
"last_sent_date": "",
"line_items": [],
"location_id": "",
"next_send_date": "",
"number": "",
"paid_to_date": 0,
"partial": 0,
"partial_due_date": "",
"po_number": "",
"private_notes": "",
"project_id": "",
"public_notes": "",
"quote_id": "",
"reminder1_sent": "",
"reminder2_sent": "",
"reminder3_sent": "",
"reminder_last_sent": "",
"status_id": "",
"subscription_id": "",
"sync": null,
"tags": [],
"tax_info": {},
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"terms": "",
"total_taxes": 0,
"updated_at": 0,
"user_id": "",
"uses_inclusive_taxes": false,
"vendor_id": ""
}
]
}
+123
View File
@@ -0,0 +1,123 @@
{
"data": [
{
"amount": 0,
"archived_at": 0,
"assigned_user_id": "",
"balance": 0,
"client_id": "",
"created_at": 0,
"custom_surcharge1": 0,
"custom_surcharge2": 0,
"custom_surcharge3": 0,
"custom_surcharge4": 0,
"custom_surcharge_tax1": false,
"custom_surcharge_tax2": false,
"custom_surcharge_tax3": false,
"custom_surcharge_tax4": false,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"design_id": "",
"discount": 0,
"documents": [],
"due_date": "",
"e_invoice": {},
"entity_type": "",
"exchange_rate": 0,
"footer": "",
"has_expenses": false,
"has_tasks": false,
"id": "",
"invitations": [
{
"archived_at": 0,
"can_sign": false,
"client_contact_id": "",
"created_at": 0,
"email_error": "",
"email_status": "",
"id": "",
"key": "",
"link": "",
"message_id": "",
"opened_date": "",
"sent_date": "",
"updated_at": 0,
"viewed_date": ""
}
],
"invoice_id": "",
"is_amount_discount": false,
"is_deleted": false,
"last_sent_date": "",
"line_items": [
{
"cost": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"discount": 0,
"expense_id": "",
"gross_line_total": 0,
"income_account_id": "",
"is_amount_discount": false,
"line_total": 0,
"net_cost": 0,
"notes": "",
"product_cost": 0,
"product_key": "",
"quantity": 0,
"sort_id": "",
"tags": "",
"task_id": "",
"tax_amount": 0,
"tax_id": "",
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"type_id": "",
"unit_code": ""
}
],
"location_id": "",
"next_send_date": "",
"number": "",
"paid_to_date": 0,
"partial": 0,
"partial_due_date": "",
"po_number": "",
"private_notes": "",
"project_id": "",
"public_notes": "",
"reminder1_sent": "",
"reminder2_sent": "",
"reminder3_sent": "",
"reminder_last_sent": "",
"status_id": "",
"subscription_id": "",
"sync": null,
"tags": [],
"tax_info": {},
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"terms": "",
"total_taxes": 0,
"updated_at": 0,
"user_id": "",
"uses_inclusive_taxes": false,
"vendor_id": ""
}
]
}
+59
View File
@@ -0,0 +1,59 @@
{
"data": [
{
"amount": 0,
"archived_at": 0,
"assigned_user_id": "",
"bank_id": "",
"calculate_tax_by_amount": false,
"category_id": "",
"client_id": "",
"created_at": 0,
"currency_id": "",
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"documents": [],
"entity_type": "",
"exchange_rate": 0,
"foreign_amount": 0,
"frequency_id": "",
"id": "",
"invoice_currency_id": "",
"invoice_documents": false,
"invoice_id": "",
"is_deleted": false,
"last_sent_date": "",
"next_send_date": "",
"number": "",
"payment_date": "",
"payment_type_id": "",
"private_notes": "",
"project_id": "",
"public_notes": "",
"recurring_dates": [],
"recurring_expense_currency_id": "",
"remaining_cycles": 0,
"should_be_invoiced": false,
"status_id": "",
"tags": [],
"tax_amount1": 0,
"tax_amount2": 0,
"tax_amount3": 0,
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"transaction_id": "",
"transaction_reference": "",
"updated_at": 0,
"user_id": "",
"uses_inclusive_taxes": false,
"vendor_id": ""
}
]
}
+90
View File
@@ -0,0 +1,90 @@
{
"data": [
{
"amount": 0,
"archived_at": 0,
"assigned_user_id": "",
"auto_bill": "",
"auto_bill_enabled": false,
"balance": 0,
"client_id": "",
"created_at": 0,
"custom_surcharge1": 0,
"custom_surcharge2": 0,
"custom_surcharge3": 0,
"custom_surcharge4": 0,
"custom_surcharge_tax1": false,
"custom_surcharge_tax2": false,
"custom_surcharge_tax3": false,
"custom_surcharge_tax4": false,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"design_id": "",
"discount": 0,
"documents": [],
"due_date": "",
"due_date_days": "",
"e_invoice": {},
"entity_type": "",
"exchange_rate": 0,
"footer": "",
"frequency_id": "",
"has_expenses": false,
"has_tasks": false,
"id": "",
"invitations": [
{
"archived_at": 0,
"can_sign": false,
"client_contact_id": "",
"created_at": 0,
"email_error": "",
"email_status": "",
"id": "",
"key": "",
"link": "",
"message_id": "",
"opened_date": "",
"sent_date": "",
"updated_at": 0,
"viewed_date": ""
}
],
"is_amount_discount": false,
"is_deleted": false,
"last_sent_date": "",
"line_items": [],
"location_id": "",
"next_send_date": "",
"next_send_datetime": "",
"number": "",
"paid_to_date": 0,
"partial": 0,
"partial_due_date": "",
"po_number": "",
"private_notes": "",
"project_id": "",
"public_notes": "",
"recurring_dates": [],
"remaining_cycles": 0,
"status_id": "",
"subscription_id": "",
"tags": [],
"tax_name1": "",
"tax_name2": "",
"tax_name3": "",
"tax_rate1": 0,
"tax_rate2": 0,
"tax_rate3": 0,
"terms": "",
"total_taxes": 0,
"updated_at": 0,
"user_id": "",
"uses_inclusive_taxes": false,
"vendor_id": ""
}
]
}
+185
View File
@@ -0,0 +1,185 @@
{
"bulk_updates": {
"client": [
""
],
"expense": [
""
],
"recurring_invoice": [
""
],
"task": [
""
]
},
"countries": [
{
"capital": "",
"citizenship": "",
"country_code": "",
"currency": "",
"currency_code": "",
"currency_sub_unit": "",
"decimal_separator": "",
"eea": false,
"full_name": "",
"id": "",
"iso_3166_2": "",
"iso_3166_3": "",
"name": "",
"region_code": "",
"sub_region_code": "",
"swap_currency_symbol": false,
"swap_postal_code": false,
"thousand_separator": ""
}
],
"currencies": [
{
"code": "",
"decimal_separator": "",
"exchange_rate": 0,
"id": "",
"name": "",
"precision": 0,
"swap_currency_symbol": false,
"symbol": "",
"thousand_separator": ""
}
],
"date_formats": [
{
"format": "",
"format_dart": "",
"format_moment": "",
"id": ""
}
],
"datetime_formats": [
{
"format": "",
"format_dart": "",
"format_moment": "",
"id": ""
}
],
"gateways": [
{
"created_at": 0,
"default_gateway_type_id": "",
"fields": "",
"id": "",
"is_offsite": false,
"is_secure": false,
"key": "",
"name": "",
"options": {
"1": {
"refund": false,
"token_billing": false,
"webhooks": [
""
]
},
"2": {
"refund": false,
"token_billing": false,
"webhooks": [
""
]
}
},
"provider": "",
"site_url": "",
"sort_order": 0,
"updated_at": 0,
"visible": false
}
],
"industries": [
{
"id": "",
"name": ""
}
],
"languages": [
{
"id": "",
"locale": "",
"name": ""
}
],
"license_key": false,
"payment_types": [
{
"gateway_type_id": 0,
"id": "",
"name": ""
}
],
"sizes": [
{
"id": "",
"name": ""
}
],
"templates": {
"invoice": {
"body": "",
"subject": ""
},
"partial_payment": {
"body": "",
"subject": ""
},
"payment": {
"body": "",
"subject": ""
},
"payment_failed": {
"body": "",
"subject": ""
},
"purchase_order": {
"body": "",
"subject": ""
},
"quote": {
"body": "",
"subject": ""
},
"quote_reminder1": {
"body": "",
"subject": ""
},
"reminder1": {
"body": "",
"subject": ""
},
"reminder2": {
"body": "",
"subject": ""
},
"reminder3": {
"body": "",
"subject": ""
},
"reminder_endless": {
"body": "",
"subject": ""
},
"statement": {
"body": "",
"subject": ""
}
},
"timezones": [
{
"id": "",
"location": "",
"name": "",
"utc_offset": 0
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"App\\Livewire\\BillingPortal\\Authentication\\Login": {
"dependencies": [],
"id": ""
},
"App\\Livewire\\BillingPortal\\Authentication\\Register": {
"dependencies": [],
"id": ""
},
"App\\Livewire\\BillingPortal\\Authentication\\RegisterOrLogin": {
"dependencies": [],
"id": ""
},
"App\\Livewire\\BillingPortal\\Cart\\Cart": {
"dependencies": [],
"id": ""
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"data": [
{
"category_id": 0,
"client_id": "",
"company_id": "",
"created_at": 0,
"event_id": 0,
"id": "",
"log": "",
"type_id": 0,
"updated_at": 0,
"user_id": ""
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"data": [
{
"archived_at": 0,
"color": null,
"created_at": 0,
"entity_type": "",
"id": "",
"is_deleted": false,
"name": "",
"updated_at": 0
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"data": [
{
"archived_at": 0,
"color": "",
"created_at": 0,
"id": "",
"is_deleted": false,
"name": "",
"status_order": 0,
"updated_at": 0
}
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"data": [
{
"archived_at": 0,
"assigned_user_id": "",
"client_id": "",
"created_at": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"date": "",
"description": "",
"documents": [],
"due_date": "",
"duration": 0,
"estimated_duration": null,
"id": "",
"invoice_id": "",
"is_date_based": false,
"is_deleted": false,
"is_running": false,
"meta": {},
"number": "",
"project_id": "",
"rate": 0,
"status_id": "",
"status_order": null,
"status_sort_order": 0,
"tags": [],
"time_log": "",
"updated_at": 0,
"user_id": ""
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"id": "",
"is_deleted": false,
"name": "",
"rate": 0,
"updated_at": 0
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"id": "",
"is_deleted": false,
"is_system": false,
"name": "",
"token": "",
"updated_at": 0,
"user_id": ""
}
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"email": "",
"email_verified_at": 0,
"first_name": "",
"google_2fa_secret": false,
"has_password": false,
"id": "",
"is_deleted": false,
"language_id": "",
"last_confirmed_email_address": "",
"last_login": 0,
"last_name": "",
"oauth_provider_id": "",
"oauth_user_token": "",
"passkey_count": 0,
"passkey_enabled": false,
"phone": "",
"referral_code": "",
"referral_meta": {
"enterprise": 0,
"free": 0,
"pro": 0
},
"settings": {
"calendar_connection": {
"email": "",
"status": ""
}
},
"signature": "",
"updated_at": 0,
"user_logged_in_notification": false,
"verified_phone_number": false
}
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"data": [
{
"address1": "",
"address2": "",
"archived_at": 0,
"assigned_user_id": "",
"city": "",
"classification": "",
"contacts": [
{
"archived_at": 0,
"can_sign": false,
"cc_only": false,
"created_at": 0,
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"email": "",
"first_name": "",
"id": "",
"is_primary": false,
"last_login": 0,
"last_name": "",
"link": "",
"password": "",
"phone": "",
"send_email": false,
"updated_at": 0
}
],
"country_id": "",
"created_at": 0,
"currency_id": "",
"custom_value1": "",
"custom_value2": "",
"custom_value3": "",
"custom_value4": "",
"display_name": "",
"documents": [],
"id": "",
"id_number": "",
"is_deleted": false,
"is_tax_exempt": false,
"language_id": "",
"last_login": 0,
"name": "",
"number": "",
"phone": "",
"postal_code": "",
"private_notes": "",
"public_notes": "",
"routing_id": "",
"state": "",
"tags": [],
"updated_at": 0,
"user_id": "",
"vat_number": "",
"website": ""
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"data": [
{
"archived_at": 0,
"company_id": "",
"created_at": 0,
"event_id": "",
"format": "",
"headers": [],
"id": "",
"is_deleted": false,
"rest_method": "",
"target_url": "",
"updated_at": 0,
"user_id": ""
}
]
}
+7 -6
View File
@@ -584,23 +584,24 @@ fn company_keeps_settings_untyped_and_preserves_toggles() {
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
}
/// System logs are the one resource whose timestamps are date strings
/// rather than integers.
/// The spec says system-log timestamps are date strings; a real server sends
/// integers, as it does everywhere else.
#[test]
fn system_log_timestamps_are_strings() {
fn system_log_timestamps_are_integers() {
let log: SystemLog = serde_json::from_value(json!({
"id": "sl1",
"event_id": 30,
"category_id": 1,
"type_id": 300,
"log": {"message": "gateway declined"},
"created_at": "2026-10-01 12:00:00",
"updated_at": "2026-10-01 12:00:00",
"created_at": 1792869488,
"updated_at": 1792869489,
}))
.unwrap();
assert_eq!(log.event_id, 30);
assert_eq!(log.created_at, "2026-10-01 12:00:00");
assert_eq!(log.created_at, 1792869488);
assert_eq!(log.updated_at, 1792869489);
assert_eq!(log.log["message"], "gateway declined");
}
+227
View File
@@ -0,0 +1,227 @@
//! Decodes fixtures captured from a real server.
//!
//! `tests/fixtures/*.json` are written by `examples/capture_shapes.rs` and
//! hold only key names and JSON types (strings are `""`, numbers `0`, bools
//! `false`). Mock responses written from the upstream source have been wrong
//! about the real thing before, so these pin the shapes a server actually
//! sent. To refresh them, seed a disposable instance with
//! `examples/seed_sample_data.rs` and re-run the capture.
//!
//! Three properties are checked: every fixture decodes into its model
//! (goal 2), a decode followed by a re-serialise loses no key (goal 1), and no
//! fixture contains a real value, so a careless capture cannot leak data.
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use invoiceninjars::models::*;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
fn dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn load(name: &str) -> Value {
let path = dir().join(format!("{name}.json"));
let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
/// The captured row of a list fixture.
fn row(name: &str) -> Value {
load(name)["data"][0].clone()
}
/// Key paths present in `raw` but absent from `out`. Arrays are compared
/// through their first element, which is all a fixture keeps.
fn lost_keys(raw: &Value, out: &Value, prefix: &str, into: &mut Vec<String>) {
match (raw, out) {
(Value::Object(raw), Value::Object(out)) => {
for (key, raw_value) in raw {
match out.get(key) {
Some(out_value) => {
lost_keys(raw_value, out_value, &format!("{prefix}.{key}"), into)
}
None => into.push(format!("{prefix}.{key}")),
}
}
}
(Value::Array(raw), Value::Array(out)) => {
if let (Some(raw), Some(out)) = (raw.first(), out.first()) {
lost_keys(raw, out, &format!("{prefix}[]"), into);
}
}
_ => {}
}
}
/// Decodes `raw` as `T`, re-serialises it, and returns problems.
fn check<T: DeserializeOwned + Serialize>(name: &str, raw: &Value) -> Vec<String> {
let typed: T = match serde_json::from_value(raw.clone()) {
Ok(typed) => typed,
Err(e) => return vec![format!("{name}: does not decode: {e}")],
};
let out = serde_json::to_value(&typed).expect("serialises");
let mut lost = Vec::new();
lost_keys(raw, &out, name, &mut lost);
lost.into_iter()
.filter(|path| !OMITTED_WHEN_EMPTY.contains(&path.as_str()))
.map(|path| format!("{path}: key lost on a decode/re-serialise"))
.collect()
}
/// Fields that are deliberately left out of a request when empty, because the
/// server rejects the empty value (`""` becomes `NULL` on a non-null column,
/// or fails an `integer` rule). A fixture holds `""` placeholders, so these
/// show up as absent after a re-serialise. Adding to this list is a decision.
const OMITTED_WHEN_EMPTY: &[&str] = &[
"locations.country_id",
"purchase_orders.status_id",
"webhooks.format",
];
/// One fixture per `(file, type)`; the macro also records which files it
/// covered so a new capture cannot sit untested.
macro_rules! list_fixtures {
($($file:literal => $ty:ty),+ $(,)?) => {
const COVERED_LISTS: &[&str] = &[$($file),+];
fn check_lists() -> Vec<String> {
let mut problems = Vec::new();
$( problems.extend(check::<$ty>($file, &row($file))); )+
problems
}
};
}
list_fixtures! {
"activities" => Activity,
"clients" => Client,
"companies" => Company,
"credits" => Credit,
"designs" => Design,
"documents" => Document,
"expense_categories" => ExpenseCategory,
"expenses" => Expense,
"group_settings" => GroupSetting,
"invoices" => Invoice,
"locations" => Location,
"payment_terms" => PaymentTerm,
"payments" => Payment,
"products" => Product,
"projects" => Project,
"purchase_orders" => PurchaseOrder,
"quotes" => Quote,
"recurring_expenses" => RecurringExpense,
"recurring_invoices" => RecurringInvoice,
"system_logs" => SystemLog,
"tags" => Tag,
"task_statuses" => TaskStatus,
"tasks" => Task,
"tax_rates" => TaxRate,
"tokens" => CompanyToken,
"users" => User,
"vendors" => Vendor,
"webhooks" => Webhook,
}
/// Fixtures that are not a one-row list.
const COVERED_OTHER: &[&str] = &[
"statics",
"health_check",
"subscription_steps",
"activity_feed",
];
#[test]
fn every_real_row_decodes_and_loses_no_key() {
let problems = check_lists();
assert!(
problems.is_empty(),
"{} problem(s):\n{}",
problems.len(),
problems.join("\n")
);
}
#[test]
fn bare_endpoint_fixtures_decode() {
let mut problems = Vec::new();
problems.extend(check::<Statics>("statics", &load("statics")));
problems.extend(check::<HealthCheck>("health_check", &load("health_check")));
problems.extend(check::<ActivityFeedEntry>(
"activity_feed",
&load("activity_feed")["data"][0],
));
// Keyed by class name on a real server; see `subscription_steps`.
let steps: Result<BTreeMap<String, SubscriptionStep>, _> =
serde_json::from_value(load("subscription_steps"));
match steps {
Ok(steps) => assert!(!steps.is_empty(), "the captured steps object is empty"),
Err(e) => problems.push(format!("subscription_steps: {e}")),
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
fn every_fixture_file_is_covered_by_a_test() {
let mut on_disk: Vec<String> = fs::read_dir(dir())
.expect("tests/fixtures exists")
.filter_map(|entry| {
let path = entry.ok()?.path();
(path.extension()? == "json").then(|| path.file_stem()?.to_str().map(String::from))?
})
.collect();
on_disk.sort();
let mut covered: Vec<String> = COVERED_LISTS
.iter()
.chain(COVERED_OTHER)
.map(|s| s.to_string())
.collect();
covered.sort();
assert_eq!(
on_disk, covered,
"a captured fixture has no test (or a test has no fixture)"
);
}
/// The capture tool replaces every value; this refuses to pass if any
/// survived, so real data cannot be committed by accident.
#[test]
fn fixtures_hold_shapes_and_never_values() {
fn walk(value: &Value, at: &str, bad: &mut Vec<String>) {
match value {
Value::String(s) if !s.is_empty() => bad.push(format!("{at}: string value")),
Value::Number(n) if n.as_f64() != Some(0.0) => bad.push(format!("{at}: number value")),
Value::Bool(true) => bad.push(format!("{at}: true")),
Value::Array(items) => {
assert!(items.len() <= 1, "{at}: a fixture keeps one array element");
items.iter().for_each(|v| walk(v, &format!("{at}[]"), bad));
}
Value::Object(map) => map
.iter()
.for_each(|(k, v)| walk(v, &format!("{at}.{k}"), bad)),
_ => {}
}
}
let mut bad = Vec::new();
for entry in fs::read_dir(dir()).expect("tests/fixtures exists") {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "json") {
let name = path.file_stem().unwrap().to_string_lossy().to_string();
walk(&load(&name), &name, &mut bad);
}
}
assert!(
bad.is_empty(),
"fixtures contain values:\n{}",
bad.join("\n")
);
}