Skip to main content

relay_knowledge/interfaces/
web.rs

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