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