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>
6.0 KiB
6.0 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- Document uploads:
upload_client,upload_invoice,upload_expense, and thirteen more, one for each resource with an/{id}/uploadroute. They take aVec<FileUpload>and an optionalis_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 nocreate_document. Enables reqwest'smultipartfeature, which addsmime,mime_guess, andunicaseand leaves the MSRV at 1.88. FileUploadandError::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. CI builds on both the MSRV and the pinned dev toolchain, plus a weekly advisory job on latest stable that reports breakage from a new Rust release. The advisory job fails visibly rather than silently; it is kept non-blocking through branch protection rather thancontinue-on-error. - Wrappers for every one of the API's 34
Route::resourcegroups, plususers— 284 methods (including document uploads). SeeCOVERAGE.mdfor the per-endpoint inventory. COVERAGE.md, a detailed record of what is wrapped, what is not, and why, checked againstroutes/api.phpon thev5-stablebranch.- Retry with full-jitter exponential backoff, configurable through
RetryPolicyandInvoiceNinjaClient::with_retry_policy. Defaults to 3 retries.Retry-Afteris honoured when the server sends it. InvoiceNinjaClient::with_timeoutand aDEFAULT_TIMEOUTof 60 seconds applied to every request.- Typed responses for previously untyped endpoints:
Statics,StaticItem,Currency,DateFormat,SearchResults,SearchHit,HealthCheck, andPhpVersion. statics_as,search_as, andhealth_check_asas generic escape hatches for callers who need to pin a shape the typed models leave inextra.tests/client_requests.rs: 50 tests covering the request plumbing against a loopbackwiremockserver. No network or credentials required.RetryPolicy::max_elapsed, a ceiling on the time one call may spend across all attempts, defaulting to 120 seconds. The request timeout is per attempt, so without this the default policy could spend four minutes on a call that times out repeatedly.
Changed
statics,search, andhealth_checkreturn concrete types instead of being generic overT: DeserializeOwned.send_emailnow unwraps thedataenvelope, soTis the entity (Invoice,Quote, …) rather than a wrapper the caller had to model. It stays generic because the endpoint serves all four entity types.chartremains generic, deliberately: the eleven chart endpoints share no common response shape, so there is nothing to narrow to.- The base URL is parsed and validated when the client is constructed. A missing scheme used to produce a 404 on every call instead of one error.
list_all_*terminates onmeta.pagination.total_pageswhen the server sends it, falling back to the short-page heuristic only when it does not.Erroris now#[non_exhaustive], so later variants are not a breaking change. Matches on it need a catch-all arm.Error::Jsonis now a struct variant carrying anexcerptof the body that failed to decode. The serde message alone ("expected value at line 1 column 1") does not reveal that the response was, say, an HTML error page from a proxy or a login redirect from a wrong base URL.- API errors with an empty or message-less body now report the status reason ("Not Found") instead of rendering blank.
Fixed
list_all_*silently truncated results. The server caps page size atmin(abs(per_page), 5000), so a page shorter than requested did not mean the last page — the walk stopped early and returned a partial list as though it were complete.per_pageis now clamped to the server maximum so the fallback heuristic holds.list_all_*withper_page(0)never terminated: the checkgot < 0has no solution. It now falls back to the default page size, and aMAX_PAGESbackstop raisesError::PaginationLimitrather than quietly truncating.- Requests could hang indefinitely.
reqwestapplies no timeout of its own and none was configured. - Ids were interpolated into request paths unescaped, so a
/,?, or#in an id changed which endpoint was addressed.
Security
- Ids used as URL path segments are escaped, and ids that are empty or
consist only of dots are rejected. Escaping alone is insufficient: the
WHATWG rules the
urlcrate implements decode%2Eback to.before resolving dot segments, so an escaped..still collapsed the path. An empty id would have addressed the collection route, makingdelete_client("")meanDELETE /clients. POSTis never replayed on a 5xx or timeout, since the server may have applied it before the response was lost and a replay would duplicate an invoice, payment, or email. A 429 is the exception — the server states it refused the request, so no work was done.- The API token and password are held as headers marked sensitive, so
neither appears in
Debugoutput.reqwest's ownClientdebug prints its default headers, so this is the only thing keeping the token out of logs; there is now a regression test pinning it.
[0.1.0] - 2026-10-23
Added
- Initial release: async client for the Invoice Ninja v5 API covering clients, invoices, quotes, credits, payments, products, expenses, projects, tasks, and vendors.