Skip to main content

crw_server/
app.rs

1use axum::Router;
2use axum::body::Body;
3use axum::extract::DefaultBodyLimit;
4use axum::http::{Request, StatusCode};
5use axum::middleware::Next;
6use axum::response::{IntoResponse, Response};
7use axum::routing::{get, post};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::Duration;
11use tower_http::cors::CorsLayer;
12use tower_http::set_header::SetResponseHeaderLayer;
13use tower_http::timeout::TimeoutLayer;
14use tower_http::trace::TraceLayer;
15
16/// Maximum request body size (1 MB) to prevent memory exhaustion from large payloads.
17const MAX_BODY_SIZE: usize = 1024 * 1024;
18
19use crate::middleware::auth_middleware;
20use crate::routes::{self, method_not_allowed};
21use crate::state::AppState;
22
23pub fn create_app(state: AppState) -> Router {
24    let api_keys = Arc::new(state.config.auth.api_keys.clone());
25    // Tower outer timeout. `effective_request_timeout_secs()` widens the
26    // operator baseline so the longest legitimate handler runtime (auto-extended
27    // scrape, search enrichment fan-out, map's 300s ceiling) isn't cancelled
28    // by the outer layer before the inner deadline fires. See issue #35.
29    let timeout = Duration::from_secs(state.config.effective_request_timeout_secs());
30    let rate_limit_rps = state.config.server.rate_limit_rps;
31
32    // Native vs Firecrawl-compat surface split.
33    //
34    // `/v1` (and, transitionally, `/v2`) are fastCRW's own API — free to evolve;
35    // staying Firecrawl-compatible is a bonus, not a contract.
36    //
37    // The SAME handlers are ALSO mounted under `/firecrawl/*`, which is the
38    // canonical, frozen Firecrawl drop-in surface (`/firecrawl/v1/*`,
39    // `/firecrawl/v2/*`). New callers who want guaranteed Firecrawl-shape should
40    // target `/firecrawl/*`; `/v2/*` at the root is a deprecated alias kept for
41    // backward-compat. Nesting reuses the v1/v2 routers verbatim, so both
42    // surfaces share request parsing, error envelopes, and body limits.
43    //
44    // All merged before the shared auth + rate-limit layers, so every surface
45    // inherits auth, rate-limiting, body-limit and the timeout layer identically.
46    // `/mcp` is version-less.
47    let firecrawl_compat = Router::new().nest(
48        "/firecrawl",
49        routes::v1::router().merge(routes::v2::router()),
50    );
51    let api_routes = routes::v1::router()
52        .merge(routes::v2::router())
53        .merge(firecrawl_compat)
54        .route(
55            "/mcp",
56            post(routes::mcp::mcp_handler).fallback(method_not_allowed),
57        );
58
59    let api_routes = if api_keys.is_empty() {
60        api_routes.with_state(state.clone())
61    } else {
62        api_routes
63            .route_layer(axum::middleware::from_fn_with_state(
64                api_keys,
65                auth_middleware,
66            ))
67            .with_state(state.clone())
68    };
69
70    let rate_limiter = if rate_limit_rps > 0 {
71        Some(Arc::new(RateLimiter::new(rate_limit_rps)))
72    } else {
73        None
74    };
75
76    Router::new()
77        .route(
78            "/health",
79            get(routes::health::health).fallback(method_not_allowed),
80        )
81        .route(
82            "/openapi.json",
83            get(routes::openapi::serve_openapi_3_1).fallback(method_not_allowed),
84        )
85        .route(
86            "/openapi-3.0.json",
87            get(routes::openapi::serve_openapi_3_0).fallback(method_not_allowed),
88        )
89        .route(
90            "/ready",
91            get(routes::health::ready).fallback(method_not_allowed),
92        )
93        .route(
94            "/metrics",
95            get(routes::metrics::metrics).fallback(method_not_allowed),
96        )
97        .route(
98            "/metrics/renderer-breakers",
99            get(routes::breakers::renderer_breakers).fallback(method_not_allowed),
100        )
101        .route(
102            "/admin/breakers/reset",
103            post(routes::breakers::reset_breakers).fallback(method_not_allowed),
104        )
105        .with_state(state)
106        .merge(api_routes)
107        .layer(axum::middleware::from_fn(move |req, next| {
108            let limiter = rate_limiter.clone();
109            rate_limit_middleware(limiter, req, next)
110        }))
111        .layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
112        .layer(TimeoutLayer::with_status_code(
113            StatusCode::GATEWAY_TIMEOUT,
114            timeout,
115        ))
116        .layer(SetResponseHeaderLayer::overriding(
117            axum::http::header::X_CONTENT_TYPE_OPTIONS,
118            axum::http::HeaderValue::from_static("nosniff"),
119        ))
120        .layer(SetResponseHeaderLayer::overriding(
121            axum::http::header::X_FRAME_OPTIONS,
122            axum::http::HeaderValue::from_static("DENY"),
123        ))
124        .layer(CorsLayer::permissive())
125        .layer(TraceLayer::new_for_http())
126}
127
128/// Simple token-bucket rate limiter using atomic counters.
129/// Refills `rps` tokens every second.
130struct RateLimiter {
131    tokens: AtomicU64,
132    max_tokens: u64,
133    last_refill: std::sync::Mutex<std::time::Instant>,
134}
135
136impl RateLimiter {
137    fn new(rps: u64) -> Self {
138        Self {
139            tokens: AtomicU64::new(rps),
140            max_tokens: rps,
141            last_refill: std::sync::Mutex::new(std::time::Instant::now()),
142        }
143    }
144
145    fn try_acquire(&self) -> bool {
146        // Refill tokens based on elapsed time.
147        {
148            let mut last = self.last_refill.lock().unwrap();
149            let elapsed = last.elapsed();
150            if elapsed >= Duration::from_secs(1) {
151                let refill = (elapsed.as_secs_f64() * self.max_tokens as f64) as u64;
152                let current = self.tokens.load(Ordering::Relaxed);
153                let new_val = (current + refill).min(self.max_tokens);
154                self.tokens.store(new_val, Ordering::Relaxed);
155                *last = std::time::Instant::now();
156            }
157        }
158
159        // Try to consume one token.
160        loop {
161            let current = self.tokens.load(Ordering::Relaxed);
162            if current == 0 {
163                return false;
164            }
165            if self
166                .tokens
167                .compare_exchange_weak(current, current - 1, Ordering::Relaxed, Ordering::Relaxed)
168                .is_ok()
169            {
170                return true;
171            }
172        }
173    }
174}
175
176async fn rate_limit_middleware(
177    limiter: Option<Arc<RateLimiter>>,
178    req: Request<Body>,
179    next: Next,
180) -> Response {
181    if let Some(limiter) = limiter
182        && req.uri().path() != "/health"
183        && req.uri().path() != "/ready"
184        && !limiter.try_acquire()
185    {
186        return (
187            StatusCode::TOO_MANY_REQUESTS,
188            axum::Json(crw_core::types::ApiResponse::<()>::err_with_code(
189                "Rate limited",
190                "rate_limited",
191            )),
192        )
193            .into_response();
194    }
195    next.run(req).await
196}