The "auth routes" open question in GOALS.md conflated four unrelated things once the upstream routes and controllers were checked: logout and the passkey settings routes sit behind the same token_auth middleware as everything else this crate wraps, login needs a credential exchange since there's no token yet, refresh/refresh_react turned out to be the bulk sync-dump endpoint already parked as a separate open question, and connected_account is Gmail OAuth linking that belongs with Integrations. Adds logout, list_passkeys, delete_passkey (passkey registration is left out — it needs a WebAuthn ceremony a browser has to drive), and InvoiceNinjaClient::login as an associated function returning a lean LoginResponse that types only is_owner/is_admin/token and leaves the server's much larger nested payload in extra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
13 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
- Logout and passkey management:
logout,list_passkeys, anddelete_passkey.logoutregenerates every system-issued token on the whole company, not just the caller's, so it ends every session sharing it. Passkey registration is not wrapped — it needs a WebAuthn ceremony only a browser can drive. AddsPasskey. InvoiceNinjaClient::login, an associated function (not a method, since there is no token yet to build a client with) that exchanges an email and password for an API token, optionally with a one-time password for two-factor accounts. AddsLoginResponseandLoginCompanyUser; the nested company/user/account data the server also returns is left inextrarather than modelled. Does not cover passkey login, SSO, or/login/precheck.
[0.2.0] - 2026-10-25
Added
- E-invoicing, seventeen methods:
validate_einvoice,einvoice_configurations,einvoice_quota,einvoice_health_check,einvoice_delivery_map,update_einvoice_token, and elevenpeppol_*. Three behave unlike the rest of the API, and the docs on each say so: a failingvalidate_einvoiceis a422, so an ordinary "not Peppol-ready" result arrives asError::Api; the Peppol handlers return the error message as the whole body rather than{"message": …}, soError::Api'serrorsmap is empty for them; andpeppol_setuphas two success shapes, henceOption<Value>.peppol_remove_legal_identifieris the crate's onlyDELETEwith a request body. - Integrations, sixteen methods: four
quickbooks_*, fourstripe_*(all password-protected), sixcalendar_connectionmethods, plusyodlee_account_statusandnordigen_institutions. These operate a connection that already exists — establishing one is an OAuth browser flow whose routes take no API token and cannot be wrapped. - Import and export, five methods:
export,preimport,import,import_json, andupload_edocument. A CSV import is two calls —preimportuploads the files asfiles[<entity_type>]and returns the column mapping the server suggests, thenimportstarts the ingest from the returnedhash. The route's one-shot form (files and mapping in a single multipart request) is deliberately not wrapped: a mapping is nested, and bracket-encoding nested structures is what this crate avoids by sending JSON.upload_edocumentimports expenses from e-documents and is not a document attachment, which is why it is not anupload_*method. AddsExportJob(the export's collection URL expires in 1–5 hours) andImportPreview. All of these queue work, so a success means accepted, not done. - Invitation-key downloads, eight methods:
download_invoice_by_invitation_keyanddownload_e_invoice_by_invitation_key, plus the same pair for quotes, credits, and purchase orders. These take the per-contact invitation key from a client-portal link rather than a record id, and return the PDF or the XML e-document as bytes. Prefer them over the id-baseddownload_*when the document is for a particular contact: the server renders in that contact's locale, so a client with contacts in two languages gets a different document per key. An unknown key is a400carryingno record found.COVERAGE.mdpreviously listed these as out of scope on the grounds that they need no auth token; that was a misreading — they are ordinary authenticated/api/v1routes. - 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. - Per-resource extras, nineteen methods:
retry_webhook,sort_tasks,set_default_design,set_default_client_gateway_token,subscription_steps,check_subscription_steps, the company-gatewaytest/clone/import_customerstrio, four bank-integration and bank-transaction routes, and six client routes (update_client_tax_data,client_show_settings,client_peppol_discovery,check_client_vat,client_vat_status,client_documents). Each is aPOSTunless noted inCOVERAGE.md, so none is retried on a 5xx. AddsMessageResponsefor the{"message": ...}replies andSubscriptionStep. - Password-protected actions, eight methods:
purge_client,merge_clients,merge_vendors,purge_company,purge_company_save_settings,invite_user,purge_user, andnew_user_template. They needwith_api_password, most cannot be undone, and none is retried on a 5xx. A merge keeps the first id and folds the second into it. Checked against the upstream controllers and mock servers; not yet against a real instance. - Activity log, email history and previews, fourteen methods:
list_activities,list_all_activities,activity_feed,entity_activity,add_activity_note,download_activity_entity,client_email_history,entity_email_history, and the previewspreview,preview_html,live_preview,live_design,preview_purchase_orderandlive_preview_purchase_order. AddsActivity,ActivityFeedEntryandActivityRef. Email history is returned as raw JSON. SeeCOVERAGE.mdfor which were confirmed against a real server and the easy mistakes (a singular entity name in one route, a plural in the next). 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— 325 methods (including document uploads, per-resource extras, password-protected actions, and activity, email-history and preview routes). 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
- The crate is renamed from
invoiceninjarstoinvoiceninja-sdk, to match the repository it has always lived in (gitea.awkawb.cloud/awkawb/invoiceninja-sdk). Depend on it asinvoiceninja-sdkinCargo.toml, and import it asinvoiceninja_sdk(Cargo lowercases a package name's hyphens to underscores for the crate identifier) — everyuse invoiceninjars::...becomesuse invoiceninja_sdk::.... Not yet published to crates.io, so this lands before the name was ever claimed there. 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.
Error::Httpnow renders its whole cause chain.reqwest's own message stops at "error sending request for url (...)", leaving out the part that says why — "Connection refused", a DNS failure, a TLS handshake error — so anyone printing the error had no way to tell which it was.
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.- Creating a recurring invoice or recurring expense failed after the record
had been created: a real server returns
frequency_idas the string"5"and the field was a strict integer. It now accepts a number or a numeric string. - Creating a purchase order, location, or webhook from a default model was
rejected by a real server. An unset
PurchaseOrder.status_id,Location.country_id, orWebhook.formatwas sent as"", which the server turns intoNULLand then refuses (a 500 for the purchase order and webhook, a 422 for the location). They are now left out of the request when empty. with_api_passwordrefused a password containing a control character (a newline, say) with a misleadingInvalidTokenerror. Such a password is now sent base64-encoded asX-API-PASSWORD-BASE64, the form the server's middleware accepts for exactly this.- A payment whose
invoicesentry had anullor missinginvoice_idoramountfailed to decode the whole payment. Both now default. list_system_logsfailed to decode against a real server.SystemLog'screated_atandupdated_atwere typed as strings, following the OpenAPI spec, but the server sends integer timestamps. They are nowi64, like every other model.- 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.