Skip to main content

relay_knowledge/interfaces/
web.rs

1//! Web HTTP adapter for same-origin diagnostics and static assets.
2
3#[path = "web_code_api.rs"]
4mod web_code_api;
5#[path = "web_code_index_request.rs"]
6mod web_code_index_request;
7#[path = "web_code_view_request.rs"]
8mod web_code_view_request;
9#[path = "web_files.rs"]
10mod web_files;
11#[path = "web_model_config.rs"]
12mod web_model_config;
13
14use std::path::{Component, Path, PathBuf};
15use std::sync::Arc;
16
17use axum::body::Body;
18use axum::extract::{Path as AxumPath, Query, State};
19use axum::http::{StatusCode, header};
20use axum::response::{IntoResponse, Response};
21use axum::routing::{get, post};
22use axum::{Json, Router};
23use serde::{Deserialize, Serialize};
24use serde_json::{Value, json};
25use tower_http::limit::RequestBodyLimitLayer;
26
27use crate::{
28    api::{
29        ApiError, AuditQueryApiRequest, CodeRepositoryRegisterRequest, ErrorKind,
30        GRAPH_CANVAS_DEFAULT_LIMIT, GraphCanvasKind, GraphCanvasRequest, GraphInspectionRequest,
31        HybridRetrievalRequest, IndexRefreshRequest, IngestEvidence, IngestRequest, InterfaceKind,
32        ProposalDecisionApiRequest, ProposalListApiRequest, RequestContext, WorkerRunRequest,
33        WorkerStatusRequest,
34    },
35    application::RelayKnowledgeService,
36    domain::{
37        CodeFeatureFlagRequest, CodeGraphContextRequest, CodeImpactRequest, CodeIndexMode,
38        CodeQueryKind, CodeRepositorySelector, CodeRepositorySetAddMemberRequest,
39        CodeRepositorySetCreateRequest, CodeRepositorySetQueryRequest,
40        CodeRepositorySetRemoveMemberRequest, CodeRetrievalRequest, FreshnessPolicy, IndexKind,
41        ProposalState, SoftwareGlobalKind, SoftwareGlobalRequest, WorkerKind,
42    },
43};
44pub(super) use web_code_index_request::code_index_request;
45
46/// Builds the Web router without opening sockets.
47pub fn router(service: RelayKnowledgeService, max_request_body_bytes: u64) -> Router {
48    router_with_assets(service, default_web_dist(), max_request_body_bytes)
49}
50
51fn router_with_assets(
52    service: RelayKnowledgeService,
53    asset_root: PathBuf,
54    max_request_body_bytes: u64,
55) -> Router {
56    let state = WebState {
57        service,
58        asset_root: Arc::new(asset_root),
59    };
60    let body_limit = usize::try_from(max_request_body_bytes).unwrap_or(usize::MAX);
61
62    Router::new()
63        .route("/api/project/status", get(project_status))
64        .route("/api/health", get(health))
65        .route("/api/service/status", get(service_status))
66        .route("/api/v1/control/status", get(control_status))
67        .route("/api/v1/control/health", get(control_health))
68        .route(
69            "/api/v1/control/service/status",
70            get(read_only_service_status),
71        )
72        .route("/api/v1/control/storage/topology", get(storage_topology))
73        .merge(web_code_api::routes())
74        .route("/api/web/graph/canvas", get(graph_canvas))
75        .route("/api/web/operations/execute", post(execute_operation))
76        .merge(web_model_config::routes())
77        .route("/", get(index))
78        .route("/{*path}", get(asset_or_index))
79        .with_state(state)
80        .layer(RequestBodyLimitLayer::new(body_limit))
81}
82
83async fn project_status(State(state): State<WebState>) -> Response {
84    match state
85        .service
86        .project_status(RequestContext::for_interface(InterfaceKind::Web))
87        .await
88    {
89        Ok(response) => Json(response).into_response(),
90        Err(error) => api_error_response(error),
91    }
92}
93
94async fn control_status(State(state): State<WebState>) -> Response {
95    let (response, _) = state
96        .service
97        .runtime_diagnostics(RequestContext::for_interface(InterfaceKind::Web));
98
99    Json(response).into_response()
100}
101
102async fn health(State(state): State<WebState>) -> Response {
103    match state
104        .service
105        .health(RequestContext::for_interface(InterfaceKind::Web))
106        .await
107    {
108        Ok(response) => Json(response).into_response(),
109        Err(error) => api_error_response(error),
110    }
111}
112
113async fn control_health(State(state): State<WebState>) -> Response {
114    match state
115        .service
116        .read_only_health(RequestContext::for_interface(InterfaceKind::Web))
117        .await
118    {
119        Ok(response) => Json(response).into_response(),
120        Err(error) => api_error_response(error),
121    }
122}
123
124async fn service_status(State(state): State<WebState>) -> Response {
125    match state
126        .service
127        .service_status(RequestContext::for_interface(InterfaceKind::Web))
128        .await
129    {
130        Ok(response) => Json(response).into_response(),
131        Err(error) => api_error_response(error),
132    }
133}
134
135async fn read_only_service_status(State(state): State<WebState>) -> Response {
136    match state
137        .service
138        .read_only_service_status(RequestContext::for_interface(InterfaceKind::Web))
139        .await
140    {
141        Ok(response) => Json(response).into_response(),
142        Err(error) => api_error_response(error),
143    }
144}
145
146async fn storage_topology(State(state): State<WebState>) -> Response {
147    match state
148        .service
149        .storage_topology_status(RequestContext::for_interface(InterfaceKind::Web))
150        .await
151    {
152        Ok(response) => Json(response).into_response(),
153        Err(error) => api_error_response(error),
154    }
155}
156
157async fn graph_canvas(
158    State(state): State<WebState>,
159    Query(query): Query<GraphCanvasQuery>,
160) -> Response {
161    let kind = match query
162        .kind
163        .as_deref()
164        .map(GraphCanvasKind::parse)
165        .transpose()
166    {
167        Ok(kind) => kind.unwrap_or(GraphCanvasKind::Knowledge),
168        Err(message) => return WebError::bad_request(message).into_response(),
169    };
170    let request = GraphCanvasRequest {
171        kind,
172        source_scope: query.scope.and_then(non_empty_query_value),
173        query: query.query.and_then(non_empty_query_value),
174        limit: query.limit.unwrap_or(GRAPH_CANVAS_DEFAULT_LIMIT),
175    };
176
177    match state
178        .service
179        .graph_canvas(request, RequestContext::for_interface(InterfaceKind::Web))
180        .await
181    {
182        Ok(response) => Json(response).into_response(),
183        Err(error) => api_error_response(error),
184    }
185}
186
187async fn execute_operation(
188    State(state): State<WebState>,
189    Json(request): Json<ExecuteOperationRequest>,
190) -> Result<Response, WebError> {
191    let operation = string_field(&request.snapshot.payload, "operation")?;
192    let context = RequestContext::for_interface(InterfaceKind::Web);
193    let (metadata, result) = dispatch_operation(
194        &state.service,
195        operation,
196        &request.snapshot.payload,
197        context,
198    )
199    .await?;
200    let response = ExecuteOperationResponse {
201        metadata,
202        operation: operation.to_owned(),
203        name: request.snapshot.name,
204        command: request.snapshot.command,
205        result,
206    };
207
208    Ok(Json(response).into_response())
209}
210
211async fn dispatch_operation(
212    service: &RelayKnowledgeService,
213    operation: &str,
214    payload: &Value,
215    context: RequestContext,
216) -> Result<(crate::api::ApiMetadata, Value), WebError> {
217    match operation {
218        "retrieve.context" => {
219            let response = service
220                .retrieve_context(retrieve_request(payload)?, context)
221                .await?;
222            Ok((response.metadata.clone(), json!(response)))
223        }
224        "graph.ingest" => {
225            let response = service.ingest(ingest_request(payload)?, context).await?;
226            Ok((response.metadata.clone(), json!(response)))
227        }
228        "graph.inspect" => {
229            let response = service
230                .inspect_graph(graph_request(payload), context)
231                .await?;
232            Ok((response.metadata.clone(), json!(response)))
233        }
234        "index.refresh" => {
235            let response = service
236                .refresh_indexes(index_request(payload)?, context)
237                .await?;
238            Ok((response.metadata.clone(), json!(response)))
239        }
240        "files.index" | "files.query" | "files.content" => {
241            web_files::dispatch_file_operation(service, operation, payload, context).await
242        }
243        "service.doctor" | "service.run.streamable_http" => {
244            let response = service.service_status(context).await?;
245            Ok((response.metadata.clone(), json!(response)))
246        }
247        "provider.embedding.probe" => {
248            let response = service.probe_embedding_provider(context).await?;
249            Ok((response.metadata.clone(), json!(response)))
250        }
251        "worker.status" => {
252            let response = service
253                .worker_status(
254                    WorkerStatusRequest {
255                        kind: optional_worker_kind(payload)?,
256                    },
257                    context,
258                )
259                .await?;
260            Ok((response.metadata.clone(), json!(response)))
261        }
262        "worker.run-once" => {
263            let response = service
264                .run_worker_once(
265                    WorkerRunRequest {
266                        kind: optional_worker_kind(payload)?,
267                    },
268                    context,
269                )
270                .await?;
271            Ok((response.metadata.clone(), json!(response)))
272        }
273        "proposal.list" => {
274            let response = service
275                .list_proposals(
276                    ProposalListApiRequest {
277                        state: optional_proposal_state(payload)?,
278                        limit: usize_field(payload, "limit")?,
279                    },
280                    context,
281                )
282                .await?;
283            Ok((response.metadata.clone(), json!(response)))
284        }
285        "proposal.show" => {
286            let response = service
287                .show_proposal(string_field(payload, "proposal_id")?.to_owned(), context)
288                .await?;
289            Ok((response.metadata.clone(), json!(response)))
290        }
291        "proposal.accept" => {
292            let response = service
293                .accept_proposal(
294                    string_field(payload, "proposal_id")?.to_owned(),
295                    proposal_decision_request(payload)?,
296                    context,
297                )
298                .await?;
299            Ok((response.metadata.clone(), json!(response)))
300        }
301        "proposal.reject" => {
302            let response = service
303                .decide_proposal_without_commit(
304                    string_field(payload, "proposal_id")?.to_owned(),
305                    ProposalState::Rejected,
306                    proposal_decision_request(payload)?,
307                    context,
308                )
309                .await?;
310            Ok((response.metadata.clone(), json!(response)))
311        }
312        "proposal.supersede" => {
313            let response = service
314                .decide_proposal_without_commit(
315                    string_field(payload, "proposal_id")?.to_owned(),
316                    ProposalState::Superseded,
317                    proposal_decision_request(payload)?,
318                    context,
319                )
320                .await?;
321            Ok((response.metadata.clone(), json!(response)))
322        }
323        "audit.query" => {
324            let response = service
325                .query_audit(
326                    AuditQueryApiRequest {
327                        operation: optional_string_field(payload, "filter_operation"),
328                        limit: usize_field(payload, "limit")?,
329                    },
330                    context,
331                )
332                .await?;
333            Ok((response.metadata.clone(), json!(response)))
334        }
335        "code.repo.register" => {
336            let response = service
337                .register_code_repository(code_register_request(payload)?, context)
338                .await?;
339            Ok((response.metadata.clone(), json!(response)))
340        }
341        "code.repo.index" => {
342            let response = service
343                .start_code_repository_index(
344                    code_index_request(payload, CodeIndexMode::Full)?,
345                    context,
346                )
347                .await?;
348            Ok((response.metadata.clone(), json!(response)))
349        }
350        "code.repo.update" => {
351            let mode = CodeIndexMode::incremental(
352                string_field(payload, "base_ref")?,
353                string_field(payload, "head_ref")?,
354            )
355            .map_err(|error| WebError::bad_request(error.to_string()))?;
356            let response = service
357                .index_code_repository(code_index_request(payload, mode)?, context)
358                .await?;
359            Ok((response.metadata.clone(), json!(response)))
360        }
361        "code.repo.query" => {
362            let response = service
363                .query_code_repository(code_query_request(payload)?, context)
364                .await?;
365            Ok((response.metadata.clone(), json!(response)))
366        }
367        "code.repo.context" => {
368            let response = service
369                .codegraph_context(code_context_request(payload)?, context)
370                .await?;
371            Ok((response.metadata.clone(), json!(response)))
372        }
373        "code.repo.feature_flags" => {
374            let response = service
375                .query_code_repository_feature_flags(code_feature_flag_request(payload)?, context)
376                .await?;
377            Ok((response.metadata.clone(), json!(response)))
378        }
379        "code.repo.impact" => {
380            let response = service
381                .impact_code_repository(code_impact_request(payload)?, context)
382                .await?;
383            Ok((response.metadata.clone(), json!(response)))
384        }
385        "code.repo.view" => {
386            let response = service
387                .codebase_view(web_code_view_request::code_view_request(payload)?, context)
388                .await?;
389            Ok((response.metadata.clone(), json!(response)))
390        }
391        "code.repo.software" => {
392            let response = service
393                .software_global_projection(code_software_request(payload)?, context)
394                .await?;
395            Ok((response.metadata.clone(), json!(response)))
396        }
397        "code.repo.status" => {
398            let response = service
399                .code_repository_status(code_selector(payload)?, context)
400                .await?;
401            Ok((response.metadata.clone(), json!(response)))
402        }
403        "code.repo_set.create" => {
404            let response = service
405                .create_code_repository_set(code_repository_set_create_request(payload)?, context)
406                .await?;
407            Ok((response.metadata.clone(), json!(response)))
408        }
409        "code.repo_set.add" => {
410            let response = service
411                .add_code_repository_set_member(code_repository_set_add_request(payload)?, context)
412                .await?;
413            Ok((response.metadata.clone(), json!(response)))
414        }
415        "code.repo_set.remove" => {
416            let response = service
417                .remove_code_repository_set_member(
418                    code_repository_set_remove_request(payload)?,
419                    context,
420                )
421                .await?;
422            Ok((response.metadata.clone(), json!(response)))
423        }
424        "code.repo_set.query" => {
425            let response = service
426                .query_code_repository_set(code_repository_set_query_request(payload)?, context)
427                .await?;
428            Ok((response.metadata.clone(), json!(response)))
429        }
430        "code.repo_set.status" => {
431            let response = service
432                .code_repository_set_status(string_field(payload, "set_alias")?.to_owned(), context)
433                .await?;
434            Ok((response.metadata.clone(), json!(response)))
435        }
436        "code.repo_set.refresh" => {
437            let set_alias = string_field(payload, "set_alias")?.to_owned();
438            let response = if optional_bool_field(payload, "async")?.unwrap_or(false) {
439                service
440                    .start_code_repository_set_refresh(set_alias, context)
441                    .await?
442            } else {
443                service
444                    .refresh_code_repository_set(set_alias, context)
445                    .await?
446            };
447            Ok((response.metadata.clone(), json!(response)))
448        }
449        other => Err(WebError::bad_request(format!(
450            "unsupported web operation '{other}'"
451        ))),
452    }
453}
454
455async fn index(State(state): State<WebState>) -> Response {
456    serve_file_or_status(index_path(&state.asset_root), StatusCode::NOT_FOUND).await
457}
458
459async fn asset_or_index(
460    State(state): State<WebState>,
461    AxumPath(path): AxumPath<String>,
462) -> Response {
463    if path.starts_with("api/") {
464        return (StatusCode::NOT_FOUND, Json(json!({"message": "not found"}))).into_response();
465    }
466
467    match sanitized_asset_path(&state.asset_root, &path) {
468        Some(asset_path)
469            if tokio::fs::metadata(&asset_path)
470                .await
471                .is_ok_and(|meta| meta.is_file()) =>
472        {
473            serve_file_or_status(asset_path, StatusCode::NOT_FOUND).await
474        }
475        _ => serve_file_or_status(index_path(&state.asset_root), StatusCode::NOT_FOUND).await,
476    }
477}
478
479async fn serve_file_or_status(path: PathBuf, missing_status: StatusCode) -> Response {
480    match tokio::fs::read(&path).await {
481        Ok(body) => (
482            StatusCode::OK,
483            [(header::CONTENT_TYPE, content_type(&path))],
484            Body::from(body),
485        )
486            .into_response(),
487        Err(_) => (
488            missing_status,
489            Json(json!({"message": "web assets are not built; run ./build.sh"})),
490        )
491            .into_response(),
492    }
493}
494
495pub(super) fn api_error_response(error: ApiError) -> Response {
496    let status = match error.error_kind {
497        ErrorKind::InvalidArgument => StatusCode::BAD_REQUEST,
498        ErrorKind::StorageUnavailable => StatusCode::SERVICE_UNAVAILABLE,
499        ErrorKind::QosRejected => StatusCode::TOO_MANY_REQUESTS,
500        ErrorKind::Timeout => StatusCode::REQUEST_TIMEOUT,
501        ErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
502    };
503
504    (status, Json(error)).into_response()
505}
506
507fn sanitized_asset_path(root: &Path, requested: &str) -> Option<PathBuf> {
508    let mut path = root.to_path_buf();
509    for component in Path::new(requested).components() {
510        match component {
511            Component::Normal(segment) => path.push(segment),
512            Component::CurDir => {}
513            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
514        }
515    }
516
517    Some(path)
518}
519
520fn index_path(root: &Path) -> PathBuf {
521    root.join("index.html")
522}
523
524fn default_web_dist() -> PathBuf {
525    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
526        .join("web")
527        .join("dist")
528}
529
530fn content_type(path: &Path) -> &'static str {
531    match path.extension().and_then(|extension| extension.to_str()) {
532        Some("css") => "text/css; charset=utf-8",
533        Some("html") => "text/html; charset=utf-8",
534        Some("js") => "text/javascript; charset=utf-8",
535        Some("json") => "application/json",
536        Some("svg") => "image/svg+xml",
537        Some("wasm") => "application/wasm",
538        _ => "application/octet-stream",
539    }
540}
541
542fn retrieve_request(payload: &Value) -> Result<HybridRetrievalRequest, WebError> {
543    Ok(HybridRetrievalRequest {
544        query: string_field(payload, "query")?.to_owned(),
545        source_scope: optional_string_field(payload, "source_scope"),
546        freshness: parse_freshness(string_field(payload, "freshness")?)?,
547        limit: usize_field(payload, "limit")?,
548    })
549}
550
551fn ingest_request(payload: &Value) -> Result<IngestRequest, WebError> {
552    Ok(IngestRequest {
553        source_scope: string_field(payload, "source_scope")?.to_owned(),
554        evidence: vec![IngestEvidence {
555            id: None,
556            source_path: None,
557            span: None,
558            confidence: None,
559            status: None,
560            content: string_field(payload, "content")?.to_owned(),
561            entity_labels: string_array_field(payload, "entity_labels")?,
562            extraction: None,
563        }],
564        relations: Vec::new(),
565        claims: Vec::new(),
566        events: Vec::new(),
567    })
568}
569
570fn graph_request(payload: &Value) -> GraphInspectionRequest {
571    GraphInspectionRequest {
572        source_scope: optional_string_field(payload, "source_scope"),
573    }
574}
575
576fn index_request(payload: &Value) -> Result<IndexRefreshRequest, WebError> {
577    Ok(IndexRefreshRequest {
578        kinds: string_array_field(payload, "kinds")?
579            .into_iter()
580            .map(|kind| parse_index_kind(&kind))
581            .collect::<Result<Vec<_>, _>>()?,
582    })
583}
584
585fn code_register_request(payload: &Value) -> Result<CodeRepositoryRegisterRequest, WebError> {
586    Ok(CodeRepositoryRegisterRequest {
587        root_path: string_field(payload, "root_path")?.to_owned(),
588        alias: code_register_alias(payload)?,
589        path_filters: optional_string_array_field(payload, "path_filters")?,
590        language_filters: optional_string_array_field(payload, "language_filters")?,
591    })
592}
593
594fn code_register_alias(payload: &Value) -> Result<String, WebError> {
595    match payload.get("alias") {
596        Some(Value::String(alias)) => Ok(alias.trim().to_owned()),
597        Some(_) => Err(WebError::bad_request("alias must be a string".to_owned())),
598        None => Ok(String::new()),
599    }
600}
601
602fn code_query_request(payload: &Value) -> Result<CodeRetrievalRequest, WebError> {
603    let mut request = CodeRetrievalRequest::new(
604        string_field(payload, "query")?,
605        code_selector(payload)?,
606        parse_code_query_kind(string_field(payload, "kind")?)?,
607        usize_field(payload, "limit")?,
608        parse_freshness(string_field(payload, "freshness")?)?,
609    )
610    .map_err(|error| WebError::bad_request(error.to_string()))?;
611    request.exclude_generated = optional_bool_field(payload, "exclude_generated")?.unwrap_or(false);
612    Ok(request)
613}
614
615fn code_context_request(payload: &Value) -> Result<CodeGraphContextRequest, WebError> {
616    CodeGraphContextRequest::new(
617        code_selector(payload)?,
618        string_field(payload, "query")?,
619        usize_field(payload, "limit")?,
620        parse_freshness(string_field(payload, "freshness")?)?,
621        usize_field(payload, "max_context_bytes")?,
622        optional_bool_field(payload, "include_code")?.unwrap_or(true),
623        optional_bool_field(payload, "exclude_generated")?.unwrap_or(false),
624    )
625    .map_err(|error| WebError::bad_request(error.to_string()))
626}
627
628fn code_feature_flag_request(payload: &Value) -> Result<CodeFeatureFlagRequest, WebError> {
629    CodeFeatureFlagRequest::new(
630        optional_string_field(payload, "query"),
631        code_selector(payload)?,
632        usize_field(payload, "limit")?,
633        parse_freshness(string_field(payload, "freshness")?)?,
634    )
635    .map_err(|error| WebError::bad_request(error.to_string()))
636}
637
638fn code_impact_request(payload: &Value) -> Result<CodeImpactRequest, WebError> {
639    CodeImpactRequest::new(
640        code_selector(payload)?,
641        string_field(payload, "base_ref")?,
642        string_field(payload, "head_ref")?,
643        usize_field(payload, "limit")?,
644    )
645    .map_err(|error| WebError::bad_request(error.to_string()))
646}
647
648fn code_software_request(payload: &Value) -> Result<SoftwareGlobalRequest, WebError> {
649    SoftwareGlobalRequest::new(
650        code_selector(payload)?,
651        parse_software_kind(string_field(payload, "kind")?)?,
652        parse_freshness(string_field(payload, "freshness")?)?,
653        usize_field(payload, "limit")?,
654    )
655    .map_err(|error| WebError::bad_request(error.to_string()))
656}
657
658fn code_selector(payload: &Value) -> Result<CodeRepositorySelector, WebError> {
659    CodeRepositorySelector::new(
660        string_field(payload, "alias")?,
661        optional_string_field(payload, "ref").unwrap_or_else(|| "HEAD".to_owned()),
662        optional_string_array_field(payload, "path_filters")?,
663        optional_string_array_field(payload, "language_filters")?,
664    )
665    .map_err(|error| WebError::bad_request(error.to_string()))
666}
667
668fn code_repository_set_create_request(
669    payload: &Value,
670) -> Result<CodeRepositorySetCreateRequest, WebError> {
671    CodeRepositorySetCreateRequest::new(
672        string_field(payload, "set_alias")?,
673        optional_string_field(payload, "description"),
674        optional_string_field(payload, "default_ref_policy_json"),
675    )
676    .map_err(|error| WebError::bad_request(error.to_string()))
677}
678
679fn code_repository_set_add_request(
680    payload: &Value,
681) -> Result<CodeRepositorySetAddMemberRequest, WebError> {
682    CodeRepositorySetAddMemberRequest::new(
683        string_field(payload, "set_alias")?,
684        string_field(payload, "repository_alias")?,
685        string_field(payload, "ref")?,
686        optional_string_array_field(payload, "path_filters")?,
687        optional_string_array_field(payload, "language_filters")?,
688        optional_i32_field(payload, "priority")?.unwrap_or(0),
689    )
690    .map_err(|error| WebError::bad_request(error.to_string()))
691}
692
693fn code_repository_set_remove_request(
694    payload: &Value,
695) -> Result<CodeRepositorySetRemoveMemberRequest, WebError> {
696    CodeRepositorySetRemoveMemberRequest::new(
697        string_field(payload, "set_alias")?,
698        string_field(payload, "repository_alias")?,
699    )
700    .map_err(|error| WebError::bad_request(error.to_string()))
701}
702
703fn code_repository_set_query_request(
704    payload: &Value,
705) -> Result<CodeRepositorySetQueryRequest, WebError> {
706    let mut request = CodeRepositorySetQueryRequest::new(
707        string_field(payload, "set_alias")?,
708        string_field(payload, "query")?,
709        parse_code_query_kind(string_field(payload, "kind")?)?,
710        usize_field(payload, "limit")?,
711        parse_freshness(string_field(payload, "freshness")?)?,
712        optional_string_array_field(payload, "path_filters")?,
713        optional_string_array_field(payload, "language_filters")?,
714    )
715    .map_err(|error| WebError::bad_request(error.to_string()))?;
716    request.exclude_generated = optional_bool_field(payload, "exclude_generated")?.unwrap_or(false);
717    Ok(request)
718}
719
720fn string_field<'a>(payload: &'a Value, field: &'static str) -> Result<&'a str, WebError> {
721    payload
722        .get(field)
723        .and_then(Value::as_str)
724        .filter(|value| !value.trim().is_empty())
725        .ok_or_else(|| WebError::bad_request(format!("{field} is required")))
726}
727
728fn optional_string_field(payload: &Value, field: &'static str) -> Option<String> {
729    payload
730        .get(field)
731        .and_then(Value::as_str)
732        .map(str::trim)
733        .filter(|value| !value.is_empty())
734        .map(ToOwned::to_owned)
735}
736
737fn string_array_field(payload: &Value, field: &'static str) -> Result<Vec<String>, WebError> {
738    payload
739        .get(field)
740        .and_then(Value::as_array)
741        .ok_or_else(|| WebError::bad_request(format!("{field} must be an array")))?
742        .iter()
743        .map(|item| {
744            item.as_str()
745                .map(str::trim)
746                .filter(|value| !value.is_empty())
747                .map(ToOwned::to_owned)
748                .ok_or_else(|| {
749                    WebError::bad_request(format!("{field} contains a non-string value"))
750                })
751        })
752        .collect()
753}
754
755fn optional_string_array_field(
756    payload: &Value,
757    field: &'static str,
758) -> Result<Vec<String>, WebError> {
759    if payload.get(field).is_none() {
760        return Ok(Vec::new());
761    }
762
763    string_array_field(payload, field)
764}
765
766fn usize_field(payload: &Value, field: &'static str) -> Result<usize, WebError> {
767    payload
768        .get(field)
769        .and_then(Value::as_u64)
770        .and_then(|value| usize::try_from(value).ok())
771        .filter(|value| *value > 0)
772        .ok_or_else(|| WebError::bad_request(format!("{field} must be a positive integer")))
773}
774
775fn i32_field(payload: &Value, field: &'static str) -> Result<i32, WebError> {
776    payload
777        .get(field)
778        .and_then(Value::as_i64)
779        .and_then(|value| i32::try_from(value).ok())
780        .ok_or_else(|| WebError::bad_request(format!("{field} must be an integer")))
781}
782
783fn optional_i32_field(payload: &Value, field: &'static str) -> Result<Option<i32>, WebError> {
784    if payload.get(field).is_none() {
785        return Ok(None);
786    }
787
788    i32_field(payload, field).map(Some)
789}
790
791fn optional_bool_field(payload: &Value, field: &'static str) -> Result<Option<bool>, WebError> {
792    if payload.get(field).is_none() {
793        return Ok(None);
794    }
795
796    payload
797        .get(field)
798        .and_then(Value::as_bool)
799        .map(Some)
800        .ok_or_else(|| WebError::bad_request(format!("{field} must be a boolean")))
801}
802
803fn parse_freshness(value: &str) -> Result<FreshnessPolicy, WebError> {
804    match value {
805        "allow-stale" => Ok(FreshnessPolicy::AllowStale),
806        "wait-until-fresh" => Ok(FreshnessPolicy::WaitUntilFresh),
807        "graph-only" => Ok(FreshnessPolicy::GraphOnly),
808        other => Err(WebError::bad_request(format!(
809            "unsupported freshness '{other}'"
810        ))),
811    }
812}
813
814fn parse_index_kind(value: &str) -> Result<IndexKind, WebError> {
815    match value {
816        "bm25" => Ok(IndexKind::Bm25),
817        "semantic" => Ok(IndexKind::Semantic),
818        "vector" => Ok(IndexKind::Vector),
819        other => Err(WebError::bad_request(format!(
820            "unsupported index kind '{other}'"
821        ))),
822    }
823}
824
825fn parse_code_query_kind(value: &str) -> Result<CodeQueryKind, WebError> {
826    match value {
827        "hybrid" => Ok(CodeQueryKind::Hybrid),
828        "symbol" => Ok(CodeQueryKind::Symbol),
829        "definition" => Ok(CodeQueryKind::Definition),
830        "references" => Ok(CodeQueryKind::References),
831        "callers" => Ok(CodeQueryKind::Callers),
832        "callees" => Ok(CodeQueryKind::Callees),
833        "imports" => Ok(CodeQueryKind::Imports),
834        "sbom" => Ok(CodeQueryKind::Sbom),
835        other => Err(WebError::bad_request(format!(
836            "unsupported code query kind '{other}'"
837        ))),
838    }
839}
840
841fn parse_software_kind(value: &str) -> Result<SoftwareGlobalKind, WebError> {
842    match value {
843        "dependencies" => Ok(SoftwareGlobalKind::Dependencies),
844        "sdks" => Ok(SoftwareGlobalKind::Sdks),
845        "files" => Ok(SoftwareGlobalKind::Files),
846        "topics" => Ok(SoftwareGlobalKind::Topics),
847        "relationships" => Ok(SoftwareGlobalKind::Relationships),
848        "build" => Ok(SoftwareGlobalKind::Build),
849        "iac" => Ok(SoftwareGlobalKind::Iac),
850        "design" => Ok(SoftwareGlobalKind::Design),
851        "all" => Ok(SoftwareGlobalKind::All),
852        other => Err(WebError::bad_request(format!(
853            "unsupported software kind '{other}'"
854        ))),
855    }
856}
857
858fn optional_worker_kind(payload: &Value) -> Result<Option<WorkerKind>, WebError> {
859    optional_string_field(payload, "kind")
860        .map(|kind| {
861            WorkerKind::parse(&kind)
862                .map_err(|_| WebError::bad_request(format!("unsupported worker kind '{kind}'")))
863        })
864        .transpose()
865}
866
867fn optional_proposal_state(payload: &Value) -> Result<Option<ProposalState>, WebError> {
868    optional_string_field(payload, "state")
869        .map(|state| {
870            ProposalState::parse(&state)
871                .map_err(|_| WebError::bad_request(format!("unsupported proposal state '{state}'")))
872        })
873        .transpose()
874}
875
876fn proposal_decision_request(payload: &Value) -> Result<ProposalDecisionApiRequest, WebError> {
877    Ok(ProposalDecisionApiRequest {
878        actor: string_field(payload, "actor")?.to_owned(),
879        reason: optional_string_field(payload, "reason"),
880    })
881}
882
883#[derive(Debug, Deserialize)]
884struct GraphCanvasQuery {
885    kind: Option<String>,
886    scope: Option<String>,
887    query: Option<String>,
888    limit: Option<usize>,
889}
890
891fn non_empty_query_value(value: String) -> Option<String> {
892    let trimmed = value.trim();
893
894    (!trimmed.is_empty()).then(|| trimmed.to_owned())
895}
896
897#[derive(Debug, Deserialize)]
898struct ExecuteOperationRequest {
899    snapshot: WebOperationSnapshot,
900}
901
902#[derive(Debug, Deserialize)]
903struct WebOperationSnapshot {
904    name: String,
905    command: String,
906    payload: Value,
907}
908
909#[derive(Debug, Serialize)]
910struct ExecuteOperationResponse {
911    metadata: crate::api::ApiMetadata,
912    operation: String,
913    name: String,
914    command: String,
915    result: Value,
916}
917
918#[derive(Debug)]
919pub(in crate::interfaces) struct WebError {
920    status: StatusCode,
921    message: String,
922}
923
924impl WebError {
925    fn bad_request(message: String) -> Self {
926        Self {
927            status: StatusCode::BAD_REQUEST,
928            message,
929        }
930    }
931}
932
933impl From<ApiError> for WebError {
934    fn from(error: ApiError) -> Self {
935        let status = match error.error_kind {
936            ErrorKind::InvalidArgument => StatusCode::BAD_REQUEST,
937            ErrorKind::StorageUnavailable => StatusCode::SERVICE_UNAVAILABLE,
938            ErrorKind::QosRejected => StatusCode::TOO_MANY_REQUESTS,
939            ErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
940            ErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
941        };
942
943        Self {
944            status,
945            message: error.message,
946        }
947    }
948}
949
950impl IntoResponse for WebError {
951    fn into_response(self) -> Response {
952        (self.status, Json(json!({ "error": self.message }))).into_response()
953    }
954}
955
956#[derive(Clone)]
957pub(super) struct WebState {
958    pub(super) service: RelayKnowledgeService,
959    asset_root: Arc<PathBuf>,
960}
961
962#[cfg(test)]
963#[path = "web_control_tests.rs"]
964mod control_tests;
965
966#[cfg(test)]
967#[path = "web_code_api_tests.rs"]
968mod code_api_tests;
969
970#[cfg(test)]
971#[path = "web_files_tests.rs"]
972mod files_tests;
973
974#[cfg(test)]
975#[path = "web_tests.rs"]
976mod tests;