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:
+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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user