1#[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
46pub 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;