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