1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4#[derive(Debug, Serialize)]
9pub struct EnrollRequest {
10 pub client_id: String,
11 pub client_secret: String,
12 pub instance_id: String,
13}
14
15#[derive(Debug, Deserialize)]
16pub struct EnrollResponse {
17 pub service_id: Uuid,
18 pub session_token: String,
19}
20
21#[derive(Debug, Serialize)]
26pub struct HeartbeatRequest {
27 pub service_id: Uuid,
28 pub status: String,
29 pub current_operation: Option<String>,
30}
31
32#[derive(Debug, Deserialize)]
33pub struct HeartbeatResponse {
34 pub session_token: String,
35 pub pending_commands: Vec<PendingCommand>,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39pub struct PendingCommand {
40 pub id: Uuid,
41 pub command: String,
42 pub payload: Option<serde_json::Value>,
43}
44
45#[derive(Debug, Serialize)]
50pub struct CommandUpdateRequest {
51 pub status: String,
52 pub result: Option<serde_json::Value>,
53}
54
55#[derive(Debug, Default, Serialize)]
60pub struct SyncResult {
61 pub readings_synced: u64,
62 pub status_events_synced: u64,
63 pub full_sync: bool,
64 pub duration_ms: u64,
65 #[serde(skip_serializing_if = "Vec::is_empty")]
66 pub errors: Vec<String>,
67 #[serde(skip_serializing_if = "Vec::is_empty")]
68 pub log: Vec<String>,
69}
70
71#[derive(Debug)]
72pub enum SyncTrigger {
73 Scheduled,
74 Command { id: Uuid, full: bool },
75}
76
77#[derive(Debug, Clone)]
82pub struct RunnerConfig {
83 pub api_base_url: String,
84 pub client_id: String,
85 pub client_secret: String,
86 pub instance_id: String,
87 pub heartbeat_interval_secs: u64,
88 pub sync_interval_secs: u64,
89}
90
91impl RunnerConfig {
92 pub fn from_env() -> Result<Self, String> {
93 Ok(Self {
94 api_base_url: require_env("API_BASE_URL")?,
95 client_id: require_env("SERVICE_CLIENT_ID")?,
96 client_secret: require_env("SERVICE_CLIENT_SECRET")?,
97 instance_id: std::env::var("INSTANCE_ID").unwrap_or_else(|_| "default".to_string()),
98 heartbeat_interval_secs: env_u64("HEARTBEAT_INTERVAL_SECONDS", 30),
99 sync_interval_secs: env_u64("SYNC_INTERVAL_SECONDS", 300),
100 })
101 }
102}
103
104fn require_env(key: &str) -> Result<String, String> {
105 std::env::var(key).map_err(|_| format!("Missing required env var: {key}"))
106}
107
108fn env_u64(key: &str, default: u64) -> u64 {
109 std::env::var(key)
110 .ok()
111 .and_then(|v| v.parse().ok())
112 .unwrap_or(default)
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct DataStream {
121 pub id: Uuid,
122 pub source_system: String,
123 pub source_key: String,
124 pub source_name: Option<String>,
125 pub source_path: Option<String>,
126 pub metadata: serde_json::Value,
127 pub site_parameter_id: Option<Uuid>,
128 pub is_active: bool,
129 pub last_data_time: Option<chrono::DateTime<chrono::Utc>>,
130}
131
132#[derive(Debug, Serialize)]
133pub struct RegisterStreamRequest {
134 pub source_system: String,
135 pub source_key: String,
136 pub source_name: Option<String>,
137 pub source_path: Option<String>,
138 pub metadata: serde_json::Value,
139}
140
141#[derive(Debug, Serialize)]
142pub struct IngestReadingsRequest {
143 pub stream_id: Uuid,
144 pub readings: Vec<IngestReading>,
145}
146
147#[derive(Debug, Clone, Serialize)]
148pub struct IngestReading {
149 pub time: chrono::DateTime<chrono::Utc>,
150 pub raw_value: f64,
151 #[serde(skip_serializing_if = "is_zero")]
152 pub replicate_index: i16,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub sensor_id: Option<Uuid>,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub calibration_id: Option<Uuid>,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub deployment_id: Option<Uuid>,
159}
160
161fn is_zero(v: &i16) -> bool {
162 *v == 0
163}
164
165#[derive(Debug, Serialize)]
166pub struct IngestStatusEventsRequest {
167 pub stream_id: Uuid,
168 pub events: Vec<IngestStatusEvent>,
169}
170
171#[derive(Debug, Serialize)]
172pub struct IngestStatusEvent {
173 pub time: chrono::DateTime<chrono::Utc>,
174 pub value: String,
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn test_enroll_request_serialization() {
183 let req = EnrollRequest {
184 client_id: "svc_abc".to_string(),
185 client_secret: "secret123".to_string(),
186 instance_id: "vaisala-01".to_string(),
187 };
188 let json = serde_json::to_value(&req).unwrap();
189 assert_eq!(json["client_id"], "svc_abc");
190 assert_eq!(json["instance_id"], "vaisala-01");
191 }
192
193 #[test]
194 fn test_enroll_response_deserialization() {
195 let json = serde_json::json!({
196 "service_id": "550e8400-e29b-41d4-a716-446655440000",
197 "session_token": "tok-abc"
198 });
199 let resp: EnrollResponse = serde_json::from_value(json).unwrap();
200 assert_eq!(resp.session_token, "tok-abc");
201 }
202
203 #[test]
204 fn test_heartbeat_response_with_commands() {
205 let json = serde_json::json!({
206 "session_token": "new-tok",
207 "pending_commands": [
208 {
209 "id": "550e8400-e29b-41d4-a716-446655440000",
210 "command": "trigger_sync",
211 "payload": null
212 }
213 ]
214 });
215 let resp: HeartbeatResponse = serde_json::from_value(json).unwrap();
216 assert_eq!(resp.pending_commands.len(), 1);
217 assert_eq!(resp.pending_commands[0].command, "trigger_sync");
218 }
219
220 #[test]
221 fn test_sync_result_default() {
222 let r = SyncResult::default();
223 assert_eq!(r.readings_synced, 0);
224 assert!(!r.full_sync);
225 assert!(r.errors.is_empty());
226 }
227
228 #[test]
229 fn test_sync_result_serialization_skips_empty() {
230 let r = SyncResult {
231 readings_synced: 100,
232 ..Default::default()
233 };
234 let json = serde_json::to_value(&r).unwrap();
235 assert_eq!(json["readings_synced"], 100);
236 assert!(json.get("errors").is_none());
237 }
238
239 #[test]
240 fn test_ingest_reading_serialization() {
241 let r = IngestReading {
242 time: chrono::Utc::now(),
243 raw_value: 42.5,
244 replicate_index: 0,
245 sensor_id: None,
246 calibration_id: None,
247 deployment_id: None,
248 };
249 let json = serde_json::to_value(&r).unwrap();
250 assert_eq!(json["raw_value"], 42.5);
251 assert!(json.get("replicate_index").is_none());
252 assert!(json.get("sensor_id").is_none());
253 }
254
255 #[test]
256 fn test_register_stream_request() {
257 let req = RegisterStreamRequest {
258 source_system: "vaisala".to_string(),
259 source_key: "loc_1270".to_string(),
260 source_name: Some("MDepthmm".to_string()),
261 source_path: None,
262 metadata: serde_json::json!({"device": "25284027"}),
263 };
264 let json = serde_json::to_value(&req).unwrap();
265 assert_eq!(json["source_system"], "vaisala");
266 assert_eq!(json["metadata"]["device"], "25284027");
267 }
268
269 #[test]
270 fn test_data_stream_deserialization() {
271 let json = serde_json::json!({
272 "id": "550e8400-e29b-41d4-a716-446655440000",
273 "source_system": "vaisala",
274 "source_key": "loc_1270",
275 "source_name": "MDepthmm",
276 "source_path": null,
277 "metadata": {},
278 "site_parameter_id": null,
279 "is_active": true,
280 "last_data_time": null
281 });
282 let stream: DataStream = serde_json::from_value(json).unwrap();
283 assert_eq!(stream.source_system, "vaisala");
284 assert!(stream.is_active);
285 assert!(stream.site_parameter_id.is_none());
286 }
287}