1use std::collections::HashMap;
5use std::future::Future;
6use std::pin::{Pin, pin};
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::task::{Context, Poll};
10use std::time::{Duration, Instant};
11
12use axum::body::{Body, Bytes, HttpBody};
13use axum::extract::rejection::JsonRejection;
14use axum::extract::{Request, State};
15use axum::http::{HeaderMap, Method, StatusCode, header};
16use axum::middleware::{self, Next};
17use axum::response::{IntoResponse, Response};
18use axum::routing::get;
19use axum::{Json, Router};
20use http_body::{Frame, SizeHint};
21use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
22use hyper_util::server::conn::auto;
23use hyper_util::server::graceful::GracefulShutdown;
24use hyper_util::service::TowerToHyperService;
25use pagesift_store::Store;
26use tokio::net::TcpListener;
27use tokio::sync::Mutex;
28use tokio::time::Sleep;
29
30use crate::engine::Engine;
31use crate::error::{ApiError, ErrorCode};
32use crate::jobs::Jobs;
33use crate::limits::{Caller, KeyLimits, Limiter};
34use crate::metrics::Metrics;
35use crate::webhook::{WebhookConfig, WebhookSettings, Webhooks};
36
37pub const PAGE_SIZE: usize = 100;
39pub const DEFAULT_BATCH_CONCURRENCY: usize = 8;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Timeouts {
44 pub header_read: Duration,
46 pub body: Duration,
48 pub request: Duration,
51}
52
53impl Default for Timeouts {
54 fn default() -> Self {
55 Self {
56 header_read: Duration::from_secs(30),
57 body: Duration::from_secs(30),
58 request: Duration::from_millis(crate::validate::MAX_SCRAPE_TIMEOUT_MS)
59 + Duration::from_secs(60),
60 }
61 }
62}
63
64#[derive(Clone, Default)]
65pub struct Config {
66 pub api_keys: Vec<String>,
68 pub public_url: Option<String>,
70 pub max_batch_concurrency: Option<usize>,
71 pub store: Option<Store>,
73 pub key_limits: HashMap<String, KeyLimits>,
75 pub default_limits: KeyLimits,
77 pub webhook: WebhookSettings,
78 pub timeouts: Timeouts,
80}
81
82impl std::fmt::Debug for Config {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("Config")
85 .field("api_keys", &self.api_keys.len())
86 .field("public_url", &self.public_url)
87 .field("max_batch_concurrency", &self.max_batch_concurrency)
88 .field("store", &self.store.is_some())
89 .field("key_limits", &self.key_limits.len())
90 .field("default_limits", &self.default_limits)
91 .field("webhook", &self.webhook)
92 .field("timeouts", &self.timeouts)
93 .finish()
94 }
95}
96
97#[derive(Clone)]
98pub struct AppState {
99 pub engine: Arc<dyn Engine>,
100 pub keys: Arc<HashMap<String, Arc<KeyLimits>>>,
101 pub default_limits: Arc<KeyLimits>,
102 pub public_url: Option<String>,
103 pub batch_concurrency: usize,
104 pub jobs: Jobs,
105 pub limiter: Arc<Limiter>,
106 pub webhooks: Webhooks,
107 pub extracts: Arc<Mutex<HashMap<String, ExtractJob>>>,
108 pub metrics: Arc<Metrics>,
109}
110
111pub struct ExtractJob {
112 pub status: &'static str,
113 pub data: Option<serde_json::Value>,
114 pub error: Option<ApiError>,
115 pub created: std::time::SystemTime,
116}
117
118pub fn router(engine: Arc<dyn Engine>, config: Config) -> Router {
120 let timeouts = config.timeouts;
121 let default_limits = Arc::new(config.default_limits);
122 let mut keys: HashMap<String, Arc<KeyLimits>> = config
123 .api_keys
124 .into_iter()
125 .map(|k| (k, default_limits.clone()))
126 .collect();
127 keys.extend(config.key_limits.into_iter().map(|(k, l)| (k, Arc::new(l))));
128
129 let accounts: HashMap<String, Arc<KeyLimits>> = keys
130 .iter()
131 .map(|(k, l)| {
132 (
133 Caller::new(Some(k), l.clone()).account.to_string(),
134 l.clone(),
135 )
136 })
137 .collect();
138
139 let metrics = Arc::new(Metrics::default());
140 let limiter = Arc::new(Limiter::new(config.store.clone()));
141 let webhooks = Webhooks::new(config.webhook, metrics.clone());
142 let jobs = Jobs::new(
143 config.store,
144 limiter.clone(),
145 Some(webhooks.clone()),
146 metrics.clone(),
147 );
148
149 if tokio::runtime::Handle::try_current().is_ok() {
150 let fallback = default_limits.clone();
151 jobs.restore(&engine, &|account| {
152 accounts
153 .get(account)
154 .cloned()
155 .unwrap_or_else(|| fallback.clone())
156 });
157 } else {
158 tracing::warn!("router built outside a tokio runtime: saved jobs are not resumed");
159 }
160
161 let state = AppState {
162 engine: engine.clone(),
163 keys: Arc::new(keys),
164 default_limits,
165 public_url: config
166 .public_url
167 .map(|u| u.trim_end_matches('/').to_string()),
168 batch_concurrency: config
169 .max_batch_concurrency
170 .unwrap_or(DEFAULT_BATCH_CONCURRENCY),
171 jobs,
172 limiter,
173 webhooks,
174 extracts: Arc::default(),
175 metrics,
176 };
177
178 let firecrawl = crate::firecrawl::firecrawl_routes()
179 .route_layer(middleware::from_fn_with_state(state.clone(), auth));
180 let jina = crate::jina::jina_router(engine);
181
182 Router::new()
183 .route("/healthz", get(|| async { "ok" }))
184 .route("/metrics", get(render_metrics))
185 .merge(firecrawl)
186 .with_state(state.clone())
187 .merge(jina)
188 .fallback(|| async { ApiError::new(ErrorCode::NotFound, "no such endpoint") })
189 .layer(middleware::from_fn(uniform_errors))
190 .layer(middleware::from_fn_with_state(timeouts, deadline))
191 .layer(middleware::from_fn_with_state(state.clone(), observe))
192}
193
194pub async fn serve(
196 listener: TcpListener,
197 app: Router,
198 shutdown: impl Future<Output = ()> + Send,
199) -> std::io::Result<()> {
200 serve_with(listener, app, Timeouts::default(), shutdown).await
201}
202
203pub async fn serve_with(
206 listener: TcpListener,
207 app: Router,
208 timeouts: Timeouts,
209 shutdown: impl Future<Output = ()> + Send,
210) -> std::io::Result<()> {
211 let mut builder = auto::Builder::new(TokioExecutor::new());
212 builder
213 .http1()
214 .timer(TokioTimer::new())
215 .header_read_timeout(timeouts.header_read);
216 let graceful = GracefulShutdown::new();
217 let mut shutdown = pin!(shutdown);
218 loop {
219 let stream = tokio::select! {
220 r = listener.accept() => match r {
221 Ok((stream, _)) => stream,
222 Err(e) => {
223 if !matches!(
225 e.kind(),
226 std::io::ErrorKind::ConnectionRefused
227 | std::io::ErrorKind::ConnectionAborted
228 | std::io::ErrorKind::ConnectionReset
229 ) {
230 tracing::error!(error = %e, "accept failed");
231 tokio::time::sleep(Duration::from_secs(1)).await;
232 }
233 continue;
234 }
235 },
236 () = &mut shutdown => break,
237 };
238 let _ = stream.set_nodelay(true);
240 let conn = builder
241 .serve_connection_with_upgrades(
242 TokioIo::new(stream),
243 TowerToHyperService::new(app.clone()),
244 )
245 .into_owned();
246 let conn = graceful.watch(conn);
247 tokio::spawn(async move {
248 if let Err(e) = conn.await {
249 tracing::trace!(error = %e, "connection ended with an error");
250 }
251 });
252 }
253 drop(listener);
254 graceful.shutdown().await;
255 Ok(())
256}
257
258async fn deadline(State(t): State<Timeouts>, req: Request, next: Next) -> Response {
260 let fired = Arc::new(AtomicBool::new(false));
261 let req = if req.body().is_end_stream() {
262 req
263 } else {
264 let fired = fired.clone();
265 req.map(|inner| {
266 Body::new(DeadlineBody {
267 inner,
268 sleep: Box::pin(tokio::time::sleep(t.body)),
269 fired,
270 })
271 })
272 };
273 match tokio::time::timeout(t.request, next.run(req)).await {
274 Ok(_) if fired.load(Ordering::Relaxed) => ApiError::new(
276 ErrorCode::Timeout,
277 format!(
278 "request body was not received within {} s",
279 t.body.as_secs_f64()
280 ),
281 )
282 .into_response(),
283 Ok(resp) => resp,
284 Err(_) => ApiError::new(
285 ErrorCode::Timeout,
286 format!(
287 "request did not complete within {} s",
288 t.request.as_secs_f64()
289 ),
290 )
291 .into_response(),
292 }
293}
294
295struct DeadlineBody {
297 inner: Body,
298 sleep: Pin<Box<Sleep>>,
299 fired: Arc<AtomicBool>,
300}
301
302impl HttpBody for DeadlineBody {
303 type Data = Bytes;
304 type Error = axum::Error;
305
306 fn poll_frame(
307 self: Pin<&mut Self>,
308 cx: &mut Context<'_>,
309 ) -> Poll<Option<Result<Frame<Bytes>, axum::Error>>> {
310 let this = self.get_mut();
311 if let Poll::Ready(frame) = Pin::new(&mut this.inner).poll_frame(cx) {
312 return Poll::Ready(frame);
313 }
314 if this.sleep.as_mut().poll(cx).is_ready() {
315 this.fired.store(true, Ordering::Relaxed);
316 return Poll::Ready(Some(Err(axum::Error::new("request body timed out"))));
317 }
318 Poll::Pending
319 }
320
321 fn is_end_stream(&self) -> bool {
322 self.inner.is_end_stream()
323 }
324
325 fn size_hint(&self) -> SizeHint {
326 self.inner.size_hint()
327 }
328}
329
330async fn uniform_errors(req: Request, next: Next) -> Response {
333 let resp = next.run(req).await;
334 let status = resp.status();
335 if !status.is_client_error() || resp.extensions().get::<ErrorCode>().is_some() {
337 return resp;
338 }
339 let plain = resp
340 .headers()
341 .get(header::CONTENT_TYPE)
342 .is_none_or(|v| v.as_bytes().starts_with(b"text/plain"));
343 if !plain {
344 return resp;
345 }
346 let (parts, body) = resp.into_parts();
347 let text = axum::body::to_bytes(body, 64 * 1024)
348 .await
349 .map(|b| String::from_utf8_lossy(&b).trim().to_string())
350 .unwrap_or_default();
351 let code = match status {
352 StatusCode::NOT_FOUND => ErrorCode::NotFound,
353 StatusCode::PAYLOAD_TOO_LARGE => ErrorCode::TooLarge,
354 StatusCode::UNAUTHORIZED => ErrorCode::Unauthorized,
355 _ => ErrorCode::InvalidRequest,
356 };
357 let message = if text.is_empty() {
358 status
359 .canonical_reason()
360 .unwrap_or("bad request")
361 .to_lowercase()
362 } else {
363 text
364 };
365 let mut out = ApiError::new(code, message).into_response();
366 *out.status_mut() = status;
367 for (name, value) in &parts.headers {
368 if name != header::CONTENT_TYPE && name != header::CONTENT_LENGTH {
369 out.headers_mut().append(name, value.clone());
370 }
371 }
372 out
373}
374
375async fn auth(
377 State(s): State<AppState>,
378 headers: HeaderMap,
379 mut req: Request,
380 next: Next,
381) -> Response {
382 let caller = if s.keys.is_empty() {
383 Caller::new(None, s.default_limits.clone())
384 } else {
385 let key = headers
386 .get(header::AUTHORIZATION)
387 .and_then(|v| v.to_str().ok())
388 .and_then(|v| v.strip_prefix("Bearer "))
389 .map(str::trim);
390 match key.and_then(|k| s.keys.get(k).map(|l| (k, l))) {
391 Some((k, l)) => Caller::new(Some(k), l.clone()),
392 None => {
393 return ApiError::new(ErrorCode::Unauthorized, "missing or invalid API key")
394 .into_response();
395 }
396 }
397 };
398 if req.method() == Method::POST
399 && let Err(e) = s.limiter.check_rate(&caller)
400 {
401 let mut resp = e.into_response();
402 resp.extensions_mut().insert(caller);
403 return resp;
404 }
405 req.extensions_mut().insert(caller.clone());
406 let mut resp = next.run(req).await;
407 resp.extensions_mut().insert(caller);
408 resp
409}
410
411async fn observe(State(s): State<AppState>, req: Request, next: Next) -> Response {
413 let route = req
414 .extensions()
415 .get::<axum::extract::MatchedPath>()
416 .map(|p| p.as_str().to_string())
417 .unwrap_or_else(|| "unmatched".into());
418 let method = req.method().clone();
419 let started = Instant::now();
420 s.metrics.request_started();
421 let resp = next.run(req).await;
422 let elapsed = started.elapsed();
423 let status = resp.status().as_u16();
424 s.metrics.request_finished(&route, status, elapsed);
425 let code = resp.extensions().get::<ErrorCode>().map(|c| c.as_str());
426 if let Some(code) = code {
427 s.metrics.error(code, "http");
428 }
429 let account = resp.extensions().get::<Caller>().map(|c| c.account.clone());
430 let latency_ms = elapsed.as_secs_f64() * 1000.0;
431 if route.starts_with("/v2/") || route.starts_with("/v1/") || route.starts_with("/r") {
432 tracing::info!(
433 target: "pagesift::http",
434 %method, %route, status, latency_ms, account = account.as_deref(), code,
435 "request"
436 );
437 } else {
438 tracing::debug!(target: "pagesift::http", %method, %route, status, latency_ms, "request");
439 }
440 resp
441}
442
443pub fn body<T>(r: Result<Json<T>, JsonRejection>) -> Result<T, ApiError> {
444 r.map(|Json(v)| v)
445 .map_err(|e| ApiError::invalid(format!("invalid request body: {}", e.body_text())))
446}
447
448pub fn base_url(s: &AppState, headers: &HeaderMap) -> String {
449 if let Some(u) = &s.public_url {
450 return u.clone();
451 }
452 let host = headers
453 .get(header::HOST)
454 .and_then(|v| v.to_str().ok())
455 .unwrap_or("localhost:3002");
456 format!("http://{host}")
457}
458
459pub fn webhook(
460 s: &AppState,
461 v: Option<&serde_json::Value>,
462) -> Result<Option<WebhookConfig>, ApiError> {
463 v.filter(|v| !v.is_null())
464 .map(|v| WebhookConfig::parse(v, s.webhooks.ssrf()))
465 .transpose()
466}
467
468async fn render_metrics(State(s): State<AppState>) -> impl IntoResponse {
469 let out = s.metrics.render(&s.jobs.counts().await);
470 (
471 StatusCode::OK,
472 [(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
473 out,
474 )
475}