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:
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.rs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{yml,yaml,toml,nix}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,22 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- run: cargo build --all-targets
|
||||
- run: cargo test
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
.devenv/
|
||||
.direnv/
|
||||
*.swp
|
||||
@@ -0,0 +1,72 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||
|
||||
## Development environment
|
||||
|
||||
This project uses Nix for toolchain management (Rust via `rust-overlay`):
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
```
|
||||
|
||||
## Common commands
|
||||
|
||||
```sh
|
||||
cargo build
|
||||
cargo test
|
||||
cargo test <test_name> # run a single test
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo fmt
|
||||
|
||||
# Run the example (requires IN_API_TOKEN, optionally IN_BASE_URL)
|
||||
IN_API_TOKEN=... cargo run --example list_clients
|
||||
```
|
||||
|
||||
Tests live in `tests/` (integration-style, no network access required);
|
||||
`src/` has no `#[test]` modules.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
|
||||
`X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers
|
||||
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`) handle request
|
||||
dispatch and decode `ListResponse<T>` / `ItemResponse<T>` envelopes, mapping
|
||||
non-2xx responses through `api_error` into `Error::Api`.
|
||||
- Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.)
|
||||
are generated by the `resource_methods!` macro at the bottom of
|
||||
`client.rs`, which expands to
|
||||
`list`/`list_all`/`get`/`create`/`update`/`delete` for a given REST path and
|
||||
model type. **To add a new resource**: add a model under `src/models/`,
|
||||
re-export it from `src/models/mod.rs`, and add one `resource_methods!`
|
||||
invocation in `client.rs`. Note the macro takes six method names before the
|
||||
path and model — adding a resource means passing all six.
|
||||
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
|
||||
follows `page`/`per_page` until a page returns fewer rows than requested.
|
||||
- `src/models/` — one file per resource (`client.rs`, `invoice.rs`,
|
||||
`payment.rs`, `product.rs`, `expense.rs`, `project.rs`, `task.rs`,
|
||||
`vendor.rs`), plus `common.rs` for shared envelope/pagination types
|
||||
(`ListParams`, `ListResponse`, `ItemResponse`, `Meta`, `Pagination`).
|
||||
Invoice Ninja stores quotes and credits in the same table as invoices (an
|
||||
`entity_type` field distinguishes them), so `Quote` and `Credit` are type
|
||||
aliases for `Invoice` (`src/models/invoice.rs`) and share the
|
||||
`/quotes`, `/credits`, and `/invoices` paths via the macro.
|
||||
- Every model struct has a `#[serde(flatten)] pub extra: Map<String, Value>`
|
||||
field. This preserves any API fields not explicitly modeled, so a `get`
|
||||
followed by an `update` round-trips without silently dropping data. Keep
|
||||
this pattern when adding fields or new models.
|
||||
- `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON
|
||||
decode, API error responses (with per-field validation `errors` map), and
|
||||
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
|
||||
JSON shape.
|
||||
|
||||
## Coverage
|
||||
|
||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
||||
and `delete`.
|
||||
Generated
+1169
File diff suppressed because it is too large
Load Diff
+16
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "invoiceninjars"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "An async Rust client for the Invoice Ninja v5 API"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/bryant/invoiceninjars"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 bryant
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,44 @@
|
||||
# invoiceninjars
|
||||
|
||||
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||
|
||||
## Development
|
||||
|
||||
Enter the dev shell (Rust toolchain via `rust-overlay`):
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
||||
|
||||
let client = InvoiceNinjaClient::new(
|
||||
"https://invoicing.co/api/v1", // or your self-hosted base URL
|
||||
std::env::var("IN_API_TOKEN")?,
|
||||
)?;
|
||||
|
||||
let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
|
||||
```
|
||||
|
||||
List endpoints are paginated. `list_*` returns a single page; `list_all_*`
|
||||
follows pagination and returns every matching record:
|
||||
|
||||
```rust
|
||||
let all_clients = client.list_all_clients(&ListParams::new()).await?;
|
||||
```
|
||||
|
||||
Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`):
|
||||
|
||||
```sh
|
||||
IN_API_TOKEN=... cargo run --example list_clients
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
||||
and `delete`. Add more resources by adding a model under `src/models/` and a
|
||||
`resource_methods!` invocation in `src/client.rs`.
|
||||
@@ -0,0 +1,21 @@
|
||||
use invoiceninjars::models::ListParams;
|
||||
use invoiceninjars::InvoiceNinjaClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> invoiceninjars::Result<()> {
|
||||
let base_url =
|
||||
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into());
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
|
||||
let client = InvoiceNinjaClient::new(base_url, token)?;
|
||||
|
||||
let clients = client
|
||||
.list_clients(&ListParams::new().per_page(10).sort("created_at|desc"))
|
||||
.await?;
|
||||
|
||||
for c in clients {
|
||||
println!("{:?}\t{}", c.id, c.name);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1791914995,
|
||||
"narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792502884,
|
||||
"narHash": "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "02c087f3fe673c9e317f206a627cb6b910336dbe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792739799,
|
||||
"narHash": "sha256-M2rr/yPbTHvgAzCyZ+zHJHflFM0XU7t42Sw2skA3TQM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b0829a2cc9f7b596583331d78a942c1e5170d8a0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
description = "invoiceninjars: a Rust client for the Invoice Ninja v5 API";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
rustToolchain
|
||||
pkgs.pkg-config
|
||||
pkgs.openssl
|
||||
];
|
||||
|
||||
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
});
|
||||
}
|
||||
+300
@@ -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(¶ms).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
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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};
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use invoiceninjars::models::{
|
||||
Client, Credit, Expense, Invoice, ListParams, Project, Quote, Task, Vendor,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn client_round_trip_preserves_unknown_fields() {
|
||||
let payload = json!({
|
||||
"id": "abc123",
|
||||
"name": "Acme Inc",
|
||||
"custom_value1": "unmodeled field",
|
||||
});
|
||||
|
||||
let client: Client = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(client.name, "Acme Inc");
|
||||
assert_eq!(
|
||||
client.extra.get("custom_value1").unwrap(),
|
||||
"unmodeled field"
|
||||
);
|
||||
|
||||
let round_tripped = serde_json::to_value(&client).unwrap();
|
||||
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoice_round_trip_preserves_unknown_fields() {
|
||||
let payload = json!({
|
||||
"id": "inv1",
|
||||
"client_id": "abc123",
|
||||
"number": "INV-0001",
|
||||
"custom_surcharge1": 12.5,
|
||||
});
|
||||
|
||||
let invoice: Invoice = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(invoice.number, "INV-0001");
|
||||
assert_eq!(invoice.extra.get("custom_surcharge1").unwrap(), 12.5);
|
||||
|
||||
let round_tripped = serde_json::to_value(&invoice).unwrap();
|
||||
assert_eq!(round_tripped["custom_surcharge1"], 12.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_params_omits_unset_fields() {
|
||||
let params = ListParams::new().per_page(25).sort("created_at|desc");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(¶ms).unwrap(),
|
||||
json!({"per_page": 25, "sort": "created_at|desc"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_round_trip_preserves_nested_contact_fields() {
|
||||
let payload = json!({
|
||||
"id": "ven1",
|
||||
"name": "Supply Co",
|
||||
"custom_value2": "unmodeled vendor field",
|
||||
"contacts": [{
|
||||
"id": "vc1",
|
||||
"first_name": "Ada",
|
||||
"email": "ada@supply.example",
|
||||
"is_primary": true,
|
||||
"custom_value1": "unmodeled contact field",
|
||||
}],
|
||||
});
|
||||
|
||||
let vendor: Vendor = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(vendor.name, "Supply Co");
|
||||
assert_eq!(vendor.contacts.len(), 1);
|
||||
assert_eq!(vendor.contacts[0].first_name, "Ada");
|
||||
assert!(vendor.contacts[0].is_primary);
|
||||
|
||||
let round_tripped = serde_json::to_value(&vendor).unwrap();
|
||||
assert_eq!(round_tripped["custom_value2"], "unmodeled vendor field");
|
||||
assert_eq!(
|
||||
round_tripped["contacts"][0]["custom_value1"],
|
||||
"unmodeled contact field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expense_round_trip_preserves_unknown_fields() {
|
||||
let payload = json!({
|
||||
"id": "exp1",
|
||||
"vendor_id": "ven1",
|
||||
"amount": 42.75,
|
||||
"should_be_invoiced": true,
|
||||
"custom_value3": "unmodeled field",
|
||||
});
|
||||
|
||||
let expense: Expense = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(expense.vendor_id, "ven1");
|
||||
assert_eq!(expense.amount, 42.75);
|
||||
assert!(expense.should_be_invoiced);
|
||||
|
||||
let round_tripped = serde_json::to_value(&expense).unwrap();
|
||||
assert_eq!(round_tripped["custom_value3"], "unmodeled field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_round_trip_preserves_unknown_fields() {
|
||||
let payload = json!({
|
||||
"id": "task1",
|
||||
"project_id": "proj1",
|
||||
"description": "Design review",
|
||||
"time_log": "[[1700000000,1700003600]]",
|
||||
"custom_value1": "unmodeled field",
|
||||
});
|
||||
|
||||
let task: Task = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(task.project_id, "proj1");
|
||||
assert_eq!(task.time_log, "[[1700000000,1700003600]]");
|
||||
|
||||
let round_tripped = serde_json::to_value(&task).unwrap();
|
||||
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_round_trip_preserves_unknown_fields() {
|
||||
let payload = json!({
|
||||
"id": "proj1",
|
||||
"client_id": "abc123",
|
||||
"name": "Website rebuild",
|
||||
"budgeted_hours": 120.0,
|
||||
"custom_value4": "unmodeled field",
|
||||
});
|
||||
|
||||
let project: Project = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(project.name, "Website rebuild");
|
||||
assert_eq!(project.budgeted_hours, 120.0);
|
||||
|
||||
let round_tripped = serde_json::to_value(&project).unwrap();
|
||||
assert_eq!(round_tripped["custom_value4"], "unmodeled field");
|
||||
}
|
||||
|
||||
/// `Quote` and `Credit` are type aliases for `Invoice`; this pins that the
|
||||
/// aliased endpoints decode the shared invoice shape.
|
||||
#[test]
|
||||
fn quote_and_credit_share_the_invoice_shape() {
|
||||
let payload = json!({
|
||||
"id": "q1",
|
||||
"number": "QUO-0001",
|
||||
"entity_type": "quote",
|
||||
});
|
||||
|
||||
let quote: Quote = serde_json::from_value(payload.clone()).unwrap();
|
||||
assert_eq!(quote.number, "QUO-0001");
|
||||
assert_eq!(quote.extra.get("entity_type").unwrap(), "quote");
|
||||
|
||||
let credit: Credit = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(credit.number, "QUO-0001");
|
||||
}
|
||||
Reference in New Issue
Block a user