use serde::Deserialize;
use strum::{Display, EnumString, FromRepr};
use crate::error::{Error, Result};
use crate::secrets::AccountNumber;
use crate::streamer::{Service, subscription::SubscriptionField};
impl SubscriptionField for Field {
const SERVICE: Service = Service::AccountActivity;
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Deserialize,
serde_repr::Serialize_repr,
Display,
EnumString,
FromRepr,
)]
#[repr(u8)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum Field {
SubscriptionKey,
Account,
MessageType,
MessageData,
}
impl From<Field> for u8 {
fn from(field: Field) -> Self {
field as u8
}
}
impl TryFrom<u8> for Field {
type Error = String;
fn try_from(value: u8) -> std::result::Result<Self, Self::Error> {
Field::from_repr(value).ok_or_else(|| format!("Invalid field: {}", value))
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct Content {
pub key: String,
pub delayed: bool,
pub seq: Option<i64>,
pub subscription_key: Option<String>,
pub account: Option<AccountNumber>,
pub message_type: Option<String>,
pub message_data: Option<String>,
}
impl Content {
pub(crate) fn decode_batch(remapped: serde_json::Value) -> Result<Vec<Self>> {
serde_json::from_value(remapped).map_err(|e| Error::Codec {
context: "ACCT_ACTIVITY content".to_string(),
reason: e.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::streamer::StreamerRequest;
use crate::streamer::StreamerResponse;
use crate::streamer::response::{DataContent, parse};
use crate::streamer::subscription::{Command, Subscription, subscribe_parameters};
#[test]
fn parses_account_activity_data_into_typed_content() {
let frame = r#"{
"data": [{
"service": "ACCT_ACTIVITY",
"timestamp": 1714949592301,
"command": "SUBS",
"content": [{
"seq": 42,
"key": "my-correl-id",
"delayed": false,
"0": "my-correl-id",
"1": "12345678",
"2": "OrderEntryRequest",
"3": "{\"orderId\":\"ABC\",\"symbol\":\"AAPL\",\"quantity\":10}"
}]
}]
}"#;
let StreamerResponse::Data(data) = parse(frame).unwrap() else {
panic!("expected Data");
};
let payload = &data[0];
assert_eq!(payload.service, Service::AccountActivity);
let DataContent::AccountActivity(items) = &payload.content else {
panic!("expected AccountActivity, got {:?}", payload.content);
};
let msg = &items[0];
assert_eq!(msg.key, "my-correl-id");
assert_eq!(msg.seq, Some(42));
assert_eq!(msg.subscription_key.as_deref(), Some("my-correl-id"));
assert_eq!(
msg.account.as_ref().map(|a| a.expose_secret().to_string()),
Some("12345678".to_string())
);
assert_eq!(msg.message_type.as_deref(), Some("OrderEntryRequest"));
assert!(
msg.message_data
.as_deref()
.map(|s| s.contains("AAPL"))
.unwrap_or(false),
"message_data should preserve raw payload"
);
}
#[test]
fn account_in_account_activity_redacts_on_debug() {
let frame = r#"{
"data": [{
"service": "ACCT_ACTIVITY",
"timestamp": 1,
"command": "SUBS",
"content": [{
"seq": 1, "key": "k", "delayed": false,
"1": "12345678"
}]
}]
}"#;
let StreamerResponse::Data(data) = parse(frame).unwrap() else {
panic!("expected Data");
};
let DataContent::AccountActivity(items) = &data[0].content else {
panic!("expected AccountActivity");
};
let debug = format!("{:?}", items[0]);
assert!(
!debug.contains("12345678"),
"account number leaked through Debug: {debug}"
);
}
#[test]
fn fields_serialize_as_numeric_index() {
let value = subscribe_parameters(
vec!["my-correl-id".to_string()],
vec![
Field::SubscriptionKey,
Field::Account,
Field::MessageType,
Field::MessageData,
],
);
assert_eq!(value["keys"], "my-correl-id");
assert_eq!(value["fields"], "0,1,2,3");
}
#[test]
fn from_subscription_never_panics() {
let sub = Subscription {
command: Command::Subscribe,
keys: vec!["my-correl-id".to_string()],
fields: vec![Field::MessageType, Field::MessageData],
};
let _request: StreamerRequest = sub.into();
let sub = Subscription::<Field> {
command: Command::Unsubscribe,
keys: vec![],
fields: vec![],
};
let _request: StreamerRequest = sub.into();
}
#[test]
fn snake_case_field_names_round_trip() {
assert_eq!(Field::SubscriptionKey.to_string(), "subscription_key");
assert_eq!(Field::MessageType.to_string(), "message_type");
assert_eq!(Field::MessageData.to_string(), "message_data");
}
}