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