212 lines
6.4 KiB
Rust
212 lines
6.4 KiB
Rust
//! History DTOs for API requests and responses
|
|
//!
|
|
//! These types represent the API-facing view of entity history records
|
|
//! stored in TimescaleDB hypertables.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value as JsonValue;
|
|
use utoipa::{IntoParams, ToSchema};
|
|
|
|
use attune_common::models::entity_history::HistoryEntityType;
|
|
|
|
/// Response DTO for a single entity history record.
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct HistoryRecordResponse {
|
|
/// When the change occurred
|
|
#[schema(example = "2026-02-26T10:30:00Z")]
|
|
pub time: DateTime<Utc>,
|
|
|
|
/// The operation: `INSERT`, `UPDATE`, or `DELETE`
|
|
#[schema(example = "UPDATE")]
|
|
pub operation: String,
|
|
|
|
/// The primary key of the changed entity
|
|
#[schema(example = 42)]
|
|
pub entity_id: i64,
|
|
|
|
/// Denormalized human-readable identifier (e.g., action_ref, worker name)
|
|
#[schema(example = "core.http_request")]
|
|
pub entity_ref: Option<String>,
|
|
|
|
/// Names of fields that changed (empty for INSERT/DELETE)
|
|
#[schema(example = json!(["status", "result"]))]
|
|
pub changed_fields: Vec<String>,
|
|
|
|
/// Previous values of changed fields (null for INSERT)
|
|
#[schema(value_type = Object, example = json!({"status": "requested"}))]
|
|
pub old_values: Option<JsonValue>,
|
|
|
|
/// New values of changed fields (null for DELETE)
|
|
#[schema(value_type = Object, example = json!({"status": "running"}))]
|
|
pub new_values: Option<JsonValue>,
|
|
}
|
|
|
|
impl From<attune_common::models::entity_history::EntityHistoryRecord> for HistoryRecordResponse {
|
|
fn from(record: attune_common::models::entity_history::EntityHistoryRecord) -> Self {
|
|
Self {
|
|
time: record.time,
|
|
operation: record.operation,
|
|
entity_id: record.entity_id,
|
|
entity_ref: record.entity_ref,
|
|
changed_fields: record.changed_fields,
|
|
old_values: record.old_values,
|
|
new_values: record.new_values,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Query parameters for filtering history records.
|
|
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
|
pub struct HistoryQueryParams {
|
|
/// Filter by entity ID
|
|
#[param(example = 42)]
|
|
pub entity_id: Option<i64>,
|
|
|
|
/// Filter by entity ref (e.g., action_ref, worker name)
|
|
#[param(example = "core.http_request")]
|
|
pub entity_ref: Option<String>,
|
|
|
|
/// Filter by operation type: `INSERT`, `UPDATE`, or `DELETE`
|
|
#[param(example = "UPDATE")]
|
|
pub operation: Option<String>,
|
|
|
|
/// Only include records where this field was changed
|
|
#[param(example = "status")]
|
|
pub changed_field: Option<String>,
|
|
|
|
/// Only include records at or after this time (ISO 8601)
|
|
#[param(example = "2026-02-01T00:00:00Z")]
|
|
pub since: Option<DateTime<Utc>>,
|
|
|
|
/// Only include records at or before this time (ISO 8601)
|
|
#[param(example = "2026-02-28T23:59:59Z")]
|
|
pub until: Option<DateTime<Utc>>,
|
|
|
|
/// Page number (1-based)
|
|
#[serde(default = "default_page")]
|
|
#[param(example = 1, minimum = 1)]
|
|
pub page: u32,
|
|
|
|
/// Number of items per page
|
|
#[serde(default = "default_page_size")]
|
|
#[param(example = 50, minimum = 1, maximum = 1000)]
|
|
pub page_size: u32,
|
|
}
|
|
|
|
fn default_page() -> u32 {
|
|
1
|
|
}
|
|
|
|
fn default_page_size() -> u32 {
|
|
50
|
|
}
|
|
|
|
impl HistoryQueryParams {
|
|
/// Convert to the repository-level query params.
|
|
pub fn to_repo_params(
|
|
&self,
|
|
) -> attune_common::repositories::entity_history::HistoryQueryParams {
|
|
let limit = (self.page_size.min(1000).max(1)) as i64;
|
|
let offset = ((self.page.saturating_sub(1)) as i64) * limit;
|
|
|
|
attune_common::repositories::entity_history::HistoryQueryParams {
|
|
entity_id: self.entity_id,
|
|
entity_ref: self.entity_ref.clone(),
|
|
operation: self.operation.clone(),
|
|
changed_field: self.changed_field.clone(),
|
|
since: self.since,
|
|
until: self.until,
|
|
limit: Some(limit),
|
|
offset: Some(offset),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Path parameter for the entity type segment.
|
|
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
|
pub struct HistoryEntityTypePath {
|
|
/// Entity type: `execution` or `worker`
|
|
pub entity_type: String,
|
|
}
|
|
|
|
impl HistoryEntityTypePath {
|
|
/// Parse the entity type string, returning a typed enum or an error message.
|
|
pub fn parse(&self) -> Result<HistoryEntityType, String> {
|
|
self.entity_type.parse::<HistoryEntityType>()
|
|
}
|
|
}
|
|
|
|
/// Path parameters for entity-specific history (e.g., `/executions/42/history`).
|
|
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
|
pub struct EntityIdPath {
|
|
/// The entity's primary key
|
|
pub id: i64,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_query_params_defaults() {
|
|
let json = r#"{}"#;
|
|
let params: HistoryQueryParams = serde_json::from_str(json).unwrap();
|
|
assert_eq!(params.page, 1);
|
|
assert_eq!(params.page_size, 50);
|
|
assert!(params.entity_id.is_none());
|
|
assert!(params.operation.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_params_to_repo_params() {
|
|
let params = HistoryQueryParams {
|
|
entity_id: Some(42),
|
|
entity_ref: None,
|
|
operation: Some("UPDATE".to_string()),
|
|
changed_field: Some("status".to_string()),
|
|
since: None,
|
|
until: None,
|
|
page: 3,
|
|
page_size: 20,
|
|
};
|
|
|
|
let repo = params.to_repo_params();
|
|
assert_eq!(repo.entity_id, Some(42));
|
|
assert_eq!(repo.operation, Some("UPDATE".to_string()));
|
|
assert_eq!(repo.changed_field, Some("status".to_string()));
|
|
assert_eq!(repo.limit, Some(20));
|
|
assert_eq!(repo.offset, Some(40)); // (3-1) * 20
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_params_page_size_cap() {
|
|
let params = HistoryQueryParams {
|
|
entity_id: None,
|
|
entity_ref: None,
|
|
operation: None,
|
|
changed_field: None,
|
|
since: None,
|
|
until: None,
|
|
page: 1,
|
|
page_size: 5000,
|
|
};
|
|
|
|
let repo = params.to_repo_params();
|
|
assert_eq!(repo.limit, Some(1000));
|
|
}
|
|
|
|
#[test]
|
|
fn test_entity_type_path_parse() {
|
|
let path = HistoryEntityTypePath {
|
|
entity_type: "execution".to_string(),
|
|
};
|
|
assert_eq!(path.parse().unwrap(), HistoryEntityType::Execution);
|
|
|
|
let path = HistoryEntityTypePath {
|
|
entity_type: "unknown".to_string(),
|
|
};
|
|
assert!(path.parse().is_err());
|
|
}
|
|
}
|