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