1use std::collections::HashMap;
16use std::sync::Arc;
17
18use axum::body::Bytes;
19use axum::extract::{Path, State};
20use axum::http::{header, Method, StatusCode};
21use axum::response::{IntoResponse, Response};
22use axum::{Extension, Json};
23use laterite_auth::AuthenticatedUser;
24use laterite_core::query::{bind_values, build as to_sql, text_cast};
25use laterite_core::search::SearchProfile;
26use laterite_core::strata::async_trait;
27use laterite_core::{AnyRowExt, Db};
28use sea_query::{Alias, Expr, Query, SelectStatement};
29use serde::{Deserialize, Serialize};
30
31use crate::sql::valid_ident;
32use crate::AdminState;
33
34#[derive(Debug, Clone, Serialize)]
38pub struct PickerNode {
39 pub id: String,
40 pub label: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub hint: Option<String>,
43}
44
45#[derive(Debug, thiserror::Error)]
47#[error("picker source error: {0}")]
48pub struct PickerError(pub String);
49
50#[async_trait]
55pub trait PickerSource: Send + Sync + 'static {
56 async fn search(&self, db: &Db, q: &str, limit: u32) -> Result<Vec<PickerNode>, PickerError>;
58 async fn resolve(&self, db: &Db, id: &str) -> Result<Option<PickerNode>, PickerError>;
60}
61
62pub struct PickerSourceReg {
65 pub name: String,
66 pub source: Arc<dyn PickerSource>,
67 pub permission: Option<String>,
68}
69
70impl PickerSourceReg {
71 pub fn new(name: impl Into<String>, source: Arc<dyn PickerSource>) -> Self {
73 Self {
74 name: name.into(),
75 source,
76 permission: None,
77 }
78 }
79 pub fn require(mut self, permission: impl Into<String>) -> Self {
81 self.permission = Some(permission.into());
82 self
83 }
84}
85
86pub type PickerRegistry = HashMap<String, PickerSourceReg>;
88
89pub struct TableSource {
94 table: String,
95 id_col: String,
96 label_col: String,
97 hint_col: Option<String>,
98 search: SearchProfile,
99}
100
101impl TableSource {
102 pub fn new(
103 table: impl Into<String>,
104 id_col: impl Into<String>,
105 label_col: impl Into<String>,
106 ) -> Self {
107 Self {
108 table: table.into(),
109 id_col: id_col.into(),
110 label_col: label_col.into(),
111 hint_col: None,
112 search: SearchProfile::new(),
117 }
118 }
119
120 pub fn with_hint(mut self, hint_col: impl Into<String>) -> Self {
122 self.hint_col = Some(hint_col.into());
123 self
124 }
125
126 pub fn with_search(mut self, search: SearchProfile) -> Self {
132 self.search = search;
133 self
134 }
135
136 fn idents_valid(&self) -> bool {
139 valid_ident(&self.table)
140 && valid_ident(&self.id_col)
141 && valid_ident(&self.label_col)
142 && self.hint_col.as_deref().is_none_or(valid_ident)
143 }
144
145 fn base_select(&self, cast: &str) -> SelectStatement {
148 let mut sel = Query::select();
149 sel.expr_as(
150 Expr::col(Alias::new(&self.id_col)).cast_as(Alias::new(cast)),
151 Alias::new("id"),
152 );
153 sel.expr_as(
154 Expr::col(Alias::new(&self.label_col)).cast_as(Alias::new(cast)),
155 Alias::new("label"),
156 );
157 if let Some(hint) = &self.hint_col {
158 sel.expr_as(
159 Expr::col(Alias::new(hint)).cast_as(Alias::new(cast)),
160 Alias::new("hint"),
161 );
162 }
163 sel.from(Alias::new(&self.table));
164 sel
165 }
166}
167
168#[async_trait]
169impl PickerSource for TableSource {
170 async fn search(&self, db: &Db, q: &str, limit: u32) -> Result<Vec<PickerNode>, PickerError> {
171 if !self.idents_valid() {
172 return Err(PickerError("invalid source columns".into()));
173 }
174 let has_hint = self.hint_col.is_some();
175 let (sql, values) = {
176 let cast = text_cast(db.backend);
177 let mut sel = self.base_select(cast);
178 let caps = laterite_core::capabilities::CapabilitySet::default();
182 if let Some(cond) = self.search.condition(&caps, &self.label_col, q) {
185 sel.cond_where(cond);
186 }
187 sel.limit(limit as u64);
188 to_sql(db.backend, sel)
189 };
190 let rows = bind_values(sqlx::query(&sql), values)
191 .fetch_all(&db.pool)
192 .await
193 .map_err(|e| PickerError(e.to_string()))?;
194 Ok(rows
195 .iter()
196 .map(|r| PickerNode {
197 id: r.get_text_opt("id").ok().flatten().unwrap_or_default(),
198 label: r.get_text_opt("label").ok().flatten().unwrap_or_default(),
199 hint: has_hint
200 .then(|| r.get_text_opt("hint").ok().flatten())
201 .flatten(),
202 })
203 .collect())
204 }
205
206 async fn resolve(&self, db: &Db, id: &str) -> Result<Option<PickerNode>, PickerError> {
207 if !self.idents_valid() {
208 return Err(PickerError("invalid source columns".into()));
209 }
210 let has_hint = self.hint_col.is_some();
211 let (sql, values) = {
212 let cast = text_cast(db.backend);
213 let mut sel = self.base_select(cast);
214 sel.and_where(
215 Expr::col(Alias::new(&self.id_col))
216 .cast_as(Alias::new(cast))
217 .eq(id),
218 );
219 sel.limit(1);
220 to_sql(db.backend, sel)
221 };
222 let row = bind_values(sqlx::query(&sql), values)
223 .fetch_optional(&db.pool)
224 .await
225 .map_err(|e| PickerError(e.to_string()))?;
226 Ok(row.map(|r| PickerNode {
227 id: r.get_text_opt("id").ok().flatten().unwrap_or_default(),
228 label: r.get_text_opt("label").ok().flatten().unwrap_or_default(),
229 hint: has_hint
230 .then(|| r.get_text_opt("hint").ok().flatten())
231 .flatten(),
232 }))
233 }
234}
235
236#[derive(Deserialize)]
242struct SearchReq {
243 #[serde(default)]
244 q: String,
245 #[serde(default)]
246 limit: Option<u32>,
247}
248
249#[derive(Deserialize)]
250struct ResolveReq {
251 #[serde(default)]
252 id: String,
253}
254
255#[derive(Serialize)]
256struct SearchResp {
257 items: Vec<PickerNode>,
258}
259
260#[derive(Serialize)]
261struct ResolveResp {
262 item: Option<PickerNode>,
263}
264
265const DEFAULT_LIMIT: u32 = 20;
266const MAX_LIMIT: u32 = 50;
267
268pub(crate) async fn search(
269 method: Method,
270 State(state): State<AdminState>,
271 Extension(user): Extension<AuthenticatedUser>,
272 Path(source): Path<String>,
273 body: Bytes,
274) -> Response {
275 let reg = match guard(&method, &state, &user, &source) {
276 Ok(reg) => reg,
277 Err(resp) => return *resp,
278 };
279 let req: SearchReq = match serde_json::from_slice(&body) {
280 Ok(req) => req,
281 Err(_) => return json_no_store(StatusCode::BAD_REQUEST, &err("invalid request body")),
282 };
283 let limit = req.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
284 match reg.source.search(&state.db, req.q.trim(), limit).await {
285 Ok(items) => json_no_store(StatusCode::OK, &SearchResp { items }),
286 Err(_) => json_no_store(StatusCode::INTERNAL_SERVER_ERROR, &err("search failed")),
287 }
288}
289
290pub(crate) async fn resolve(
291 method: Method,
292 State(state): State<AdminState>,
293 Extension(user): Extension<AuthenticatedUser>,
294 Path(source): Path<String>,
295 body: Bytes,
296) -> Response {
297 let reg = match guard(&method, &state, &user, &source) {
298 Ok(reg) => reg,
299 Err(resp) => return *resp,
300 };
301 let req: ResolveReq = match serde_json::from_slice(&body) {
302 Ok(req) => req,
303 Err(_) => return json_no_store(StatusCode::BAD_REQUEST, &err("invalid request body")),
304 };
305 match reg.source.resolve(&state.db, req.id.trim()).await {
306 Ok(item) => json_no_store(StatusCode::OK, &ResolveResp { item }),
307 Err(_) => json_no_store(StatusCode::INTERNAL_SERVER_ERROR, &err("resolve failed")),
308 }
309}
310
311fn guard<'a>(
314 method: &Method,
315 state: &'a AdminState,
316 user: &AuthenticatedUser,
317 source: &str,
318) -> Result<&'a PickerSourceReg, Box<Response>> {
319 if method.as_str() != "QUERY" {
320 return Err(Box::new(json_no_store(
321 StatusCode::METHOD_NOT_ALLOWED,
322 &err("use the QUERY method"),
323 )));
324 }
325 let reg = state.pickers.get(source).ok_or_else(|| {
326 Box::new(json_no_store(
327 StatusCode::NOT_FOUND,
328 &err("unknown picker source"),
329 ))
330 })?;
331 if let Some(permission) = ®.permission {
332 if !user.permissions.allows(permission) {
333 return Err(Box::new(json_no_store(
334 StatusCode::FORBIDDEN,
335 &err("forbidden"),
336 )));
337 }
338 }
339 Ok(reg)
340}
341
342fn json_no_store<T: Serialize>(status: StatusCode, body: &T) -> Response {
343 (status, [(header::CACHE_CONTROL, "no-store")], Json(body)).into_response()
344}
345
346fn err(message: &str) -> serde_json::Value {
347 serde_json::json!({ "error": message })
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use laterite_core::strata::{
354 async_trait as strata_async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema,
355 Table,
356 };
357 use laterite_core::testing::{connect_test, TestGuard};
358
359 struct CreatePlaces;
360 #[strata_async_trait(?Send)]
361 impl Migration for CreatePlaces {
362 fn name(&self) -> &str {
363 "0001_create_places"
364 }
365 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
366 s.exec(
367 Table::create()
368 .table(Alias::new("places"))
369 .if_not_exists()
370 .col(
371 ColumnDef::new(Alias::new("id"))
372 .big_integer()
373 .not_null()
374 .auto_increment()
375 .primary_key(),
376 )
377 .col(ColumnDef::new(Alias::new("name")).text().not_null())
378 .col(ColumnDef::new(Alias::new("region")).text().not_null())
379 .to_owned(),
380 )
381 .await
382 }
383 }
384
385 async fn test_db() -> (Db, TestGuard) {
386 let places = MigrationSet::new("test.places", vec![Box::new(CreatePlaces)]);
387 connect_test(&[places]).await
388 }
389
390 async fn insert(db: &Db, name: &str, region: &str) {
391 let (sql, values) = to_sql(
392 db.backend,
393 Query::insert()
394 .into_table(Alias::new("places"))
395 .columns([Alias::new("name"), Alias::new("region")])
396 .values_panic([name.to_string().into(), region.to_string().into()])
397 .to_owned(),
398 );
399 bind_values(sqlx::query(&sql), values)
400 .execute(&db.pool)
401 .await
402 .unwrap();
403 }
404
405 #[tokio::test]
406 async fn table_source_searches_case_insensitively_and_resolves_by_id() {
407 let (db, _guard) = test_db().await;
408 insert(&db, "Kigali", "Rwanda").await;
409 insert(&db, "Kampala", "Uganda").await;
410 let source = TableSource::new("places", "id", "name").with_hint("region");
411
412 let hits = source.search(&db, "kig", 10).await.unwrap();
414 assert_eq!(hits.len(), 1);
415 assert_eq!(hits[0].label, "Kigali");
416 assert_eq!(hits[0].hint.as_deref(), Some("Rwanda"));
417
418 let node = source
420 .resolve(&db, &hits[0].id)
421 .await
422 .unwrap()
423 .expect("id resolves");
424 assert_eq!(node.label, "Kigali");
425
426 assert!(source.resolve(&db, "99999").await.unwrap().is_none());
428
429 insert(&db, "A_B", "Zone").await;
431 insert(&db, "AXB", "Zone").await;
432 let underscore = source.search(&db, "a_b", 10).await.unwrap();
434 assert_eq!(underscore.len(), 1);
435 assert_eq!(underscore[0].label, "A_B");
436 assert!(source.search(&db, "%", 10).await.unwrap().is_empty());
438 }
439
440 #[tokio::test]
443 async fn an_empty_query_lists_the_first_rows() {
444 let (db, _guard) = test_db().await;
445 insert(&db, "Kigali", "Rwanda").await;
446 insert(&db, "Kampala", "Uganda").await;
447 let source = TableSource::new("places", "id", "name");
448
449 assert_eq!(source.search(&db, "", 10).await.unwrap().len(), 2);
450 assert_eq!(source.search(&db, "", 1).await.unwrap().len(), 1);
452 }
453}