change capture
This commit is contained in:
211
crates/api/src/dto/history.rs
Normal file
211
crates/api/src/dto/history.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
//! 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`, `worker`, `enforcement`, or `event`
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user