diff --git a/crates/api/src/admin_search.rs b/crates/api/src/admin_search.rs new file mode 100644 index 0000000..a232db3 --- /dev/null +++ b/crates/api/src/admin_search.rs @@ -0,0 +1,112 @@ +//! Admin full-text search over catalogue objects. Read capability: `ViewInternal` +//! (admins search across all visibility levels). Backed by the Meilisearch index. + +use auth::{Authorized, ViewInternal}; +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + routing::get, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::AppState; + +#[derive(Deserialize)] +pub(crate) struct SearchParams { + #[serde(default)] + q: String, + visibility: Option, + offset: Option, + limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct SearchHitView { + pub id: String, + pub object_number: String, + pub object_name: String, + pub brief_description: Option, + pub visibility: String, + pub snippet: Option, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct SearchResultsView { + pub hits: Vec, + /// Meilisearch's estimate of the total number of matches. + pub estimated_total: usize, +} + +#[utoipa::path( + get, path = "/api/admin/search", + params( + ("q" = String, Query, description = "Search query text"), + ("visibility" = Option, Query, description = "Filter: draft|internal|public"), + ("offset" = Option, Query, description = "default 0"), + ("limit" = Option, Query, description = "1..=50, default 20") + ), + responses( + (status = 200, body = SearchResultsView), + (status = 400, description = "Invalid visibility value"), + (status = 401), + (status = 403), + (status = 503, description = "Search is not configured") + ) +)] +pub(crate) async fn search_objects( + _auth: Authorized, + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let Some(search) = &state.search else { + return Err(StatusCode::SERVICE_UNAVAILABLE); + }; + + let visibility = match params.visibility.as_deref() { + None | Some("") => None, + Some(v @ ("draft" | "internal" | "public")) => Some(v), + Some(_) => return Err(StatusCode::BAD_REQUEST), + }; + + let q = params.q.trim(); + + if q.is_empty() { + return Ok(Json(SearchResultsView { + hits: Vec::new(), + estimated_total: 0, + })); + } + + let offset = params.offset.unwrap_or(0).max(0) as usize; + let limit = params.limit.unwrap_or(20).clamp(1, 50) as usize; + + let results = search + .search_objects(q, visibility, offset, limit) + .await + .map_err(|err| { + tracing::error!(?err, "search query failed"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(SearchResultsView { + hits: results + .hits + .into_iter() + .map(|h| SearchHitView { + id: h.id, + object_number: h.object_number, + object_name: h.object_name, + brief_description: h.brief_description, + visibility: h.visibility, + snippet: h.snippet, + }) + .collect(), + estimated_total: results.estimated_total, + })) +} + +pub(crate) fn routes() -> Router { + Router::new().route("/api/admin/search", get(search_objects)) +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index f5f3bfa..ebf0c9b 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -3,6 +3,7 @@ mod admin; mod admin_authorities; mod admin_objects; +mod admin_search; mod admin_vocab; mod health; mod openapi; @@ -63,6 +64,7 @@ pub fn build_app(state: AppState) -> Router { .merge(admin::routes()) .merge(admin_objects::routes()) .merge(admin_vocab::routes()) + .merge(admin_search::routes()) .merge(admin_authorities::routes()) .layer(session_layer) .with_state(state) diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 2e55f50..7d08eae 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -1,7 +1,9 @@ use axum::{Json, Router, extract::State, routing::get}; use utoipa::OpenApi; -use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, health, public}; +use crate::{ + AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, health, public, +}; #[derive(OpenApi)] #[openapi( @@ -26,6 +28,7 @@ use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, heal admin_vocab::create_vocabulary, admin_vocab::list_terms, admin_vocab::add_term, + admin_search::search_objects, admin_authorities::list_authorities, admin_authorities::create_authority ), @@ -50,6 +53,8 @@ use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, heal admin_vocab::LabelInput, admin_vocab::TermView, admin_vocab::CreatedId, + admin_search::SearchHitView, + admin_search::SearchResultsView, admin_authorities::AuthorityView, admin_authorities::NewAuthorityRequest )), diff --git a/crates/api/tests/admin_search.rs b/crates/api/tests/admin_search.rs new file mode 100644 index 0000000..c2541e7 --- /dev/null +++ b/crates/api/tests/admin_search.rs @@ -0,0 +1,215 @@ +use api::{AppState, build_app, migrate_sessions}; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use db::users; +use domain::{AuditActor, Email, NewUser, Role}; +use http_body_util::BodyExt; +use search::SearchClient; +use sqlx::PgPool; +use tower::ServiceExt; + +fn meili() -> (String, String) { + ( + std::env::var("MEILI_URL").expect("MEILI_URL must be set"), + std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"), + ) +} + +fn unique_index() -> String { + format!("api_search_test_{}", uuid::Uuid::new_v4().simple()) +} + +fn state(pool: PgPool, search: Option) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: "Test".into(), + cookie_secure: false, + search, + } +} + +async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + + users::create_user( + &mut tx, + AuditActor::System, + &NewUser { + email: Email::parse(email).unwrap(), + password_hash: auth::hash_password(password).unwrap(), + role, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); +} + +async fn login(app: &axum::Router, email: &str, password: &str) -> String { + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!( + r#"{{"email":"{email}","password":"{password}"}}"# + ))) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + resp.headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn search_requires_auth(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + let (url, key) = meili(); + let search = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + + search.ensure_index().await.unwrap(); + + let app = build_app(state(pool, Some(search))); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/admin/search?q=bronze") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn search_returns_results_and_validates_params(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let (url, key) = meili(); + let search = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + + search.ensure_index().await.unwrap(); + + let app = build_app(state(pool.clone(), Some(search))); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let create = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/objects") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"object_number":"R-1","object_name":"astrolabe","number_of_objects":1,"visibility":"internal"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create.status(), StatusCode::CREATED); + + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/search?q=astrolabe") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body: serde_json::Value = + serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + assert_eq!(body["estimated_total"], 1); + assert_eq!(body["hits"][0]["object_name"], "astrolabe"); + + let empty = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/search?q=") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(empty.status(), StatusCode::OK); + + let empty_body: serde_json::Value = + serde_json::from_slice(&empty.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + assert_eq!(empty_body["estimated_total"], 0); + + let bad = app + .oneshot( + Request::builder() + .uri("/api/admin/search?q=astrolabe&visibility=bogus") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(bad.status(), StatusCode::BAD_REQUEST); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn search_unavailable_when_not_configured(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let app = build_app(state(pool, None)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let resp = app + .oneshot( + Request::builder() + .uri("/api/admin/search?q=bronze") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); +} diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 4e271b6..6959d49 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -168,6 +168,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search_objects"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/users": { parameters: { query?: never; @@ -430,6 +446,19 @@ export interface components { /** @description `"ok"` when ready, `"degraded"` otherwise. */ status: string; }; + SearchHitView: { + brief_description?: string | null; + id: string; + object_name: string; + object_number: string; + snippet?: string | null; + visibility: string; + }; + SearchResultsView: { + /** @description Meilisearch's estimate of the total number of matches. */ + estimated_total: number; + hits: components["schemas"]["SearchHitView"][]; + }; TermView: { external_uri?: string | null; id: string; @@ -947,6 +976,60 @@ export interface operations { }; }; }; + search_objects: { + parameters: { + query: { + /** @description Search query text */ + q: string; + /** @description Filter: draft|internal|public */ + visibility?: string; + /** @description default 0 */ + offset?: number; + /** @description 1..=50, default 20 */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchResultsView"]; + }; + }; + /** @description Invalid visibility value */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Search is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_users: { parameters: { query?: never;