Skip to main content

haystack_server/ops/
his.rs

1//! The `hisRead` and `hisWrite` ops — historical time-series data.
2//!
3//! Backed by the in-memory `HisStore` stored in `AppState`.
4
5use actix_web::{HttpRequest, HttpResponse, web};
6use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveTime, TimeZone};
7
8use haystack_core::data::{HCol, HDict, HGrid};
9use haystack_core::kinds::{HDateTime, HRef, Kind};
10
11use crate::content;
12use crate::error::HaystackError;
13use crate::his_store::HisItem;
14use crate::state::AppState;
15
16// ---------------------------------------------------------------------------
17// hisRead
18// ---------------------------------------------------------------------------
19
20/// POST /api/hisRead
21///
22/// Request grid has one row with `id` (Ref) and `range` (Str) columns.
23///
24/// Supported `range` formats:
25///   - `"today"` / `"yesterday"` — date ranges based on local time
26///   - `"YYYY-MM-DD"` — a single date (midnight to midnight)
27///   - `"YYYY-MM-DD,YYYY-MM-DD"` — explicit start,end dates (start inclusive, end exclusive midnight)
28pub async fn handle_read(
29    req: HttpRequest,
30    body: String,
31    state: web::Data<AppState>,
32) -> Result<HttpResponse, HaystackError> {
33    let content_type = req
34        .headers()
35        .get("Content-Type")
36        .and_then(|v| v.to_str().ok())
37        .unwrap_or("");
38    let accept = req
39        .headers()
40        .get("Accept")
41        .and_then(|v| v.to_str().ok())
42        .unwrap_or("");
43
44    let request_grid = content::decode_request_grid(&body, content_type)
45        .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
46
47    // Extract the first row.
48    let row = request_grid
49        .row(0)
50        .ok_or_else(|| HaystackError::bad_request("hisRead request has no rows"))?;
51
52    // Extract id.
53    let id = match row.get("id") {
54        Some(Kind::Ref(r)) => r.val.clone(),
55        _ => {
56            return Err(HaystackError::bad_request(
57                "hisRead: missing or invalid 'id' Ref",
58            ));
59        }
60    };
61
62    // Extract range string.
63    let range_str = match row.get("range") {
64        Some(Kind::Str(s)) => s.as_str(),
65        _ => {
66            return Err(HaystackError::bad_request(
67                "hisRead: missing or invalid 'range' Str",
68            ));
69        }
70    };
71
72    // Parse range into (start, end) pair of DateTime<FixedOffset>.
73    let (start, end) = parse_range(range_str)
74        .map_err(|e| HaystackError::bad_request(format!("hisRead: bad range: {e}")))?;
75
76    // Query the store.
77    let items = state.his.read(&id, Some(start), Some(end));
78
79    // Build response grid.
80    let cols = vec![HCol::new("ts"), HCol::new("val")];
81    let rows: Vec<HDict> = items
82        .into_iter()
83        .map(|item| {
84            let mut d = HDict::new();
85            d.set("ts", Kind::DateTime(HDateTime::new(item.ts, "UTC")));
86            d.set("val", item.val);
87            d
88        })
89        .collect();
90
91    let mut meta = HDict::new();
92    meta.set("id", Kind::Ref(HRef::from_val(&id)));
93    let grid = HGrid::from_parts(meta, cols, rows);
94
95    log::info!("hisRead: returning {} rows for point {}", grid.len(), id);
96    let (encoded, ct) = content::encode_response_grid(&grid, accept)
97        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
98
99    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
100}
101
102/// Parse a range string into a (start, end) pair of `DateTime<FixedOffset>`.
103///
104/// Supported formats:
105///   - `"today"` — midnight-to-midnight of the current local date
106///   - `"yesterday"` — midnight-to-midnight of yesterday's local date
107///   - `"YYYY-MM-DD"` — a single date
108///   - `"YYYY-MM-DD,YYYY-MM-DD"` — explicit start,end
109fn parse_range(range: &str) -> Result<(DateTime<FixedOffset>, DateTime<FixedOffset>), String> {
110    let range = range.trim();
111
112    match range {
113        "today" => {
114            let today = Local::now().date_naive();
115            Ok(date_range(today, today))
116        }
117        "yesterday" => {
118            let yesterday = Local::now().date_naive() - chrono::Duration::days(1);
119            Ok(date_range(yesterday, yesterday))
120        }
121        _ => {
122            if range.contains(',') {
123                let parts: Vec<&str> = range.splitn(2, ',').collect();
124                let start_date = parse_date(parts[0].trim())?;
125                let end_date = parse_date(parts[1].trim())?;
126                Ok(date_range(start_date, end_date))
127            } else {
128                let date = parse_date(range)?;
129                Ok(date_range(date, date))
130            }
131        }
132    }
133}
134
135/// Parse a "YYYY-MM-DD" string into a NaiveDate.
136fn parse_date(s: &str) -> Result<NaiveDate, String> {
137    NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| format!("invalid date '{s}': {e}"))
138}
139
140/// Build a (start, end) DateTime pair from date(s).
141///
142/// Start is midnight on `start_date`, end is 23:59:59 on `end_date`, both at UTC.
143fn date_range(
144    start_date: NaiveDate,
145    end_date: NaiveDate,
146) -> (DateTime<FixedOffset>, DateTime<FixedOffset>) {
147    let utc = FixedOffset::east_opt(0).unwrap();
148    let start = utc
149        .from_local_datetime(&start_date.and_time(NaiveTime::MIN))
150        .unwrap();
151    let end = utc
152        .from_local_datetime(&end_date.and_hms_opt(23, 59, 59).unwrap())
153        .unwrap();
154    (start, end)
155}
156
157// ---------------------------------------------------------------------------
158// hisWrite
159// ---------------------------------------------------------------------------
160
161/// POST /api/hisWrite
162///
163/// Request grid meta must contain `id` (Ref). Rows contain `ts` and `val`
164/// columns. Data is stored in the in-memory `HisStore`.
165pub async fn handle_write(
166    req: HttpRequest,
167    body: String,
168    state: web::Data<AppState>,
169) -> Result<HttpResponse, HaystackError> {
170    let content_type = req
171        .headers()
172        .get("Content-Type")
173        .and_then(|v| v.to_str().ok())
174        .unwrap_or("");
175    let accept = req
176        .headers()
177        .get("Accept")
178        .and_then(|v| v.to_str().ok())
179        .unwrap_or("");
180
181    let request_grid = content::decode_request_grid(&body, content_type)
182        .map_err(|e| HaystackError::bad_request(format!("failed to decode request: {e}")))?;
183
184    // Extract point id from grid meta.
185    let id = match request_grid.meta.get("id") {
186        Some(Kind::Ref(r)) => r.val.clone(),
187        _ => {
188            return Err(HaystackError::bad_request(
189                "hisWrite: grid meta must contain 'id' Ref",
190            ));
191        }
192    };
193
194    // Parse rows into HisItems.
195    let mut items = Vec::with_capacity(request_grid.len());
196    for (i, row) in request_grid.iter().enumerate() {
197        let ts = match row.get("ts") {
198            Some(Kind::DateTime(hdt)) => hdt.dt,
199            _ => {
200                return Err(HaystackError::bad_request(format!(
201                    "hisWrite: row {i} missing or invalid 'ts' DateTime"
202                )));
203            }
204        };
205        let val = row.get("val").cloned().unwrap_or(Kind::Null);
206
207        items.push(HisItem { ts, val });
208    }
209
210    let count = items.len();
211    state.his.write(&id, items);
212
213    log::info!("hisWrite: stored {} items for point {}", count, id);
214    let grid = HGrid::new();
215    let (encoded, ct) = content::encode_response_grid(&grid, accept)
216        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
217
218    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
219}