Skip to main content

code_system_graph/
http_server.rs

1//! Optional authenticated HTTP delivery for read-only application services.
2
3use std::collections::HashMap;
4use std::ffi::OsString;
5use std::fmt;
6use std::net::{IpAddr, Ipv4Addr, SocketAddr};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use axum::Router;
12use axum::extract::rejection::JsonRejection;
13use axum::extract::{ConnectInfo, DefaultBodyLimit, Json, Request, State};
14use axum::http::header::{
15    AUTHORIZATION, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_SECURITY_POLICY, HeaderName, HeaderValue, REFERRER_POLICY, WWW_AUTHENTICATE, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS
16};
17use axum::http::{Method, StatusCode};
18use axum::middleware::{self, Next};
19use axum::response::{IntoResponse, Response};
20use axum::routing::{get, post};
21use code_system_graph_core::{
22    ChangeAnalysisOptions, ImpactReport, ImpactRequest, LocalContextResult, SearchReport
23};
24use code_system_graph_model::{
25    FreshnessSummary, NodeKind, OverallFreshness, RepoId, ToolEnvelope, ToolStatus
26};
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use subtle::ConstantTimeEq;
30use thiserror::Error;
31use tokio::net::TcpListener;
32use tokio::sync::{Mutex, Semaphore};
33use tokio_util::sync::CancellationToken;
34use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
35
36use crate::{
37    ApplicationError, CODEGRAPH_DISABLED_CODE, CODEGRAPH_DISABLED_MESSAGE, ChangesInput, CommunityInput, CommunityReport, ExploreInput, SearchInput, TraceInput, analyze_workspace_changes, communities_workspace, explore_repository, impact_workspace, impact_workspace_with_codegraph, search_workspace, status_workspace, trace_workspace
38};
39
40/// Default loopback address used by optional HTTP delivery.
41pub const DEFAULT_HTTP_BIND: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4767);
42
43const BODY_LIMIT_BYTES: usize = 1024 * 1024;
44const BODY_LIMIT_BYTES_U64: u64 = 1024 * 1024;
45const CONCURRENCY_LIMIT: usize = 32;
46const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
47const RATE_LIMIT_REQUESTS: u32 = 60;
48const RATE_LIMIT_WINDOW: Duration = Duration::from_mins(1);
49const MAX_RATE_LIMIT_CLIENTS: usize = 4096;
50const MAX_TOKEN_BYTES: usize = 4096;
51const SCHEMA_VERSION: u32 = 1;
52
53const VERSION_HEADER: HeaderName = HeaderName::from_static("x-code-system-graph-version");
54const SCHEMA_HEADER: HeaderName = HeaderName::from_static("x-code-system-graph-schema-version");
55const PERMISSIONS_POLICY: HeaderName = HeaderName::from_static("permissions-policy");
56
57/// A bearer token whose formatting and errors never reveal its contents.
58pub struct BearerToken {
59    bytes: Box<[u8; MAX_TOKEN_BYTES]>,
60    len: usize,
61}
62
63impl BearerToken {
64    /// Creates a non-empty bearer token within the fixed comparison budget.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`BearerTokenError`] when the value is empty, whitespace-only, or larger than the
69    /// fixed token budget.
70    pub fn new(value: impl AsRef<str>) -> Result<Self, BearerTokenError> {
71        let value = value.as_ref();
72        if value.trim().is_empty() {
73            return Err(BearerTokenError::Invalid);
74        }
75        let source = value.as_bytes();
76        if source.len() > MAX_TOKEN_BYTES {
77            return Err(BearerTokenError::Invalid);
78        }
79        let mut bytes = Box::new([0_u8; MAX_TOKEN_BYTES]);
80        bytes[..source.len()].copy_from_slice(source);
81        Ok(Self {
82            bytes,
83            len: source.len(),
84        })
85    }
86
87    fn matches(&self, candidate: &[u8]) -> bool {
88        let mut padded = [0_u8; MAX_TOKEN_BYTES];
89        let candidate_fits = candidate.len() <= MAX_TOKEN_BYTES;
90        if candidate_fits {
91            padded[..candidate.len()].copy_from_slice(candidate);
92        }
93        let content_matches = self.bytes.as_ref().ct_eq(&padded);
94        let length_matches = self.len.ct_eq(&candidate.len());
95        bool::from(content_matches & length_matches) && candidate_fits
96    }
97}
98
99impl Clone for BearerToken {
100    fn clone(&self) -> Self {
101        Self {
102            bytes: self.bytes.clone(),
103            len: self.len,
104        }
105    }
106}
107
108impl fmt::Debug for BearerToken {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        formatter.write_str("BearerToken([REDACTED])")
111    }
112}
113
114impl Drop for BearerToken {
115    fn drop(&mut self) {
116        self.bytes.fill(0);
117        self.len = 0;
118    }
119}
120
121/// Failure to construct a safe bearer token.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
123pub enum BearerTokenError {
124    /// The supplied token does not satisfy the fixed non-empty token contract.
125    #[error("bearer token must be non-empty and within the supported size limit")]
126    Invalid,
127}
128
129/// Fixed workspace and database configuration for one HTTP server.
130#[derive(Debug, Clone)]
131pub struct HttpServerConfig {
132    /// Socket address on which the server listens.
133    pub bind: SocketAddr,
134    /// Workspace manifest used by status operations.
135    pub config_path: PathBuf,
136    /// Immutable database selection used by every request.
137    pub database_path: PathBuf,
138    /// Immutable workspace selection used by every tool request.
139    pub workspace: String,
140    /// Optional bearer token required by every route when configured.
141    pub bearer_token: Option<BearerToken>,
142    /// Whether automatic bounded `CodeGraph` enrichment is enabled.
143    pub codegraph_enabled: bool,
144    /// Optional explicit `CodeGraph` executable used by local exploration and impact enrichment.
145    pub codegraph_binary: Option<OsString>,
146}
147
148impl HttpServerConfig {
149    /// Creates loopback-only anonymous HTTP configuration for one workspace.
150    #[must_use]
151    pub fn new(
152        config_path: impl Into<PathBuf>,
153        database_path: impl Into<PathBuf>,
154        workspace: impl Into<String>,
155    ) -> Self {
156        Self {
157            bind: DEFAULT_HTTP_BIND,
158            config_path: config_path.into(),
159            database_path: database_path.into(),
160            workspace: workspace.into(),
161            bearer_token: None,
162            codegraph_enabled: false,
163            codegraph_binary: None,
164        }
165    }
166
167    /// Selects an explicit bind address.
168    #[must_use]
169    pub const fn with_bind(mut self, bind: SocketAddr) -> Self {
170        self.bind = bind;
171        self
172    }
173
174    /// Requires the supplied redacted bearer token on every route.
175    #[must_use]
176    pub fn with_bearer_token(mut self, token: BearerToken) -> Self {
177        self.bearer_token = Some(token);
178        self
179    }
180
181    /// Applies trusted process-level `CodeGraph` policy to local intelligence requests.
182    #[must_use]
183    pub fn with_codegraph(mut self, enabled: bool, binary: Option<OsString>) -> Self {
184        self.codegraph_enabled = enabled;
185        self.codegraph_binary = binary;
186        self
187    }
188
189    fn validate_for(&self, bind: SocketAddr) -> Result<(), HttpServerError> {
190        if self.workspace.trim().is_empty() {
191            return Err(HttpServerError::InvalidConfiguration(
192                "HTTP workspace must be non-empty",
193            ));
194        }
195        if !bind.ip().is_loopback() && self.bearer_token.is_none() {
196            return Err(HttpServerError::InvalidConfiguration(
197                "non-loopback HTTP bind requires a bearer token",
198            ));
199        }
200        Ok(())
201    }
202}
203
204/// HTTP server startup or serving failure.
205#[derive(Debug, Error)]
206pub enum HttpServerError {
207    /// Configuration violates a fail-closed server invariant.
208    #[error("{0}")]
209    InvalidConfiguration(&'static str),
210    /// The configured socket could not be bound.
211    #[error("failed to bind HTTP server")]
212    Bind(#[source] std::io::Error),
213    /// The HTTP transport stopped unexpectedly.
214    #[error("HTTP server failed")]
215    Serve(#[source] std::io::Error),
216    /// An externally supplied listener could not report its local address.
217    #[error("failed to inspect HTTP listener")]
218    ListenerAddress(#[source] std::io::Error),
219}
220
221/// Empty input accepted by `status`.
222#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224pub struct StatusInput {}
225
226/// Input for a contract-only ranked query.
227#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
228#[serde(deny_unknown_fields)]
229pub struct ContractsInput {
230    /// Text matched against contract identities and labels.
231    #[serde(default)]
232    pub query: String,
233    /// Optional repository filter.
234    #[serde(default)]
235    pub repo_ids: Vec<RepoId>,
236    /// Zero-based result offset.
237    #[serde(default)]
238    pub offset: usize,
239    /// Bounded page size.
240    #[serde(default = "default_contract_limit")]
241    pub limit: usize,
242}
243
244const fn default_contract_limit() -> usize {
245    20
246}
247
248#[derive(Clone)]
249struct HttpState {
250    config: Arc<HttpServerConfig>,
251    limiter: Arc<RateLimiter>,
252    concurrency: Arc<Semaphore>,
253}
254
255#[derive(Debug)]
256struct RateWindow {
257    started: Instant,
258    requests: u32,
259}
260
261#[derive(Debug, Default)]
262struct RateLimiter {
263    clients: Mutex<HashMap<IpAddr, RateWindow>>,
264}
265
266impl RateLimiter {
267    async fn check(&self, client: IpAddr) -> bool {
268        let now = Instant::now();
269        let mut clients = self.clients.lock().await;
270        clients.retain(|_, window| now.duration_since(window.started) < RATE_LIMIT_WINDOW);
271
272        if let Some(window) = clients.get_mut(&client) {
273            if window.requests >= RATE_LIMIT_REQUESTS {
274                return false;
275            }
276            window.requests += 1;
277            return true;
278        }
279
280        if clients.len() >= MAX_RATE_LIMIT_CLIENTS
281            && let Some(oldest) = clients
282                .iter()
283                .min_by_key(|(_, window)| window.started)
284                .map(|(address, _)| *address)
285        {
286            clients.remove(&oldest);
287        }
288        clients.insert(
289            client,
290            RateWindow {
291                started: now,
292                requests: 1,
293            },
294        );
295        true
296    }
297}
298
299#[derive(Debug, Serialize)]
300struct HealthReport {
301    healthy: bool,
302    version: &'static str,
303}
304
305#[derive(Debug, Serialize)]
306struct ErrorReport {
307    code: &'static str,
308    message: &'static str,
309}
310
311/// Builds the read-only HTTP router after validating bind and authentication policy.
312///
313/// Requests executed directly against this router without transport connection metadata are
314/// rate-limited as loopback requests.
315///
316/// # Errors
317///
318/// Returns [`HttpServerError::InvalidConfiguration`] for an empty workspace or for an anonymous
319/// non-loopback bind.
320pub fn create_router(config: HttpServerConfig) -> Result<Router, HttpServerError> {
321    config.validate_for(config.bind)?;
322    let state = HttpState {
323        config: Arc::new(config),
324        limiter: Arc::new(RateLimiter::default()),
325        concurrency: Arc::new(Semaphore::new(CONCURRENCY_LIMIT)),
326    };
327
328    Ok(Router::new()
329        .route("/health", get(health))
330        .route("/v1/status", get(system_status))
331        .route("/v1/tools/status", post(status))
332        .route("/v1/tools/query", post(query))
333        .route("/v1/tools/trace", post(trace))
334        .route("/v1/tools/explore", post(explore))
335        .route("/v1/tools/impact", post(impact))
336        .route("/v1/tools/analyze_changes", post(analyze_changes))
337        .route("/v1/tools/contracts", post(contracts))
338        .route("/v1/tools/communities", post(communities))
339        .fallback(unsupported_route)
340        .method_not_allowed_fallback(method_not_allowed)
341        .layer(DefaultBodyLimit::max(BODY_LIMIT_BYTES))
342        .layer(SetSensitiveRequestHeadersLayer::new(std::iter::once(
343            AUTHORIZATION,
344        )))
345        .layer(middleware::from_fn_with_state(state.clone(), request_guard))
346        .with_state(state))
347}
348
349/// Binds and serves optional HTTP delivery until cancellation requests graceful shutdown.
350///
351/// # Errors
352///
353/// Returns [`HttpServerError`] when configuration validation, binding, or serving fails.
354pub async fn serve_http(
355    config: HttpServerConfig,
356    cancellation: CancellationToken,
357) -> Result<(), HttpServerError> {
358    config.validate_for(config.bind)?;
359    let listener = TcpListener::bind(config.bind)
360        .await
361        .map_err(HttpServerError::Bind)?;
362    serve_http_on_listener(listener, config, cancellation).await
363}
364
365/// Serves on an existing listener, retaining the listener's actual bind policy.
366///
367/// This entry point supports race-free host integration tests and embedding. The listener address
368/// is independently validated so a loopback configuration cannot authorize a non-loopback socket.
369///
370/// # Errors
371///
372/// Returns [`HttpServerError`] when listener inspection, configuration validation, or serving
373/// fails.
374pub async fn serve_http_on_listener(
375    listener: TcpListener,
376    mut config: HttpServerConfig,
377    cancellation: CancellationToken,
378) -> Result<(), HttpServerError> {
379    let address = listener
380        .local_addr()
381        .map_err(HttpServerError::ListenerAddress)?;
382    config.validate_for(address)?;
383    config.bind = address;
384    let router = create_router(config)?;
385    axum::serve(
386        listener,
387        router.into_make_service_with_connect_info::<SocketAddr>(),
388    )
389    .with_graceful_shutdown(cancellation.cancelled_owned())
390    .await
391    .map_err(HttpServerError::Serve)
392}
393
394async fn request_guard(State(state): State<HttpState>, request: Request, next: Next) -> Response {
395    if request
396        .headers()
397        .get(CONTENT_LENGTH)
398        .and_then(|value| value.to_str().ok())
399        .and_then(|value| value.parse::<u64>().ok())
400        .is_some_and(|length| length > BODY_LIMIT_BYTES_U64)
401    {
402        return secure_response(error_response(
403            StatusCode::PAYLOAD_TOO_LARGE,
404            "payload_too_large",
405            "The request body exceeds the 1 MiB limit.",
406        ));
407    }
408
409    let client = request
410        .extensions()
411        .get::<ConnectInfo<SocketAddr>>()
412        .map_or(IpAddr::V4(Ipv4Addr::LOCALHOST), |address| address.ip());
413    if !state.limiter.check(client).await {
414        let mut response = error_response(
415            StatusCode::TOO_MANY_REQUESTS,
416            "rate_limited",
417            "The per-client request limit has been exceeded.",
418        );
419        response
420            .headers_mut()
421            .insert("retry-after", HeaderValue::from_static("60"));
422        return secure_response(response);
423    }
424
425    if !authorized(&state.config, &request) {
426        let mut response = error_response(
427            StatusCode::UNAUTHORIZED,
428            "unauthorized",
429            "A valid bearer token is required.",
430        );
431        response
432            .headers_mut()
433            .insert(WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
434        return secure_response(response);
435    }
436
437    let Ok(_permit) = state.concurrency.clone().try_acquire_owned() else {
438        return secure_response(error_response(
439            StatusCode::SERVICE_UNAVAILABLE,
440            "busy",
441            "The server concurrency limit has been reached.",
442        ));
443    };
444
445    match tokio::time::timeout(REQUEST_TIMEOUT, next.run(request)).await {
446        Ok(response) => secure_response(response),
447        Err(_) => secure_response(error_response(
448            StatusCode::GATEWAY_TIMEOUT,
449            "timeout",
450            "The request exceeded the server time limit.",
451        )),
452    }
453}
454
455fn authorized(config: &HttpServerConfig, request: &Request) -> bool {
456    let Some(expected) = config.bearer_token.as_ref() else {
457        return true;
458    };
459    request
460        .headers()
461        .get(AUTHORIZATION)
462        .and_then(|header| header.as_bytes().strip_prefix(b"Bearer "))
463        .is_some_and(|candidate| expected.matches(candidate))
464}
465
466fn secure_response(mut response: Response) -> Response {
467    let headers = response.headers_mut();
468    headers.insert(
469        VERSION_HEADER,
470        HeaderValue::from_static(env!("CARGO_PKG_VERSION")),
471    );
472    headers.insert(SCHEMA_HEADER, HeaderValue::from_static("1"));
473    headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
474    headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
475    headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
476    headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
477    headers.insert(
478        CONTENT_SECURITY_POLICY,
479        HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
480    );
481    headers.insert(
482        PERMISSIONS_POLICY,
483        HeaderValue::from_static(
484            "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=()",
485        ),
486    );
487    response
488}
489
490async fn health() -> Response {
491    success_response(ToolEnvelope {
492        schema_version: SCHEMA_VERSION,
493        status: ToolStatus::Ok,
494        data: Some(HealthReport {
495            healthy: true,
496            version: env!("CARGO_PKG_VERSION"),
497        }),
498        freshness: unknown_freshness(),
499        warnings: Vec::new(),
500    })
501}
502
503async fn system_status(State(state): State<HttpState>) -> Response {
504    status_response(state).await
505}
506
507async fn status(
508    State(state): State<HttpState>,
509    payload: Result<Json<StatusInput>, JsonRejection>,
510) -> Response {
511    if let Err(rejection) = payload {
512        return json_rejection_response(&rejection);
513    }
514    status_response(state).await
515}
516
517async fn status_response(state: HttpState) -> Response {
518    let config = Arc::clone(&state.config);
519    run_blocking(move || status_workspace(&config.config_path, &config.database_path))
520        .await
521        .map_or_else(tool_failure_response, |status| {
522            if status.workspace != state.config.workspace {
523                return error_response(
524                    StatusCode::UNPROCESSABLE_ENTITY,
525                    "workspace_mismatch",
526                    "The configured manifest does not match the fixed HTTP workspace.",
527                );
528            }
529            let freshness = status.freshness.clone();
530            success_response(ToolEnvelope {
531                schema_version: SCHEMA_VERSION,
532                status: tool_status_from_freshness(&freshness),
533                data: Some(status),
534                freshness,
535                warnings: Vec::new(),
536            })
537        })
538}
539
540async fn query(
541    State(state): State<HttpState>,
542    payload: Result<Json<SearchInput>, JsonRejection>,
543) -> Response {
544    let Json(input) = match payload {
545        Ok(input) => input,
546        Err(rejection) => return json_rejection_response(&rejection),
547    };
548    let config = Arc::clone(&state.config);
549    tool_service_response(
550        run_blocking(move || search_workspace(&config.database_path, &config.workspace, &input))
551            .await,
552    )
553}
554
555async fn trace(
556    State(state): State<HttpState>,
557    payload: Result<Json<TraceInput>, JsonRejection>,
558) -> Response {
559    let Json(input) = match payload {
560        Ok(input) => input,
561        Err(rejection) => return json_rejection_response(&rejection),
562    };
563    let config = Arc::clone(&state.config);
564    tool_service_response(
565        run_blocking(move || trace_workspace(&config.database_path, &config.workspace, &input))
566            .await,
567    )
568}
569
570async fn explore(
571    State(state): State<HttpState>,
572    payload: Result<Json<ExploreInput>, JsonRejection>,
573) -> Response {
574    if !state.config.codegraph_enabled {
575        return error_response(
576            StatusCode::FORBIDDEN,
577            CODEGRAPH_DISABLED_CODE,
578            CODEGRAPH_DISABLED_MESSAGE,
579        );
580    }
581    let Json(input) = match payload {
582        Ok(input) => input,
583        Err(rejection) => return json_rejection_response(&rejection),
584    };
585    let envelope: ToolEnvelope<LocalContextResult> = explore_repository(
586        &state.config.database_path,
587        &state.config.workspace,
588        &input,
589        state.config.codegraph_binary.clone(),
590    )
591    .await;
592    success_response(envelope)
593}
594
595async fn impact(
596    State(state): State<HttpState>,
597    payload: Result<Json<ImpactRequest>, JsonRejection>,
598) -> Response {
599    let Json(input) = match payload {
600        Ok(input) => input,
601        Err(rejection) => return json_rejection_response(&rejection),
602    };
603    let config = Arc::clone(&state.config);
604    let result: Result<ToolEnvelope<ImpactReport>, ToolFailure> = if config.codegraph_enabled {
605        impact_workspace_with_codegraph(
606            &config.database_path,
607            &config.workspace,
608            &input,
609            config.codegraph_binary.clone(),
610        )
611        .await
612        .map_err(|_| ToolFailure::Application)
613    } else {
614        run_blocking(move || impact_workspace(&config.database_path, &config.workspace, &input))
615            .await
616    };
617    tool_service_response(result)
618}
619
620async fn analyze_changes(
621    State(state): State<HttpState>,
622    payload: Result<Json<ChangesInput>, JsonRejection>,
623) -> Response {
624    let Json(input) = match payload {
625        Ok(input) => input,
626        Err(rejection) => return json_rejection_response(&rejection),
627    };
628    tool_service_response(
629        analyze_workspace_changes(
630            &state.config.database_path,
631            &state.config.workspace,
632            &input,
633            &ChangeAnalysisOptions::default(),
634            None,
635        )
636        .await
637        .map_err(|_| ToolFailure::Application),
638    )
639}
640
641async fn contracts(
642    State(state): State<HttpState>,
643    payload: Result<Json<ContractsInput>, JsonRejection>,
644) -> Response {
645    let Json(input) = match payload {
646        Ok(input) => input,
647        Err(rejection) => return json_rejection_response(&rejection),
648    };
649    let search = SearchInput {
650        query: input.query,
651        node_kinds: vec![
652            NodeKind::HttpOperation,
653            NodeKind::GraphqlOperation,
654            NodeKind::RpcMethod,
655            NodeKind::EventChannel,
656            NodeKind::EventSchema,
657        ],
658        repo_ids: input.repo_ids,
659        service_ids: Vec::new(),
660        community_ids: Vec::new(),
661        offset: input.offset,
662        limit: input.limit,
663    };
664    let config = Arc::clone(&state.config);
665    let result: Result<ToolEnvelope<SearchReport>, ToolFailure> =
666        run_blocking(move || search_workspace(&config.database_path, &config.workspace, &search))
667            .await;
668    tool_service_response(result)
669}
670
671async fn communities(
672    State(state): State<HttpState>,
673    payload: Result<Json<CommunityInput>, JsonRejection>,
674) -> Response {
675    let Json(input) = match payload {
676        Ok(input) => input,
677        Err(rejection) => return json_rejection_response(&rejection),
678    };
679    let config = Arc::clone(&state.config);
680    let result: Result<ToolEnvelope<CommunityReport>, ToolFailure> = run_blocking(move || {
681        communities_workspace(&config.database_path, &config.workspace, &input)
682    })
683    .await;
684    tool_service_response(result)
685}
686
687async fn unsupported_route(method: Method) -> Response {
688    let message = if matches!(
689        method,
690        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
691    ) {
692        "Mutating and administrative HTTP routes are disabled."
693    } else {
694        "The requested HTTP route is not supported."
695    };
696    error_response(StatusCode::NOT_FOUND, "not_found", message)
697}
698
699async fn method_not_allowed() -> Response {
700    error_response(
701        StatusCode::METHOD_NOT_ALLOWED,
702        "method_not_allowed",
703        "The HTTP method is not supported for this read-only route.",
704    )
705}
706
707#[derive(Debug, Clone, Copy)]
708enum ToolFailure {
709    Application,
710    Worker,
711}
712
713async fn run_blocking<T, F>(operation: F) -> Result<T, ToolFailure>
714where
715    T: Send + 'static,
716    F: FnOnce() -> Result<T, ApplicationError> + Send + 'static,
717{
718    tokio::task::spawn_blocking(operation)
719        .await
720        .map_err(|_| ToolFailure::Worker)?
721        .map_err(|_| ToolFailure::Application)
722}
723
724fn tool_service_response<T: Serialize>(result: Result<ToolEnvelope<T>, ToolFailure>) -> Response {
725    result.map_or_else(tool_failure_response, success_response)
726}
727
728fn tool_failure_response(failure: ToolFailure) -> Response {
729    let status = match failure {
730        ToolFailure::Application => StatusCode::UNPROCESSABLE_ENTITY,
731        ToolFailure::Worker => StatusCode::INTERNAL_SERVER_ERROR,
732    };
733    error_response(
734        status,
735        "tool_failed",
736        "The application service could not complete the request.",
737    )
738}
739
740fn json_rejection_response(rejection: &JsonRejection) -> Response {
741    let status = rejection.status();
742    let (code, message) = if status == StatusCode::PAYLOAD_TOO_LARGE {
743        (
744            "payload_too_large",
745            "The request body exceeds the 1 MiB limit.",
746        )
747    } else {
748        ("invalid_json", "The JSON request body is invalid.")
749    };
750    error_response(status, code, message)
751}
752
753fn success_response<T: Serialize>(envelope: ToolEnvelope<T>) -> Response {
754    (StatusCode::OK, Json(envelope)).into_response()
755}
756
757fn error_response(status: StatusCode, code: &'static str, message: &'static str) -> Response {
758    (
759        status,
760        Json(ToolEnvelope {
761            schema_version: SCHEMA_VERSION,
762            status: ToolStatus::Error,
763            data: Some(ErrorReport { code, message }),
764            freshness: unknown_freshness(),
765            warnings: Vec::new(),
766        }),
767    )
768        .into_response()
769}
770
771fn unknown_freshness() -> FreshnessSummary {
772    FreshnessSummary {
773        overall: OverallFreshness::Unknown,
774        stale_repositories: Vec::new(),
775        reasons: Vec::new(),
776    }
777}
778
779const fn tool_status_from_freshness(freshness: &FreshnessSummary) -> ToolStatus {
780    match freshness.overall {
781        OverallFreshness::Fresh => ToolStatus::Ok,
782        OverallFreshness::Stale | OverallFreshness::Partial | OverallFreshness::Unknown => {
783            ToolStatus::Degraded
784        }
785    }
786}