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:
+11
-1
@@ -9,6 +9,16 @@ 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.
|
||||
- `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 +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
|
||||
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` — 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,
|
||||
checked against `routes/api.php` on the `v5-stable` branch.
|
||||
- Retry with full-jitter exponential backoff, configurable through
|
||||
|
||||
@@ -161,6 +161,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
|
||||
@@ -224,16 +236,16 @@ changes, doc tweaks) does not need an entry.
|
||||
## Coverage
|
||||
|
||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
only, 4 with entity actions, and 25 hand-written.
|
||||
(which isn't one). 284 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
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
|
||||
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
|
||||
gap, and the reason for each gap. The main remaining gaps are a handful of
|
||||
per-resource extras
|
||||
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
|
||||
integration/auth routes.
|
||||
|
||||
|
||||
+55
-8
@@ -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). 284 methods in total.
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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 |
|
||||
@@ -166,14 +214,13 @@ that case the body is a short JSON message instead of CSV.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Generated
+24
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
**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.
|
||||
|
||||
Still to do:
|
||||
|
||||
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
|
||||
@@ -159,9 +164,10 @@ in order of value over effort:
|
||||
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 +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
|
||||
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.
|
||||
|
||||
@@ -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 — 284 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
|
||||
|
||||
+105
-3
@@ -1,7 +1,7 @@
|
||||
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};
|
||||
@@ -14,6 +14,7 @@ use crate::models::{
|
||||
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";
|
||||
@@ -220,8 +221,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,6 +554,44 @@ 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(|_| ())
|
||||
@@ -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,
|
||||
/// quotes, credits, and purchase orders expose this route.
|
||||
macro_rules! entity_action_methods {
|
||||
@@ -977,6 +1052,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
|
||||
|
||||
@@ -54,6 +54,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.
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1176,3 +1176,277 @@ async fn no_budget_means_only_max_retries_bounds_the_call() {
|
||||
.expect_err("still fails");
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user