Add the Activity, SubscriptionStep and MessageResponse models
These are the types the per-resource extras and the activity endpoints
need, split out so they land before the code and tests that use them.
`MessageResponse` covers the `{"message": "..."}` body that the action
endpoints answer with, defaulting `message` so a reply carrying none still
decodes. `int_or_numeric_string` goes in alongside them: the spec calls
`frequency_id` a number, but a real 5.13.50 server sends the string `"5"`,
and a strict `i64` turned that into a hard decode error *after* the record
had already been created.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// One row of the activity log, as `GET /activities` returns it.
|
||||
///
|
||||
/// Every reference is a hashed id, or an empty string when the activity does
|
||||
/// not concern that kind of record. `activity_type_id` arrives as a string
|
||||
/// here but as a number in the [feed](ActivityFeedEntry).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Activity {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub activity_type_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub user_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub company_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub contact_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vendor_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vendor_contact_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quote_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub credit_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub payment_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub recurring_invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub recurring_expense_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub purchase_order_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub expense_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub task_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub token_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_system: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub ip: String,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// A named record an activity refers to: the display label and its hashed id.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ActivityRef {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub label: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hashed_id: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One entry of the activity feed (`activity_feed`, `entity_activity`).
|
||||
///
|
||||
/// A different shape from [`Activity`]: the server resolves the records an
|
||||
/// activity mentions into labelled references. Which references are present
|
||||
/// depends on the activity type, so each is optional, and any the server adds
|
||||
/// that this does not name lands in `extra`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ActivityFeedEntry {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hashed_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub activity_type_id: i64,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub ip: String,
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub created_at: i64,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vendor: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub invoice: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub quote: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub credit: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payment: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expense: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub purchase_order: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_invoice: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_expense: Option<ActivityRef>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
@@ -154,6 +154,32 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserializes an integer that may arrive as a JSON number or as a numeric
|
||||
/// string.
|
||||
///
|
||||
/// The OpenAPI spec calls `frequency_id` a number on recurring invoices and
|
||||
/// expenses, but a real 5.13.50 server returns the string `"5"`. A strict
|
||||
/// `i64` made that a hard decode error *after* the record had already been
|
||||
/// created. A string that is not a whole number is still an error.
|
||||
pub(crate) fn int_or_numeric_string<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match Option::<Value>::deserialize(deserializer)? {
|
||||
None | Some(Value::Null) => Ok(0),
|
||||
Some(Value::Number(n)) => n
|
||||
.as_i64()
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("expected a whole number, got {n}"))),
|
||||
Some(Value::String(s)) if s.is_empty() => Ok(0),
|
||||
Some(Value::String(s)) => s.trim().parse().map_err(|_| {
|
||||
serde::de::Error::custom(format!("expected a whole number, got string `{s}`"))
|
||||
}),
|
||||
Some(other) => Err(serde::de::Error::custom(format!(
|
||||
"expected a whole number or numeric string, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Body for a bulk action (`POST /{resource}/bulk`).
|
||||
///
|
||||
/// The action set varies by resource; `archive`, `restore`, and `delete` work
|
||||
@@ -174,3 +200,15 @@ impl BulkRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `{"message": "..."}` body that action endpoints answer with.
|
||||
///
|
||||
/// `message` is defaulted so a reply that carries none still decodes.
|
||||
/// Anything else the server adds is kept in `extra`.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
#[serde(default, deserialize_with = "null_to_default")]
|
||||
pub message: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
mod activity;
|
||||
mod bank_integration;
|
||||
mod bank_transaction;
|
||||
mod bank_transaction_rule;
|
||||
@@ -35,12 +36,15 @@ mod user;
|
||||
mod vendor;
|
||||
mod webhook;
|
||||
|
||||
pub use activity::{Activity, ActivityFeedEntry, ActivityRef};
|
||||
pub use bank_integration::BankIntegration;
|
||||
pub use bank_transaction::BankTransaction;
|
||||
pub use bank_transaction_rule::BankTransactionRule;
|
||||
pub use client::{Client, ClientContact};
|
||||
pub use client_gateway_token::ClientGatewayToken;
|
||||
pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination};
|
||||
pub use common::{
|
||||
BulkRequest, ItemResponse, ListParams, ListResponse, MessageResponse, Meta, Pagination,
|
||||
};
|
||||
pub use company::Company;
|
||||
pub use company_gateway::CompanyGateway;
|
||||
pub use company_token::CompanyToken;
|
||||
@@ -61,7 +65,7 @@ pub use recurring_expense::RecurringExpense;
|
||||
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
|
||||
pub use search::{SearchHit, SearchResults};
|
||||
pub use statics::{Currency, DateFormat, StaticItem, Statics};
|
||||
pub use subscription::Subscription;
|
||||
pub use subscription::{Subscription, SubscriptionStep};
|
||||
pub use system_log::SystemLog;
|
||||
pub use tag::Tag;
|
||||
pub use task::Task;
|
||||
|
||||
@@ -55,3 +55,16 @@ pub struct Subscription {
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One step of the subscription purchase flow and the steps it depends on,
|
||||
/// as returned by `GET /subscriptions/steps`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SubscriptionStep {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub dependencies: Vec<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user