1use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::atomic::Ordering;
6use std::time::Instant;
7
8use axum::extract::rejection::JsonRejection;
9use axum::extract::{Extension, Path, Query, Request, State};
10use axum::http::{HeaderMap, Method, StatusCode, header};
11use axum::middleware::{self, Next};
12use axum::response::{IntoResponse, Response};
13use axum::routing::{get, post};
14use axum::{Json, Router};
15use pagesift_store::Store;
16use serde::Deserialize;
17use tokio::sync::Mutex;
18
19use crate::engine::Engine;
20use crate::error::{ApiError, ErrorCode};
21use crate::jobs::{Jobs, Start, rfc3339};
22use crate::limits::{Caller, KeyLimits, Limiter};
23use crate::metrics::{Metrics, PageSource};
24use crate::types::{
25 BatchScrapeRequest, CrawlRequest, ExtractRequest, JobErrors, JobStarted, JobStatus, MapRequest,
26 MapResponse, ScrapeRequest, ScrapeResponse, SearchRequest,
27};
28use crate::validate::{check_scrape_options, check_url};
29use crate::webhook::{WebhookConfig, WebhookSettings, Webhooks};
30
31const PAGE_SIZE: usize = 100;
33const DEFAULT_BATCH_CONCURRENCY: usize = 8;
34
35#[derive(Clone, Default)]
36pub struct Config {
37 pub api_keys: Vec<String>,
39 pub public_url: Option<String>,
41 pub max_batch_concurrency: Option<usize>,
42 pub store: Option<Store>,
45 pub key_limits: HashMap<String, KeyLimits>,
47 pub default_limits: KeyLimits,
49 pub webhook: WebhookSettings,
50}
51
52impl std::fmt::Debug for Config {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("Config")
56 .field("api_keys", &self.api_keys.len())
57 .field("public_url", &self.public_url)
58 .field("max_batch_concurrency", &self.max_batch_concurrency)
59 .field("store", &self.store.is_some())
60 .field("key_limits", &self.key_limits.len())
61 .field("default_limits", &self.default_limits)
62 .field("webhook", &self.webhook)
63 .finish()
64 }
65}
66
67#[derive(Clone)]
68pub struct AppState {
69 engine: Arc<dyn Engine>,
70 keys: Arc<HashMap<String, Arc<KeyLimits>>>,
72 default_limits: Arc<KeyLimits>,
73 public_url: Option<String>,
74 batch_concurrency: usize,
75 jobs: Jobs,
76 limiter: Arc<Limiter>,
77 webhooks: Webhooks,
78 extracts: Arc<Mutex<std::collections::HashMap<String, ExtractJob>>>,
79 metrics: Arc<Metrics>,
80}
81
82struct ExtractJob {
83 status: &'static str,
84 data: Option<serde_json::Value>,
85 error: Option<ApiError>,
86 created: std::time::SystemTime,
87}
88
89pub fn router(engine: Arc<dyn Engine>, config: Config) -> Router {
91 let default_limits = Arc::new(config.default_limits);
92 let mut keys: HashMap<String, Arc<KeyLimits>> = config
93 .api_keys
94 .into_iter()
95 .map(|k| (k, default_limits.clone()))
96 .collect();
97 keys.extend(config.key_limits.into_iter().map(|(k, l)| (k, Arc::new(l))));
98 let accounts: HashMap<String, Arc<KeyLimits>> = keys
100 .iter()
101 .map(|(k, l)| {
102 (
103 Caller::new(Some(k), l.clone()).account.to_string(),
104 l.clone(),
105 )
106 })
107 .collect();
108 let metrics = Arc::new(Metrics::default());
109 let limiter = Arc::new(Limiter::new(config.store.clone()));
110 let webhooks = Webhooks::new(config.webhook, metrics.clone());
111 let jobs = Jobs::new(
112 config.store,
113 limiter.clone(),
114 Some(webhooks.clone()),
115 metrics.clone(),
116 );
117 if tokio::runtime::Handle::try_current().is_ok() {
118 let fallback = default_limits.clone();
119 jobs.restore(&engine, &|account| {
120 accounts
121 .get(account)
122 .cloned()
123 .unwrap_or_else(|| fallback.clone())
124 });
125 } else {
126 tracing::warn!("router built outside a tokio runtime: saved jobs are not resumed");
127 }
128 let state = AppState {
129 engine,
130 keys: Arc::new(keys),
131 default_limits,
132 public_url: config
133 .public_url
134 .map(|u| u.trim_end_matches('/').to_string()),
135 batch_concurrency: config
136 .max_batch_concurrency
137 .unwrap_or(DEFAULT_BATCH_CONCURRENCY),
138 jobs,
139 limiter,
140 webhooks,
141 extracts: Arc::default(),
142 metrics,
143 };
144 let v2 = Router::new()
145 .route("/v2/scrape", post(scrape))
146 .route("/v2/batch/scrape", post(batch_start))
147 .route("/v2/batch/scrape/{id}", get(job_status).delete(job_cancel))
148 .route("/v2/batch/scrape/{id}/errors", get(job_errors))
149 .route("/v2/map", post(map))
150 .route("/v2/search", post(search))
151 .route("/v2/extract", post(extract_start))
152 .route("/v2/extract/{id}", get(extract_status))
153 .route("/v2/crawl", post(crawl_start))
154 .route("/v2/crawl/active", get(jobs_active))
155 .route("/v2/crawl/{id}", get(job_status).delete(job_cancel))
156 .route("/v2/crawl/{id}/errors", get(job_errors))
157 .route_layer(middleware::from_fn_with_state(state.clone(), auth));
158 Router::new()
159 .route("/healthz", get(|| async { "ok" }))
160 .route("/metrics", get(render_metrics))
161 .merge(v2)
162 .fallback(|| async { ApiError::new(ErrorCode::NotFound, "no such endpoint") })
163 .layer(middleware::from_fn_with_state(state.clone(), observe))
164 .with_state(state)
165}
166
167async fn auth(
170 State(s): State<AppState>,
171 headers: HeaderMap,
172 mut req: Request,
173 next: Next,
174) -> Response {
175 let caller = if s.keys.is_empty() {
176 Caller::new(None, s.default_limits.clone())
177 } else {
178 let key = headers
179 .get(header::AUTHORIZATION)
180 .and_then(|v| v.to_str().ok())
181 .and_then(|v| v.strip_prefix("Bearer "))
182 .map(str::trim);
183 match key.and_then(|k| s.keys.get(k).map(|l| (k, l))) {
184 Some((k, l)) => Caller::new(Some(k), l.clone()),
185 None => {
186 return ApiError::new(ErrorCode::Unauthorized, "missing or invalid API key")
187 .into_response();
188 }
189 }
190 };
191 if req.method() == Method::POST
192 && let Err(e) = s.limiter.check_rate(&caller)
193 {
194 let mut resp = e.into_response();
195 resp.extensions_mut().insert(caller);
196 return resp;
197 }
198 req.extensions_mut().insert(caller.clone());
199 let mut resp = next.run(req).await;
200 resp.extensions_mut().insert(caller);
201 resp
202}
203
204async fn observe(State(s): State<AppState>, req: Request, next: Next) -> Response {
206 let route = req
207 .extensions()
208 .get::<axum::extract::MatchedPath>()
209 .map(|p| p.as_str().to_string())
210 .unwrap_or_else(|| "unmatched".into());
211 let method = req.method().clone();
212 let started = Instant::now();
213 s.metrics.request_started();
214 let resp = next.run(req).await;
215 let elapsed = started.elapsed();
216 let status = resp.status().as_u16();
217 s.metrics.request_finished(&route, status, elapsed);
218 let code = resp.extensions().get::<ErrorCode>().map(|c| c.as_str());
219 if let Some(code) = code {
220 s.metrics.error(code, "http");
221 }
222 let account = resp.extensions().get::<Caller>().map(|c| c.account.clone());
223 let latency_ms = elapsed.as_secs_f64() * 1000.0;
224 if route.starts_with("/v2/") {
225 tracing::info!(
226 target: "pagesift::http",
227 %method, %route, status, latency_ms, account = account.as_deref(), code,
228 "request"
229 );
230 } else {
231 tracing::debug!(target: "pagesift::http", %method, %route, status, latency_ms, "request");
232 }
233 resp
234}
235
236fn body<T>(r: Result<Json<T>, JsonRejection>) -> Result<T, ApiError> {
238 r.map(|Json(v)| v)
239 .map_err(|e| ApiError::invalid(format!("invalid request body: {}", e.body_text())))
240}
241
242fn base_url(s: &AppState, headers: &HeaderMap) -> String {
243 if let Some(u) = &s.public_url {
244 return u.clone();
245 }
246 let host = headers
247 .get(header::HOST)
248 .and_then(|v| v.to_str().ok())
249 .unwrap_or("localhost:3002");
250 format!("http://{host}")
251}
252
253async fn scrape(
254 State(s): State<AppState>,
255 caller: Option<Extension<Caller>>,
256 req: Result<Json<ScrapeRequest>, JsonRejection>,
257) -> Result<Json<ScrapeResponse>, ApiError> {
258 let req = body(req)?;
259 check_url(&req.url)?;
260 check_scrape_options(&req.options, s.engine.as_ref())?;
261 if let Some(Extension(c)) = &caller {
262 s.limiter.check_quota(c)?;
263 }
264 match s.engine.scrape(&req.url, &req.options).await {
265 Ok(data) => {
266 s.metrics.scrape_ok.fetch_add(1, Ordering::Relaxed);
267 s.metrics.page(PageSource::Scrape);
268 if let Some(Extension(c)) = &caller {
269 s.limiter.consume_pages(c, 1).await;
270 }
271 Ok(Json(ScrapeResponse {
272 success: true,
273 warning: data.warning.clone(),
274 data,
275 }))
276 }
277 Err(e) => {
278 s.metrics.scrape_err.fetch_add(1, Ordering::Relaxed);
279 Err(e)
280 }
281 }
282}
283
284async fn batch_start(
285 State(s): State<AppState>,
286 caller: Option<Extension<Caller>>,
287 headers: HeaderMap,
288 req: Result<Json<BatchScrapeRequest>, JsonRejection>,
289) -> Result<Json<JobStarted>, ApiError> {
290 let req = body(req)?;
291 check_scrape_options(&req.options, s.engine.as_ref())?;
292 let (mut valid, mut invalid) = (Vec::new(), Vec::new());
293 for u in req.urls {
294 match check_url(&u) {
295 Ok(()) => valid.push(u),
296 Err(e) if !req.ignore_invalid_urls => return Err(e),
297 Err(_) => invalid.push(u),
298 }
299 }
300 if valid.is_empty() {
301 return Err(ApiError::invalid("no valid URLs"));
302 }
303 let start = Start {
304 owner: caller.map(|Extension(c)| c),
305 webhook: webhook(&s, req.webhook.as_ref())?,
306 };
307 let concurrency = req
308 .max_concurrency
309 .unwrap_or(s.batch_concurrency)
310 .min(s.batch_concurrency);
311 let id = s
312 .jobs
313 .start_batch_with(s.engine.clone(), valid, req.options, concurrency, start)
314 .await?;
315 Ok(Json(JobStarted {
316 success: true,
317 url: format!("{}/v2/batch/scrape/{id}", base_url(&s, &headers)),
318 id,
319 invalid_urls: req.ignore_invalid_urls.then_some(invalid),
320 }))
321}
322
323#[derive(Deserialize)]
324struct Paging {
325 #[serde(default)]
326 skip: usize,
327}
328
329async fn job_status(
330 State(s): State<AppState>,
331 headers: HeaderMap,
332 uri: axum::http::Uri,
333 Path(id): Path<String>,
334 Query(p): Query<Paging>,
335) -> Result<Json<JobStatus>, ApiError> {
336 let base = base_url(&s, &headers);
337 let path = uri.path().to_string();
338 let page = s
339 .jobs
340 .status(&id, p.skip, PAGE_SIZE)
341 .await
342 .ok_or_else(|| ApiError::new(ErrorCode::NotFound, format!("job {id} not found")))?;
343 let end = p.skip.min(page.docs) + page.data.len();
344 let more = end < page.docs || (page.state.as_str() == "scraping" && end < page.total);
345 Ok(Json(JobStatus {
346 success: true,
347 status: page.state.as_str().into(),
348 completed: page.completed,
349 total: page.total,
350 credits_used: page.completed,
351 expires_at: rfc3339(page.expires_at),
352 next: (more && end > p.skip).then(|| format!("{base}{path}?skip={end}")),
353 data: page.data,
354 pagesift: page.summary,
355 }))
356}
357
358async fn job_errors(
359 State(s): State<AppState>,
360 Path(id): Path<String>,
361) -> Result<Json<JobErrors>, ApiError> {
362 s.jobs
363 .errors(&id)
364 .await
365 .map(|(errors, robots_blocked)| {
366 Json(JobErrors {
367 errors,
368 robots_blocked,
369 })
370 })
371 .ok_or_else(|| ApiError::new(ErrorCode::NotFound, format!("job {id} not found")))
372}
373
374async fn job_cancel(
375 State(s): State<AppState>,
376 Path(id): Path<String>,
377) -> Result<Response, ApiError> {
378 if s.jobs.cancel(&id).await {
379 Ok(Json(serde_json::json!({ "success": true, "status": "cancelled" })).into_response())
380 } else {
381 Err(ApiError::new(
382 ErrorCode::NotFound,
383 format!("job {id} not found"),
384 ))
385 }
386}
387
388async fn jobs_active(State(s): State<AppState>) -> Json<serde_json::Value> {
389 let ids = s.jobs.active().await;
390 Json(serde_json::json!({
391 "success": true,
392 "crawls": ids.into_iter().map(|id| serde_json::json!({ "id": id })).collect::<Vec<_>>(),
393 }))
394}
395
396async fn map(
397 State(s): State<AppState>,
398 req: Result<Json<MapRequest>, JsonRejection>,
399) -> Result<Json<MapResponse>, ApiError> {
400 let req = body(req)?;
401 check_url(&req.url)?;
402 let links = s.engine.map(&req).await?;
403 Ok(Json(MapResponse {
404 success: true,
405 links,
406 }))
407}
408
409async fn search(
410 State(s): State<AppState>,
411 req: Result<Json<SearchRequest>, JsonRejection>,
412) -> Result<Json<serde_json::Value>, ApiError> {
413 let req = body(req)?;
414 if req.query.trim().is_empty() {
415 return Err(ApiError::invalid("query must not be empty"));
416 }
417 if !(1..=100).contains(&req.limit) {
418 return Err(ApiError::invalid("limit must be between 1 and 100"));
419 }
420 if let Some(o) = &req.scrape_options {
421 check_scrape_options(o, s.engine.as_ref())?;
422 }
423 let data = s.engine.search(&req).await?;
424 let credits = data["web"].as_array().map_or(0, Vec::len);
425 Ok(Json(serde_json::json!({
426 "success": true,
427 "data": data,
428 "creditsUsed": credits,
429 "id": uuid::Uuid::new_v4().to_string(),
430 })))
431}
432
433async fn extract_start(
434 State(s): State<AppState>,
435 req: Result<Json<ExtractRequest>, JsonRejection>,
436) -> Result<Json<serde_json::Value>, ApiError> {
437 let req = body(req)?;
438 if req.urls.is_empty() {
439 return Err(ApiError::invalid("urls must not be empty"));
440 }
441 if req.prompt.is_none() && req.schema.is_none() {
442 return Err(ApiError::invalid("extract needs a prompt or a schema"));
443 }
444 if req.enable_web_search {
445 return Err(ApiError::unsupported(
446 "enableWebSearch is not available in this build",
447 ));
448 }
449 for u in &req.urls {
450 if let Err(e) = check_url(u.trim_end_matches("/*"))
451 && !req.ignore_invalid_urls
452 {
453 return Err(e);
454 }
455 }
456 let id = uuid::Uuid::new_v4().to_string();
457 {
458 let mut map = s.extracts.lock().await;
459 let now = std::time::SystemTime::now();
460 map.retain(|_, j| j.created + crate::jobs::JOB_TTL > now);
461 map.insert(
462 id.clone(),
463 ExtractJob {
464 status: "processing",
465 data: None,
466 error: None,
467 created: now,
468 },
469 );
470 }
471 let (engine, extracts, job_id) = (s.engine.clone(), s.extracts.clone(), id.clone());
472 tokio::spawn(async move {
473 let result = engine.extract(&req).await;
474 if let Some(job) = extracts.lock().await.get_mut(&job_id) {
475 match result {
476 Ok(v) => {
477 job.status = "completed";
478 job.data = Some(v);
479 }
480 Err(e) => {
481 job.status = "failed";
482 job.error = Some(e);
483 }
484 }
485 }
486 });
487 Ok(Json(serde_json::json!({ "success": true, "id": id })))
488}
489
490async fn extract_status(
491 State(s): State<AppState>,
492 Path(id): Path<String>,
493) -> Result<Json<serde_json::Value>, ApiError> {
494 let map = s.extracts.lock().await;
495 let job = map
496 .get(&id)
497 .ok_or_else(|| ApiError::new(ErrorCode::NotFound, format!("extract job {id} not found")))?;
498 let mut body = serde_json::json!({
499 "success": job.error.is_none(),
500 "status": job.status,
501 "expiresAt": rfc3339(job.created + crate::jobs::JOB_TTL),
502 });
503 if let Some(d) = &job.data {
504 body["data"] = d.clone();
505 }
506 if let Some(e) = &job.error {
507 body["error"] = format!("{}: {}", e.code.as_str(), e.message).into();
508 }
509 Ok(Json(body))
510}
511
512async fn crawl_start(
513 State(s): State<AppState>,
514 caller: Option<Extension<Caller>>,
515 headers: HeaderMap,
516 req: Result<Json<CrawlRequest>, JsonRejection>,
517) -> Result<Json<JobStarted>, ApiError> {
518 let req = body(req)?;
519 check_url(&req.url)?;
520 check_scrape_options(&req.scrape_options, s.engine.as_ref())?;
521 for pattern in req.include_paths.iter().chain(&req.exclude_paths) {
522 regex_check(pattern)?;
523 }
524 let start = Start {
525 owner: caller.map(|Extension(c)| c),
526 webhook: webhook(&s, req.webhook.as_ref())?,
527 };
528 let id = s
529 .jobs
530 .start_crawl_with(s.engine.clone(), req, start)
531 .await?;
532 Ok(Json(JobStarted {
533 success: true,
534 url: format!("{}/v2/crawl/{id}", base_url(&s, &headers)),
535 id,
536 invalid_urls: None,
537 }))
538}
539
540fn webhook(s: &AppState, v: Option<&serde_json::Value>) -> Result<Option<WebhookConfig>, ApiError> {
542 v.filter(|v| !v.is_null())
543 .map(|v| WebhookConfig::parse(v, s.webhooks.ssrf()))
544 .transpose()
545}
546
547fn regex_check(pattern: &str) -> Result<(), ApiError> {
549 regex::RegexBuilder::new(pattern)
550 .size_limit(1 << 20)
551 .build()
552 .map(|_| ())
553 .map_err(|e| ApiError::invalid(format!("invalid path pattern {pattern:?}: {e}")))
554}
555
556async fn render_metrics(State(s): State<AppState>) -> impl IntoResponse {
557 let out = s.metrics.render(&s.jobs.counts().await);
558 (
559 StatusCode::OK,
560 [(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
561 out,
562 )
563}