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>
This commit is contained in:
2026-10-24 14:26:41 -04:00
co-authored by Claude Opus 5
parent 355cf08324
commit e4f2373b53
12 changed files with 608 additions and 32 deletions
+11 -1
View File
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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.
- `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 - A declared minimum supported Rust version of **1.88**, verified by running
the test suite against it. The floor comes from the dependency graph the test suite against it. The floor comes from the dependency graph
(`reqwest` → `url` → `idna` → `icu_*`), not from this crate's own code. (`reqwest` → `url` → `idna` → `icu_*`), not from this crate's own code.
@@ -17,7 +27,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 release. The advisory job fails visibly rather than silently; it is kept
non-blocking through branch protection rather than `continue-on-error`. non-blocking through branch protection rather than `continue-on-error`.
- Wrappers for every one of the API's 34 `Route::resource` groups, plus - 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` — 284 methods (including document uploads). See `COVERAGE.md` for the per-endpoint inventory.
- `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why, - `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why,
checked against `routes/api.php` on the `v5-stable` branch. checked against `routes/api.php` on the `v5-stable` branch.
- Retry with full-jitter exponential backoff, configurable through - Retry with full-jitter exponential backoff, configurable through
+16 -4
View File
@@ -161,6 +161,18 @@ changes, doc tweaks) does not need an entry.
elsewhere without checking `routes/api.php`. In particular the official PHP elsewhere without checking `routes/api.php`. In particular the official PHP
SDK puts bulk `download` on *every* resource, which is wrong; only SDK puts bulk `download` on *every* resource, which is wrong; only
PDF-rendering entities support it. 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, - Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
refunds, ping/health) are hand-written in the last `impl` block rather than refunds, ping/health) are hand-written in the last `impl` block rather than
macro-generated, because their request and response shapes are all macro-generated, because their request and response shapes are all
@@ -224,16 +236,16 @@ changes, doc tweaks) does not need an entry.
## Coverage ## Coverage
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users` **All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD (which isn't one). 284 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, and 25 hand-written. only, 4 with entity actions, 16 with document uploads, and 28 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, `report`, `chart`, `search`, `statics`, the four action-capable resources, `report`, `chart`, `search`, `statics`,
`send_email`, `refund_payment`, `ping`, and `health_check`. `send_email`, `refund_payment`, `ping`, and `health_check`.
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every **`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
gap, and the reason for each gap. The main remaining gaps are multipart gap, and the reason for each gap. The main remaining gaps are a handful of
uploads (needs a reqwest feature), a handful of per-resource extras per-resource extras
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the (`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
integration/auth routes. integration/auth routes.
+55 -8
View File
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
> exists or what it returns. > exists or what it returns.
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is **All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
not a resource group). 265 methods in total. not a resource group). 284 methods in total.
## Resources with full CRUD + bulk ## Resources with full CRUD + bulk
@@ -123,6 +123,54 @@ same reason: the response isn't JSON.
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`. 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 ## Other wrapped endpoints
| Endpoint | Method | | Endpoint | Method |
@@ -166,14 +214,13 @@ that case the body is a short JSON message instead of CSV.
## Not covered ## Not covered
### Blocked on a dependency change ### Nothing is blocked on a dependency
**Multipart uploads** — `PUT /{resource}/{id}/upload` exists for clients, Multipart uploads used to be, and are now wrapped — see [Document
companies, credits, expenses, group_settings, invoices, payments, products, uploads](#document-uploads). One upload route is deliberately left out:
projects, purchase_orders, quotes, recurring_expenses, recurring_invoices, `PUT /edocument/upload` is not a document attachment. It imports an expense
recurring_quotes, tasks, and vendors. These need reqwest's `multipart` from an e-document (`ExpenseController::edocument`), and is better treated as
feature, which isn't currently enabled. This is the main reason `Document` an import endpoint alongside the others under *Import/export* below.
has no `create_document`.
### Resource groups not wrapped ### Resource groups not wrapped
Generated
+24
View File
@@ -600,6 +600,22 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" 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]] [[package]]
name = "mio" name = "mio"
version = "1.2.4" version = "1.2.4"
@@ -776,6 +792,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -784,6 +801,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
@@ -1163,6 +1181,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "unicase"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.27" version = "1.0.27"
+1 -1
View File
@@ -12,7 +12,7 @@ license = "MIT"
repository = "https://github.com/awkawb1/invoiceninja-sdk" repository = "https://github.com/awkawb1/invoiceninja-sdk"
[dependencies] [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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
thiserror = "2" thiserror = "2"
+20 -11
View File
@@ -141,11 +141,16 @@ 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 `COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
in order of value over effort: in order of value over effort:
1. **Multipart uploads** — 16 `PUT /{resource}/{id}/upload` routes plus **Done:** multipart document uploads, for the 16 resources that have the
`edocument/upload`. Blocked only on enabling reqwest's `multipart` route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
feature. This is also what makes `create_document` possible, so it also make `create_document` possible, which was wrong: upstream
unblocks the one obviously missing piece of an otherwise complete `DocumentController::store` is an empty stub, and an upload is the *only*
resource. 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.
Still to do:
2. **Per-resource extras** — mechanical, and they reuse existing patterns: 2. **Per-resource extras** — mechanical, and they reuse existing patterns:
`webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`, `webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`,
`client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the `client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the
@@ -159,9 +164,10 @@ in order of value over effort:
5. **Public invitation-key downloads** — eight routes that take no auth 5. **Public invitation-key downloads** — eight routes that take no auth
token, so they do not fit the authenticated request path and need a token, so they do not fit the authenticated request path and need a
different entry point. different entry point.
6. **Import/export and the integrations** — Stripe, QuickBooks, einvoice 6. **Import/export and the integrations** — `/import`, `/export`,
(17 routes), Yodlee, Nordigen, calendar. The largest block and the least `edocument/upload`, Stripe, QuickBooks, einvoice (17 routes), Yodlee,
SDK-shaped; worth doing last and possibly in pieces. 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 Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
stale quietly. stale quietly.
@@ -178,9 +184,12 @@ These are genuinely undecided. Deciding one is a change to this file.
eleven chart endpoints share no response shape. Per-chart structs would eleven chart endpoints share no response shape. Per-chart structs would
be eleven small models of unstable payloads; unclear whether that earns be eleven small models of unstable payloads; unclear whether that earns
its keep. its keep.
- **Large responses are buffered entirely into memory.** PDF downloads and - **Large bodies are buffered entirely into memory.** PDF downloads and CSV
CSV report exports go through `Vec<u8>`. Streaming would be better for report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
big exports but changes those signatures. 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 - **`Error::PaginationLimit` has no test**, because reaching it needs
10,001 mock round trips. Making `MAX_PAGES` injectable purely for a test 10,001 mock round trips. Making `MAX_PAGES` injectable purely for a test
may be worse than the gap. may be worse than the gap.
+22 -4
View File
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage ## Coverage
Every one of the API's 34 resource groups, plus users — 265 methods. Each Every one of the API's 34 resource groups, plus users — 284 methods. Each
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
(where the API offers one) `bulk`: (where the API offers one) `bulk`:
@@ -158,15 +158,33 @@ let pdf: Vec<u8> = client.download_invoice("abc123").await?;
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?; let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
``` ```
Reports return their raw export body (CSV for most of them), and the Documents are attached through the record they belong to, since that is the
schemaless endpoints decode into any type you choose: 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 ```rust
use serde_json::{json, Value}; use serde_json::{json, Value};
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?; let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
let totals: Value = client.chart("totals_v2", &json!({})).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 Add more resources by adding a model under `src/models/` and a
+105 -3
View File
@@ -1,7 +1,7 @@
use std::time::Duration; use std::time::Duration;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; 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 serde::{de::DeserializeOwned, Serialize};
use crate::error::{ApiErrorBody, Error, Result}; use crate::error::{ApiErrorBody, Error, Result};
@@ -14,6 +14,7 @@ use crate::models::{
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
}; };
use crate::retry::{self, RetryPolicy}; use crate::retry::{self, RetryPolicy};
use crate::upload::FileUpload;
/// API root of the hosted Invoice Ninja app. /// API root of the hosted Invoice Ninja app.
pub const HOSTED_BASE_URL: &str = "https://invoicing.co/api/v1"; pub const HOSTED_BASE_URL: &str = "https://invoicing.co/api/v1";
@@ -220,8 +221,16 @@ impl InvoiceNinjaClient {
/// configured [`RetryPolicy`]. /// configured [`RetryPolicy`].
/// ///
/// Every helper goes through here, so retrying is uniform. The request is /// 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 /// cloned before each attempt; a body that cannot be cloned simply gets
/// today, since all payloads are in-memory JSON) simply gets one attempt. /// 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 /// 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 /// out is surfaced, because the server may already have applied it and a
@@ -545,6 +554,44 @@ impl InvoiceNinjaClient {
Self::decode(response).await 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. /// Issues a request whose body is discarded, failing on non-2xx.
async fn get_discard(&self, path: &str) -> Result<()> { async fn get_discard(&self, path: &str) -> Result<()> {
self.get_raw(path).await.map(|_| ()) self.get_raw(path).await.map(|_| ())
@@ -609,6 +656,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, /// Generates the `GET /{resource}/{id}/{action}` helpers. Only invoices,
/// quotes, credits, and purchase orders expose this route. /// quotes, credits, and purchase orders expose this route.
macro_rules! entity_action_methods { macro_rules! entity_action_methods {
@@ -977,6 +1052,33 @@ impl InvoiceNinjaClient {
.await .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 ----------------------- // --- entity actions with non-entity responses -----------------------
/// Converts an invoice into a purchase order. Separate from /// Converts an invoice into a purchase order. Separate from
+8
View File
@@ -54,6 +54,14 @@ pub enum Error {
#[error("`{0}` cannot be used as a url path segment")] #[error("`{0}` cannot be used as a url path segment")]
InvalidPathSegment(String), 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 /// A `list_all_*` call kept receiving full pages past the point where any
/// plausible result set would have ended. /// plausible result set would have ended.
/// ///
+2
View File
@@ -18,7 +18,9 @@ mod client;
mod error; mod error;
pub mod models; pub mod models;
mod retry; mod retry;
mod upload;
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL}; pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
pub use error::{ApiErrorBody, Error, Result}; pub use error::{ApiErrorBody, Error, Result};
pub use retry::RetryPolicy; pub use retry::RetryPolicy;
pub use upload::FileUpload;
+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()
}
}
+274
View File
@@ -1176,3 +1176,277 @@ async fn no_budget_means_only_max_retries_bounds_the_call() {
.expect_err("still fails"); .expect_err("still fails");
assert_eq!(server.received_requests().await.unwrap().len(), 5); assert_eq!(server.received_requests().await.unwrap().len(), 5);
} }
// --- document uploads ---------------------------------------------------
use invoiceninjars::FileUpload;
fn pdf(name: &str) -> FileUpload {
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
}
fn body_text(request: &wiremock::Request) -> String {
String::from_utf8_lossy(&request.body).into_owned()
}
async fn upload_ok_server(route: &str) -> MockServer {
let server = MockServer::start().await;
Mock::given(path(route))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "abc" } })))
.mount(&server)
.await;
server
}
/// The route is registered as PUT, but PHP does not parse multipart on a PUT,
/// so the files would never arrive. The working form is POST with a
/// `_method=PUT` field, which Laravel turns back into a PUT before routing.
#[tokio::test]
async fn upload_is_a_post_that_spoofs_put_with_documents_as_an_array() {
let server = upload_ok_server("/clients/abc/upload").await;
let updated = client(&server)
.upload_client("abc", vec![pdf("receipt.pdf")], None)
.await
.expect("upload should succeed");
assert_eq!(updated.id.as_deref(), Some("abc"), "returns the entity");
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.method.as_str(),
"POST",
"a real PUT would drop the files"
);
let content_type = sent.headers.get("content-type").unwrap().to_str().unwrap();
assert!(
content_type.starts_with("multipart/form-data; boundary="),
"got {content_type}"
);
assert_eq!(
sent.headers.get("x-api-token").unwrap().to_str().unwrap(),
"test-token"
);
let body = body_text(sent);
assert!(body.contains("name=\"_method\"\r\n\r\nPUT"), "body: {body}");
assert!(
body.contains("name=\"documents[]\"; filename=\"receipt.pdf\""),
"body: {body}"
);
assert!(body.contains("Content-Type: application/pdf"));
assert!(
body.contains("%PDF-1.4 fake"),
"file bytes must be in the body"
);
}
/// Laravel's `boolean` rule accepts only true, false, 0, 1, '0' and '1' under
/// strict comparison, so the string "true" fails validation.
#[tokio::test]
async fn is_public_is_sent_as_one_or_zero_never_true_false() {
for (flag, expected) in [
(Some(true), Some("1")),
(Some(false), Some("0")),
(None, None),
] {
let server = upload_ok_server("/invoices/abc/upload").await;
client(&server)
.upload_invoice("abc", vec![pdf("a.pdf")], flag)
.await
.expect("upload should succeed");
let body = body_text(&server.received_requests().await.unwrap()[0]);
match expected {
Some(value) => assert!(
body.contains(&format!("name=\"is_public\"\r\n\r\n{value}\r\n")),
"is_public={flag:?} should send {value}, body: {body}"
),
None => assert!(
!body.contains("is_public"),
"an unset flag must be omitted, not defaulted, body: {body}"
),
}
assert!(!body.contains("true") && !body.contains("false"));
}
}
#[tokio::test]
async fn several_files_become_several_document_parts() {
let server = upload_ok_server("/expenses/abc/upload").await;
client(&server)
.upload_expense("abc", vec![pdf("one.pdf"), pdf("two.pdf")], None)
.await
.expect("upload should succeed");
let body = body_text(&server.received_requests().await.unwrap()[0]);
assert_eq!(body.matches("name=\"documents[]\"").count(), 2);
assert!(body.contains("filename=\"one.pdf\"") && body.contains("filename=\"two.pdf\""));
}
/// An upload with nothing in it is answered with a success that changed
/// nothing; that is caught locally instead.
#[tokio::test]
async fn invalid_uploads_are_rejected_before_any_request() {
let server = upload_ok_server("/clients/abc/upload").await;
let c = client(&server);
let none = c.upload_client("abc", vec![], None).await.unwrap_err();
assert!(matches!(none, Error::InvalidUpload(_)), "got {none:?}");
let nameless = c
.upload_client("abc", vec![FileUpload::new(" ", b"x".to_vec())], None)
.await
.unwrap_err();
assert!(
matches!(nameless, Error::InvalidUpload(_)),
"got {nameless:?}"
);
let bad_mime = c
.upload_client(
"abc",
vec![FileUpload::new("a.txt", b"x".to_vec()).mime("not a mime")],
None,
)
.await
.unwrap_err();
assert!(
matches!(bad_mime, Error::InvalidUpload(_)),
"got {bad_mime:?}"
);
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn upload_ids_get_the_same_path_protection() {
let server = upload_ok_server("/clients/x/upload").await;
let err = client(&server)
.upload_client("..", vec![pdf("a.pdf")], None)
.await
.unwrap_err();
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
/// An upload ADDS a document, so it is not idempotent whatever the HTTP verb.
/// A replay after a lost response would attach the file twice. This holds
/// only because reqwest's multipart body cannot be cloned, which nothing in
/// the types enforces, so it is pinned here for 5xx and 429 alike.
#[tokio::test]
async fn uploads_are_never_replayed() {
for status in [500u16, 503, 429] {
let server = MockServer::start().await;
Mock::given(path("/clients/abc/upload"))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
let err = client(&server)
.with_retry_policy(quick_retry())
.upload_client("abc", vec![pdf("a.pdf")], None)
.await
.expect_err("the failure should surface");
assert!(
matches!(err, Error::Api { status: s, .. } if s == status),
"got {err:?}"
);
assert_eq!(
server.received_requests().await.unwrap().len(),
1,
"a {status} on an upload must not be retried"
);
}
}
#[tokio::test]
async fn upload_validation_errors_keep_the_field_map() {
let server = MockServer::start().await;
Mock::given(path("/clients/abc/upload"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"message": "The given data was invalid.",
"errors": { "documents.0": ["The documents.0 must be a file of type: pdf."] }
})))
.mount(&server)
.await;
let err = client(&server)
.upload_client("abc", vec![pdf("a.exe")], None)
.await
.unwrap_err();
match err {
Error::Api { status, errors, .. } => {
assert_eq!(status, 422);
assert!(errors.contains_key("documents.0"));
}
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[test]
fn file_upload_debug_never_prints_the_contents() {
let file = FileUpload::new("secret.pdf", b"CONFIDENTIAL-CONTENTS".to_vec());
let rendered = format!("{file:?}");
assert!(!rendered.contains("CONFIDENTIAL"), "got {rendered}");
assert!(rendered.contains("secret.pdf") && rendered.contains("21"));
}
/// The sixteen methods come from one macro, so a typo in a path or a wrong
/// resource name would only show up here.
#[tokio::test]
async fn every_upload_method_targets_its_own_route() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": {} })))
.mount(&server)
.await;
let c = client(&server);
let f = || vec![pdf("a.pdf")];
c.upload_client("i", f(), None).await.unwrap();
c.upload_company("i", f(), None).await.unwrap();
c.upload_credit("i", f(), None).await.unwrap();
c.upload_expense("i", f(), None).await.unwrap();
c.upload_group_setting("i", f(), None).await.unwrap();
c.upload_invoice("i", f(), None).await.unwrap();
c.upload_payment("i", f(), None).await.unwrap();
c.upload_product("i", f(), None).await.unwrap();
c.upload_project("i", f(), None).await.unwrap();
c.upload_purchase_order("i", f(), None).await.unwrap();
c.upload_quote("i", f(), None).await.unwrap();
c.upload_recurring_expense("i", f(), None).await.unwrap();
c.upload_recurring_invoice("i", f(), None).await.unwrap();
c.upload_recurring_quote("i", f(), None).await.unwrap();
c.upload_task("i", f(), None).await.unwrap();
c.upload_vendor("i", f(), None).await.unwrap();
let paths: Vec<String> = server
.received_requests()
.await
.unwrap()
.iter()
.map(|r| r.url.path().to_string())
.collect();
assert_eq!(
paths,
[
"clients",
"companies",
"credits",
"expenses",
"group_settings",
"invoices",
"payments",
"products",
"projects",
"purchase_orders",
"quotes",
"recurring_expenses",
"recurring_invoices",
"recurring_quotes",
"tasks",
"vendors",
]
.map(|resource| format!("/{resource}/i/upload"))
);
}