Skip to main content

haystack_server/ops/
libs.rs

1//! Library and spec management endpoints.
2//!
3//! # Overview
4//!
5//! These endpoints manage Xeto-based ontology libraries at runtime:
6//! listing specs, inspecting individual specs, loading/unloading libraries,
7//! exporting library source, and validating entities against the ontology.
8//!
9//! # Endpoints
10//!
11//! - `POST /api/specs` — list specs, optionally filtered by `lib` (Str).
12//!   Response: `qname`, `name`, `lib`, `base`, `doc`, `abstract`.
13//! - `POST /api/spec` — get single spec by `qname` (Str).
14//!   Response: `qname`, `name`, `lib`, `base`, `doc`, `abstract`, `slots`.
15//! - `POST /api/loadLib` — load library from `name` (Str) and `source` (Str).
16//!   Response: `loaded`, `specs`.
17//! - `POST /api/unloadLib` — unload library by `name` (Str).
18//!   Response: `unloaded`.
19//! - `POST /api/exportLib` — export library by `name` (Str) to Xeto source.
20//!   Response: `name`, `source`.
21//! - `POST /api/validate` — validate entity rows against the ontology.
22//!   Response: `entity`, `issueType`, `detail`.
23//!
24//! # Errors
25//!
26//! - **400 Bad Request** — missing required columns, spec not found, load/unload
27//!   error, or request decode failure.
28//! - **500 Internal Server Error** — encoding error.
29
30use actix_web::{HttpRequest, HttpResponse, web};
31
32use haystack_core::data::{HCol, HDict, HGrid};
33use haystack_core::kinds::Kind;
34
35use crate::content;
36use crate::error::HaystackError;
37use crate::state::AppState;
38
39/// POST /api/specs — list specs, optionally filtered by library.
40///
41/// Request grid may have a `lib` (Str) column to filter by library name.
42/// Returns a grid of specs sorted by `qname`, with columns:
43/// `qname`, `name`, `lib`, `base`, `doc`, and `abstract` (Marker).
44pub async fn handle_specs(
45    req: HttpRequest,
46    body: String,
47    state: web::Data<AppState>,
48) -> Result<HttpResponse, HaystackError> {
49    let content_type = req
50        .headers()
51        .get("Content-Type")
52        .and_then(|v| v.to_str().ok())
53        .unwrap_or("");
54    let accept = req
55        .headers()
56        .get("Accept")
57        .and_then(|v| v.to_str().ok())
58        .unwrap_or("");
59
60    let ns = state.namespace.read();
61
62    // Parse optional lib filter from request
63    let lib_filter: Option<String> = if body.trim().is_empty() {
64        None
65    } else {
66        let grid = content::decode_request_grid(&body, content_type)
67            .map_err(|e| HaystackError::bad_request(format!("decode error: {e}")))?;
68        grid.row(0).and_then(|row| match row.get("lib") {
69            Some(Kind::Str(s)) if !s.is_empty() => Some(s.clone()),
70            _ => None,
71        })
72    };
73
74    let specs = ns.specs(lib_filter.as_deref());
75    let cols = vec![
76        HCol::new("qname"),
77        HCol::new("name"),
78        HCol::new("lib"),
79        HCol::new("base"),
80        HCol::new("doc"),
81        HCol::new("abstract"),
82    ];
83
84    let mut rows: Vec<HDict> = specs
85        .iter()
86        .map(|spec| {
87            let mut row = HDict::new();
88            row.set("qname", Kind::Str(spec.qname.clone()));
89            row.set("name", Kind::Str(spec.name.clone()));
90            row.set("lib", Kind::Str(spec.lib.clone()));
91            if let Some(ref base) = spec.base {
92                row.set("base", Kind::Str(base.clone()));
93            }
94            row.set("doc", Kind::Str(spec.doc.clone()));
95            if spec.is_abstract {
96                row.set("abstract", Kind::Marker);
97            }
98            row
99        })
100        .collect();
101
102    rows.sort_by(|a, b| {
103        let a_name = match a.get("qname") {
104            Some(Kind::Str(s)) => s.as_str(),
105            _ => "",
106        };
107        let b_name = match b.get("qname") {
108            Some(Kind::Str(s)) => s.as_str(),
109            _ => "",
110        };
111        a_name.cmp(b_name)
112    });
113
114    let grid = HGrid::from_parts(HDict::new(), cols, rows);
115    let (encoded, ct) = content::encode_response_grid(&grid, accept)
116        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
117    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
118}
119
120/// POST /api/spec — get a single spec by qualified name.
121///
122/// Request grid must have a `qname` (Str) column with the fully-qualified
123/// spec name. Returns a single-row grid with `qname`, `name`, `lib`,
124/// `base`, `doc`, `abstract`, and `slots` (comma-separated slot names).
125pub async fn handle_spec(
126    req: HttpRequest,
127    body: String,
128    state: web::Data<AppState>,
129) -> Result<HttpResponse, HaystackError> {
130    let content_type = req
131        .headers()
132        .get("Content-Type")
133        .and_then(|v| v.to_str().ok())
134        .unwrap_or("");
135    let accept = req
136        .headers()
137        .get("Accept")
138        .and_then(|v| v.to_str().ok())
139        .unwrap_or("");
140
141    let grid = content::decode_request_grid(&body, content_type)
142        .map_err(|e| HaystackError::bad_request(format!("decode error: {e}")))?;
143    let row = grid
144        .row(0)
145        .ok_or_else(|| HaystackError::bad_request("request grid has no rows"))?;
146    let qname = match row.get("qname") {
147        Some(Kind::Str(s)) => s.clone(),
148        _ => return Err(HaystackError::bad_request("qname column required")),
149    };
150
151    let ns = state.namespace.read();
152    let spec = ns
153        .get_spec(&qname)
154        .ok_or_else(|| HaystackError::bad_request(format!("spec '{}' not found", qname)))?;
155
156    let cols = vec![
157        HCol::new("qname"),
158        HCol::new("name"),
159        HCol::new("lib"),
160        HCol::new("base"),
161        HCol::new("doc"),
162        HCol::new("abstract"),
163        HCol::new("slots"),
164    ];
165
166    let mut result = HDict::new();
167    result.set("qname", Kind::Str(spec.qname.clone()));
168    result.set("name", Kind::Str(spec.name.clone()));
169    result.set("lib", Kind::Str(spec.lib.clone()));
170    if let Some(ref base) = spec.base {
171        result.set("base", Kind::Str(base.clone()));
172    }
173    result.set("doc", Kind::Str(spec.doc.clone()));
174    if spec.is_abstract {
175        result.set("abstract", Kind::Marker);
176    }
177    // Encode slots as a comma-separated string for simplicity
178    let slot_names: Vec<String> = spec.slots.iter().map(|s| s.name.clone()).collect();
179    result.set("slots", Kind::Str(slot_names.join(",")));
180
181    let grid = HGrid::from_parts(HDict::new(), cols, vec![result]);
182    let (encoded, ct) = content::encode_response_grid(&grid, accept)
183        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
184    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
185}
186
187/// POST /api/loadLib — load a library from Xeto source text.
188///
189/// Request grid must have `name` (Str) and `source` (Str) columns.
190/// Returns a single-row grid with `loaded` (library name) and `specs`
191/// (comma-separated list of loaded spec qualified names).
192pub async fn handle_load_lib(
193    req: HttpRequest,
194    body: String,
195    state: web::Data<AppState>,
196) -> Result<HttpResponse, HaystackError> {
197    let content_type = req
198        .headers()
199        .get("Content-Type")
200        .and_then(|v| v.to_str().ok())
201        .unwrap_or("");
202    let accept = req
203        .headers()
204        .get("Accept")
205        .and_then(|v| v.to_str().ok())
206        .unwrap_or("");
207
208    let grid = content::decode_request_grid(&body, content_type)
209        .map_err(|e| HaystackError::bad_request(format!("decode error: {e}")))?;
210    let row = grid
211        .row(0)
212        .ok_or_else(|| HaystackError::bad_request("request grid has no rows"))?;
213    let name = match row.get("name") {
214        Some(Kind::Str(s)) => s.clone(),
215        _ => return Err(HaystackError::bad_request("name column required")),
216    };
217    let source = match row.get("source") {
218        Some(Kind::Str(s)) => s.clone(),
219        _ => return Err(HaystackError::bad_request("source column required")),
220    };
221
222    let mut ns = state.namespace.write();
223    let qnames = ns
224        .load_xeto_str(&source, &name)
225        .map_err(|e| HaystackError::bad_request(format!("load error: {e}")))?;
226
227    let cols = vec![HCol::new("loaded"), HCol::new("specs")];
228    let mut result = HDict::new();
229    result.set("loaded", Kind::Str(name));
230    result.set("specs", Kind::Str(qnames.join(",")));
231    let grid = HGrid::from_parts(HDict::new(), cols, vec![result]);
232    let (encoded, ct) = content::encode_response_grid(&grid, accept)
233        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
234    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
235}
236
237/// POST /api/unloadLib — unload a library by name.
238///
239/// Request grid must have a `name` (Str) column.
240/// Returns a single-row grid with `unloaded` (the library name).
241pub async fn handle_unload_lib(
242    req: HttpRequest,
243    body: String,
244    state: web::Data<AppState>,
245) -> Result<HttpResponse, HaystackError> {
246    let content_type = req
247        .headers()
248        .get("Content-Type")
249        .and_then(|v| v.to_str().ok())
250        .unwrap_or("");
251    let accept = req
252        .headers()
253        .get("Accept")
254        .and_then(|v| v.to_str().ok())
255        .unwrap_or("");
256
257    let grid = content::decode_request_grid(&body, content_type)
258        .map_err(|e| HaystackError::bad_request(format!("decode error: {e}")))?;
259    let row = grid
260        .row(0)
261        .ok_or_else(|| HaystackError::bad_request("request grid has no rows"))?;
262    let name = match row.get("name") {
263        Some(Kind::Str(s)) => s.clone(),
264        _ => return Err(HaystackError::bad_request("name column required")),
265    };
266
267    let mut ns = state.namespace.write();
268    ns.unload_lib(&name).map_err(HaystackError::bad_request)?;
269
270    let cols = vec![HCol::new("unloaded")];
271    let mut result = HDict::new();
272    result.set("unloaded", Kind::Str(name));
273    let grid = HGrid::from_parts(HDict::new(), cols, vec![result]);
274    let (encoded, ct) = content::encode_response_grid(&grid, accept)
275        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
276    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
277}
278
279/// POST /api/exportLib — export a library to Xeto source text.
280///
281/// Request grid must have a `name` (Str) column.
282/// Returns a single-row grid with `name` and `source` (Xeto text).
283pub async fn handle_export_lib(
284    req: HttpRequest,
285    body: String,
286    state: web::Data<AppState>,
287) -> Result<HttpResponse, HaystackError> {
288    let content_type = req
289        .headers()
290        .get("Content-Type")
291        .and_then(|v| v.to_str().ok())
292        .unwrap_or("");
293    let accept = req
294        .headers()
295        .get("Accept")
296        .and_then(|v| v.to_str().ok())
297        .unwrap_or("");
298
299    let grid = content::decode_request_grid(&body, content_type)
300        .map_err(|e| HaystackError::bad_request(format!("decode error: {e}")))?;
301    let row = grid
302        .row(0)
303        .ok_or_else(|| HaystackError::bad_request("request grid has no rows"))?;
304    let name = match row.get("name") {
305        Some(Kind::Str(s)) => s.clone(),
306        _ => return Err(HaystackError::bad_request("name column required")),
307    };
308
309    let ns = state.namespace.read();
310    let xeto_text = ns
311        .export_lib_xeto(&name)
312        .map_err(HaystackError::bad_request)?;
313
314    let cols = vec![HCol::new("name"), HCol::new("source")];
315    let mut result = HDict::new();
316    result.set("name", Kind::Str(name));
317    result.set("source", Kind::Str(xeto_text));
318    let grid = HGrid::from_parts(HDict::new(), cols, vec![result]);
319    let (encoded, ct) = content::encode_response_grid(&grid, accept)
320        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
321    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
322}
323
324/// POST /api/validate — validate entities against the ontology.
325///
326/// Each row in the request grid is an entity dict to validate.
327/// Returns a grid of validation issues with columns: `entity` (Str),
328/// `issueType` (Str), and `detail` (Str). An empty grid means all
329/// entities passed validation.
330pub async fn handle_validate(
331    req: HttpRequest,
332    body: String,
333    state: web::Data<AppState>,
334) -> Result<HttpResponse, HaystackError> {
335    let content_type = req
336        .headers()
337        .get("Content-Type")
338        .and_then(|v| v.to_str().ok())
339        .unwrap_or("");
340    let accept = req
341        .headers()
342        .get("Accept")
343        .and_then(|v| v.to_str().ok())
344        .unwrap_or("");
345
346    let grid = content::decode_request_grid(&body, content_type)
347        .map_err(|e| HaystackError::bad_request(format!("decode error: {e}")))?;
348
349    let ns = state.namespace.read();
350
351    let cols = vec![
352        HCol::new("entity"),
353        HCol::new("issueType"),
354        HCol::new("detail"),
355    ];
356    let mut rows: Vec<HDict> = Vec::new();
357
358    for entity in &grid.rows {
359        let issues = ns.validate_entity(entity);
360        for issue in issues {
361            let mut row = HDict::new();
362            if let Some(ref e) = issue.entity {
363                row.set("entity", Kind::Str(e.clone()));
364            }
365            row.set("issueType", Kind::Str(issue.issue_type));
366            row.set("detail", Kind::Str(issue.detail));
367            rows.push(row);
368        }
369    }
370
371    let grid = HGrid::from_parts(HDict::new(), cols, rows);
372    let (encoded, ct) = content::encode_response_grid(&grid, accept)
373        .map_err(|e| HaystackError::internal(format!("encoding error: {e}")))?;
374    Ok(HttpResponse::Ok().content_type(ct).body(encoded))
375}