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
+105 -3
View File
@@ -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
+8
View File
@@ -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.
///
+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;
+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()
}
}