Initial commit: async Rust client for the Invoice Ninja v5 API
CI / test (push) Canceled after 0s

Covers Clients, Invoices, Quotes, Credits, Payments, Products, Expenses,
Projects, Tasks, and Vendors, each with list, list_all, get, create,
update, and delete, generated by the resource_methods! macro.

Every model carries a #[serde(flatten)] extra map so fields the API
returns but the crate does not model survive a get/update round-trip
instead of being silently dropped.

Includes a Nix dev shell (Rust via rust-overlay), a GitHub Actions CI
workflow running build/test/fmt/clippy, and round-trip tests for the
extra-preservation invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 14:30:01 -04:00
co-authored by Claude Opus 5
commit ff9a859d0b
25 changed files with 2544 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
use reqwest::{header, StatusCode};
use serde::{de::DeserializeOwned, Serialize};
use crate::error::{ApiErrorBody, Error, Result};
use crate::models::{
Client as ClientModel, Credit, Expense, Invoice, ItemResponse, ListParams, ListResponse,
Payment, Product, Project, Quote, Task, Vendor,
};
/// An async client for the Invoice Ninja v5 API.
///
/// `base_url` should point at the API root, e.g. `https://invoicing.co/api/v1`
/// for the hosted app, or `https://your-domain.example/api/v1` for a
/// self-hosted install.
#[derive(Debug, Clone)]
pub struct InvoiceNinjaClient {
http: reqwest::Client,
base_url: String,
}
impl InvoiceNinjaClient {
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
let base_url = base_url.into();
if base_url.trim().is_empty() {
return Err(Error::InvalidBaseUrl(base_url));
}
let base_url = base_url.trim_end_matches('/').to_string();
let mut headers = header::HeaderMap::new();
let mut token_value = header::HeaderValue::from_str(&api_token.into())
.map_err(|e| Error::InvalidToken(e.to_string()))?;
token_value.set_sensitive(true);
headers.insert("X-Api-Token", token_value);
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
);
headers.insert(
"X-Requested-With",
header::HeaderValue::from_static("XMLHttpRequest"),
);
let http = reqwest::Client::builder()
.default_headers(headers)
.build()?;
Ok(Self { http, base_url })
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
async fn decode<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
Ok(serde_json::from_slice(&bytes)?)
} else {
Err(Self::api_error(status, &bytes))
}
}
fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
match serde_json::from_slice::<ApiErrorBody>(bytes) {
Ok(body) => Error::Api {
status,
message: body.message,
errors: body.errors,
},
Err(_) => Error::Api {
status,
message: String::from_utf8_lossy(bytes).into_owned(),
errors: Default::default(),
},
}
}
async fn get_list<T: DeserializeOwned>(
&self,
path: &str,
params: &ListParams,
) -> Result<Vec<T>> {
let response = self.http.get(self.url(path)).query(params).send().await?;
let body: ListResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
/// Pages through a list endpoint, following `page`/`per_page` until a
/// page comes back shorter than requested (or empty).
async fn get_all_pages<T: DeserializeOwned>(
&self,
path: &str,
params: &ListParams,
) -> Result<Vec<T>> {
const DEFAULT_PAGE_SIZE: u64 = 50;
let per_page = params.per_page.unwrap_or(DEFAULT_PAGE_SIZE);
let mut page = params.page.unwrap_or(1);
let mut params = params.clone();
params.per_page = Some(per_page);
let mut all = Vec::new();
loop {
params.page = Some(page);
let response = self.http.get(self.url(path)).query(&params).send().await?;
let body: ListResponse<T> = Self::decode(response).await?;
let got = body.data.len() as u64;
all.extend(body.data);
if got < per_page {
break;
}
page += 1;
}
Ok(all)
}
async fn get_one<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let response = self.http.get(self.url(path)).send().await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
async fn post_one<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
payload: &B,
) -> Result<T> {
let response = self.http.post(self.url(path)).json(payload).send().await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
async fn put_one<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
payload: &B,
) -> Result<T> {
let response = self.http.put(self.url(path)).json(payload).send().await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
async fn delete_one(&self, path: &str) -> Result<()> {
let response = self.http.delete(self.url(path)).send().await?;
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
Ok(())
} else {
Err(Self::api_error(status, &bytes))
}
}
}
/// Generates typed list/list_all/get/create/update/delete methods for a
/// resource backed by a single Invoice Ninja REST endpoint (e.g. `/clients`).
macro_rules! resource_methods {
($list:ident, $list_all:ident, $get:ident, $create:ident, $update:ident, $delete:ident, $path:literal, $model:ty) => {
pub async fn $list(&self, params: &ListParams) -> Result<Vec<$model>> {
self.get_list($path, params).await
}
/// Like the paginated list method, but automatically follows
/// pagination and returns every matching record across all pages.
pub async fn $list_all(&self, params: &ListParams) -> Result<Vec<$model>> {
self.get_all_pages($path, params).await
}
pub async fn $get(&self, id: &str) -> Result<$model> {
self.get_one(&format!("{}/{}", $path, id)).await
}
pub async fn $create(&self, body: &$model) -> Result<$model> {
self.post_one($path, body).await
}
pub async fn $update(&self, id: &str, body: &$model) -> Result<$model> {
self.put_one(&format!("{}/{}", $path, id), body).await
}
pub async fn $delete(&self, id: &str) -> Result<()> {
self.delete_one(&format!("{}/{}", $path, id)).await
}
};
}
impl InvoiceNinjaClient {
resource_methods!(
list_clients,
list_all_clients,
get_client,
create_client,
update_client,
delete_client,
"clients",
ClientModel
);
resource_methods!(
list_invoices,
list_all_invoices,
get_invoice,
create_invoice,
update_invoice,
delete_invoice,
"invoices",
Invoice
);
resource_methods!(
list_quotes,
list_all_quotes,
get_quote,
create_quote,
update_quote,
delete_quote,
"quotes",
Quote
);
resource_methods!(
list_credits,
list_all_credits,
get_credit,
create_credit,
update_credit,
delete_credit,
"credits",
Credit
);
resource_methods!(
list_payments,
list_all_payments,
get_payment,
create_payment,
update_payment,
delete_payment,
"payments",
Payment
);
resource_methods!(
list_products,
list_all_products,
get_product,
create_product,
update_product,
delete_product,
"products",
Product
);
resource_methods!(
list_expenses,
list_all_expenses,
get_expense,
create_expense,
update_expense,
delete_expense,
"expenses",
Expense
);
resource_methods!(
list_projects,
list_all_projects,
get_project,
create_project,
update_project,
delete_project,
"projects",
Project
);
resource_methods!(
list_tasks,
list_all_tasks,
get_task,
create_task,
update_task,
delete_task,
"tasks",
Task
);
resource_methods!(
list_vendors,
list_all_vendors,
get_vendor,
create_vendor,
update_vendor,
delete_vendor,
"vendors",
Vendor
);
}
+35
View File
@@ -0,0 +1,35 @@
use serde::Deserialize;
use std::collections::HashMap;
/// Error shape returned by the Invoice Ninja API for failed requests.
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorBody {
#[serde(default)]
pub message: String,
#[serde(default)]
pub errors: HashMap<String, Vec<String>>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("http transport error: {0}")]
Http(#[from] reqwest::Error),
#[error("failed to decode JSON response: {0}")]
Json(#[from] serde_json::Error),
#[error("api returned {status}: {message}")]
Api {
status: reqwest::StatusCode,
message: String,
errors: HashMap<String, Vec<String>>,
},
#[error("invalid base url: {0}")]
InvalidBaseUrl(String),
#[error("invalid api token: {0}")]
InvalidToken(String),
}
pub type Result<T> = std::result::Result<T, Error>;
+22
View File
@@ -0,0 +1,22 @@
//! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
//!
//! ```no_run
//! use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
//!
//! # async fn run() -> invoiceninjars::Result<()> {
//! let client = InvoiceNinjaClient::new(
//! "https://invoicing.co/api/v1",
//! std::env::var("IN_API_TOKEN").unwrap(),
//! )?;
//!
//! let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
//! # Ok(())
//! # }
//! ```
mod client;
mod error;
pub mod models;
pub use client::InvoiceNinjaClient;
pub use error::{ApiErrorBody, Error, Result};
+72
View File
@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Client {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub id_number: String,
#[serde(default)]
pub vat_number: String,
#[serde(default)]
pub address1: String,
#[serde(default)]
pub address2: String,
#[serde(default)]
pub city: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub postal_code: String,
#[serde(default)]
pub country_id: String,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub website: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub balance: f64,
#[serde(default)]
pub paid_to_date: f64,
#[serde(default)]
pub credit_balance: f64,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub contacts: Vec<ClientContact>,
/// Any fields returned by the API that aren't modeled above are preserved here,
/// so a `get` followed by an `update` round-trips without data loss.
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClientContact {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub first_name: String,
#[serde(default)]
pub last_name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub is_primary: bool,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+83
View File
@@ -0,0 +1,83 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
pub struct ListResponse<T> {
pub data: Vec<T>,
#[serde(default)]
pub meta: Option<Meta>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ItemResponse<T> {
pub data: T,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Meta {
#[serde(default)]
pub pagination: Option<Pagination>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Pagination {
pub total: u64,
pub count: u64,
pub per_page: u64,
pub current_page: u64,
pub total_pages: u64,
}
/// Query parameters accepted by Invoice Ninja's list endpoints.
#[derive(Debug, Clone, Default, Serialize)]
pub struct ListParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub per_page: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<String>,
/// Comma-separated list of relationships to eager-load, e.g. "client,payments".
#[serde(skip_serializing_if = "Option::is_none")]
pub include: Option<String>,
/// Set to 1 to include archived/deleted records.
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
impl ListParams {
pub fn new() -> Self {
Self::default()
}
pub fn page(mut self, page: u64) -> Self {
self.page = Some(page);
self
}
pub fn per_page(mut self, per_page: u64) -> Self {
self.per_page = Some(per_page);
self
}
pub fn filter(mut self, filter: impl Into<String>) -> Self {
self.filter = Some(filter.into());
self
}
pub fn sort(mut self, sort: impl Into<String>) -> Self {
self.sort = Some(sort.into());
self
}
pub fn include(mut self, include: impl Into<String>) -> Self {
self.include = Some(include.into());
self
}
pub fn status(mut self, status: impl Into<String>) -> Self {
self.status = Some(status.into());
self
}
}
+51
View File
@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Expense {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub vendor_id: String,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub invoice_id: String,
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub exchange_rate: f64,
#[serde(default)]
pub public_notes: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub date: String,
#[serde(default)]
pub payment_date: String,
#[serde(default)]
pub transaction_reference: String,
#[serde(default)]
pub category_id: String,
#[serde(default)]
pub tax_name1: String,
#[serde(default)]
pub tax_rate1: f64,
#[serde(default)]
pub tax_name2: String,
#[serde(default)]
pub tax_rate2: f64,
#[serde(default)]
pub should_be_invoiced: bool,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+85
View File
@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Invoice {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub number: String,
#[serde(default)]
pub date: String,
#[serde(default)]
pub due_date: String,
#[serde(default)]
pub po_number: String,
#[serde(default)]
pub status_id: String,
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub balance: f64,
#[serde(default)]
pub discount: f64,
#[serde(default)]
pub is_amount_discount: bool,
#[serde(default)]
pub public_notes: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub terms: String,
#[serde(default)]
pub footer: String,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub line_items: Vec<InvoiceItem>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InvoiceItem {
#[serde(default)]
pub product_key: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub cost: f64,
#[serde(default)]
pub quantity: f64,
#[serde(default)]
pub discount: f64,
#[serde(default)]
pub tax_name1: String,
#[serde(default)]
pub tax_rate1: f64,
#[serde(default)]
pub tax_name2: String,
#[serde(default)]
pub tax_rate2: f64,
#[serde(default)]
pub line_total: f64,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// Invoice Ninja stores quotes in the same table/schema as invoices
/// (an `entity_type` distinguishes them), so the `/quotes` endpoint
/// returns the same JSON shape as `/invoices`.
pub type Quote = Invoice;
/// Likewise, credits share the invoices table/schema, so the `/credits`
/// endpoint returns the same JSON shape as `/invoices`.
pub type Credit = Invoice;
+19
View File
@@ -0,0 +1,19 @@
mod client;
mod common;
mod expense;
mod invoice;
mod payment;
mod product;
mod project;
mod task;
mod vendor;
pub use client::{Client, ClientContact};
pub use common::{ItemResponse, ListParams, ListResponse, Meta, Pagination};
pub use expense::Expense;
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
pub use payment::{Payment, PaymentInvoice};
pub use product::Product;
pub use project::Project;
pub use task::Task;
pub use vendor::{Vendor, VendorContact};
+46
View File
@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Payment {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub applied: f64,
#[serde(default)]
pub refunded: f64,
#[serde(default)]
pub date: String,
#[serde(default)]
pub transaction_reference: String,
#[serde(default)]
pub type_id: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub invoices: Vec<PaymentInvoice>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PaymentInvoice {
pub invoice_id: String,
pub amount: f64,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+37
View File
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Product {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub product_key: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub cost: f64,
#[serde(default)]
pub price: f64,
#[serde(default)]
pub quantity: f64,
#[serde(default)]
pub tax_name1: String,
#[serde(default)]
pub tax_rate1: f64,
#[serde(default)]
pub tax_name2: String,
#[serde(default)]
pub tax_rate2: f64,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+37
View File
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub number: String,
#[serde(default)]
pub task_rate: f64,
#[serde(default)]
pub due_date: String,
#[serde(default)]
pub budgeted_hours: f64,
#[serde(default)]
pub color: String,
#[serde(default)]
pub public_notes: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+36
View File
@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Task {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub invoice_id: String,
#[serde(default)]
pub project_id: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub rate: f64,
#[serde(default)]
pub status_id: String,
#[serde(default)]
pub is_running: bool,
#[serde(default)]
pub is_deleted: bool,
/// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API.
#[serde(default)]
pub time_log: String,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+66
View File
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Vendor {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub id_number: String,
#[serde(default)]
pub vat_number: String,
#[serde(default)]
pub address1: String,
#[serde(default)]
pub address2: String,
#[serde(default)]
pub city: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub postal_code: String,
#[serde(default)]
pub country_id: String,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub website: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub contacts: Vec<VendorContact>,
/// Any fields returned by the API that aren't modeled above are preserved here,
/// so a `get` followed by an `update` round-trips without data loss.
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VendorContact {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub first_name: String,
#[serde(default)]
pub last_name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub is_primary: bool,
#[serde(flatten)]
pub extra: Map<String, Value>,
}