use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use pagesift_api::types::{Document, Metadata, PdfParser, ScrapeOptions, Upload, UploadKind};
use pagesift_api::{ApiError, BoxFuture, Config, Engine, MAX_PARSE_FILE_BYTES, Timeouts, router};
use serde_json::Value;
use tower::ServiceExt;
struct Fake;
impl Engine for Fake {
fn scrape<'a>(
&'a self,
_url: &'a str,
_opts: &'a ScrapeOptions,
) -> BoxFuture<'a, Result<Document, ApiError>> {
Box::pin(async { Err(ApiError::invalid("not used")) })
}
fn parse<'a>(
&'a self,
file: Upload,
opts: &'a ScrapeOptions,
) -> BoxFuture<'a, Result<Document, ApiError>> {
Box::pin(async move {
let parser = PdfParser::from_parsers(opts.parsers.as_ref())?;
let kind = match file.kind {
UploadKind::Pdf => "pdf",
UploadKind::Html => "html",
UploadKind::Office(f) => f.extension(),
};
Ok(Document {
markdown: opts.wants("markdown").then(|| {
format!(
"{kind} {} bytes, main={} maxPages={:?}",
file.bytes.len(),
opts.only_main_content,
parser.max_pages
)
}),
metadata: Metadata {
source_url: Some(file.filename),
content_type: file.content_type,
status_code: 200,
proxy_used: "basic".into(),
..Default::default()
},
..Default::default()
})
})
}
}
fn app(keys: &[&str]) -> axum::Router {
router(
Arc::new(Fake),
Config {
api_keys: keys.iter().map(|k| k.to_string()).collect(),
..Default::default()
},
)
}
const BOUNDARY: &str = "XpagesiftBoundary";
type Part<'a> = (&'a str, Option<&'a str>, Option<&'a str>, &'a [u8]);
fn multipart(parts: &[Part]) -> Vec<u8> {
let mut body = Vec::new();
for (name, filename, ctype, data) in parts {
body.extend_from_slice(format!("--{BOUNDARY}\r\n").as_bytes());
let mut disp = format!("Content-Disposition: form-data; name=\"{name}\"");
if let Some(f) = filename {
disp.push_str(&format!("; filename=\"{f}\""));
}
body.extend_from_slice(disp.as_bytes());
body.extend_from_slice(b"\r\n");
if let Some(t) = ctype {
body.extend_from_slice(format!("Content-Type: {t}\r\n").as_bytes());
}
body.extend_from_slice(b"\r\n");
body.extend_from_slice(data);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{BOUNDARY}--\r\n").as_bytes());
body
}
async fn send(app: &axum::Router, req: Request<Body>) -> (StatusCode, Value) {
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v = serde_json::from_slice(&bytes)
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into()));
(status, v)
}
fn request(body: Vec<u8>) -> Request<Body> {
Request::post("/v2/parse")
.header(
"content-type",
format!("multipart/form-data; boundary={BOUNDARY}"),
)
.header("content-length", body.len())
.body(Body::from(body))
.unwrap()
}
async fn parse(parts: &[Part<'_>]) -> (StatusCode, Value) {
send(&app(&[]), request(multipart(parts))).await
}
const PDF: &[u8] = b"%PDF-1.7\n% fake\n";
#[tokio::test]
async fn pdf_upload_returns_scrape_shape() {
let (status, v) = parse(&[
("file", Some("dir/report.pdf"), Some("application/pdf"), PDF),
(
"options",
None,
Some("application/json"),
br#"{"formats":[{"type":"markdown"}],"onlyMainContent":false,"parsers":[{"type":"pdf","maxPages":2}],"timeout":300000,"origin":"python-sdk"}"#,
),
])
.await;
assert_eq!(status, StatusCode::OK, "{v}");
assert_eq!(v["success"], true);
assert_eq!(
v["data"]["markdown"],
format!("pdf {} bytes, main=false maxPages=Some(2)", PDF.len())
);
assert_eq!(v["data"]["metadata"]["sourceURL"], "report.pdf");
assert_eq!(v["data"]["metadata"]["statusCode"], 200);
}
#[tokio::test]
async fn options_are_optional_and_default_like_firecrawl() {
let (status, v) = parse(&[("file", Some("a.pdf"), None, PDF)]).await;
assert_eq!(status, StatusCode::OK, "{v}");
assert_eq!(
v["data"]["markdown"],
format!("pdf {} bytes, main=true maxPages=None", PDF.len())
);
}
#[tokio::test]
async fn file_type_comes_from_content_then_name_then_mime() {
let (s, v) = parse(&[("file", Some("blob.bin"), None, PDF)]).await;
assert_eq!(s, StatusCode::OK);
assert!(v["data"]["markdown"].as_str().unwrap().starts_with("pdf"));
let html = b"<html><body><p>hi</p></body></html>";
let (s, v) = parse(&[("file", Some("page.htm"), None, html)]).await;
assert_eq!(s, StatusCode::OK);
assert!(v["data"]["markdown"].as_str().unwrap().starts_with("html"));
let (s, v) = parse(&[("file", Some("upload"), Some("text/html; charset=gbk"), html)]).await;
assert_eq!(s, StatusCode::OK);
assert!(v["data"]["markdown"].as_str().unwrap().starts_with("html"));
assert_eq!(
v["data"]["metadata"]["contentType"],
"text/html; charset=gbk"
);
let (s, v) = parse(&[("file", Some("a.docx"), None, b"PK\x03\x04")]).await;
assert_eq!(s, StatusCode::OK);
assert!(
v["data"]["markdown"].as_str().unwrap().starts_with("docx"),
"{v}"
);
for name in ["a.odt", "a.doc", "a.zip"] {
let (s, v) = parse(&[("file", Some(name), None, b"PK\x03\x04")]).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "{name}");
assert_eq!(v["code"], "UNSUPPORTED_OPTION");
}
}
#[tokio::test]
async fn missing_or_empty_file_is_invalid() {
let (s, v) = parse(&[("options", None, None, b"{}")]).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert_eq!(v["code"], "INVALID_REQUEST");
assert!(v["error"].as_str().unwrap().contains("'file'"), "{v}");
let (s, v) = parse(&[("file", Some("a.pdf"), None, b"")]).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert_eq!(v["code"], "INVALID_REQUEST");
let (s, v) = parse(&[
("file", Some("a.pdf"), None, PDF),
("file", Some("b.pdf"), None, PDF),
])
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert_eq!(v["code"], "INVALID_REQUEST");
}
#[tokio::test]
async fn non_multipart_bodies_are_rejected_uniformly() {
let req = Request::post("/v2/parse")
.header("content-type", "application/json")
.body(Body::from(r#"{"file":"x"}"#))
.unwrap();
let (s, v) = send(&app(&[]), req).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert_eq!(v["success"], false);
assert_eq!(v["code"], "INVALID_REQUEST");
let mut body = multipart(&[("file", Some("a.pdf"), None, PDF)]);
body.truncate(body.len() - 10);
let (s, v) = send(&app(&[]), request(body)).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
assert_eq!(v["code"], "INVALID_REQUEST");
}
#[tokio::test]
async fn options_must_be_a_json_object() {
for (raw, code) in [
(&b"{not json"[..], "INVALID_REQUEST"),
(b"[1,2]", "INVALID_REQUEST"),
(b"\"markdown\"", "INVALID_REQUEST"),
(br#"{"timeout": 999999}"#, "INVALID_REQUEST"),
(br#"{"formats": 5}"#, "INVALID_REQUEST"),
(
br#"{"parsers":[{"type":"pdf","mode":"ocr"}]}"#,
"UNSUPPORTED_OPTION",
),
(
br#"{"parsers":[{"type":"pdf","blocks":true}]}"#,
"UNSUPPORTED_OPTION",
),
(
br#"{"parsers":[{"type":"pdf","maxPages":0}]}"#,
"INVALID_REQUEST",
),
(br#"{"formats":["screenshot"]}"#, "UNSUPPORTED_OPTION"),
(br#"{"formats":["changeTracking"]}"#, "UNSUPPORTED_OPTION"),
(
br#"{"actions":[{"type":"wait","milliseconds":1}]}"#,
"UNSUPPORTED_OPTION",
),
(br#"{"waitFor": 1000}"#, "UNSUPPORTED_OPTION"),
(br#"{"mobile": true}"#, "UNSUPPORTED_OPTION"),
(br#"{"proxy": "stealth"}"#, "UNSUPPORTED_OPTION"),
(br#"{"engine": "browser"}"#, "UNSUPPORTED_OPTION"),
] {
let (s, v) = parse(&[
("file", Some("a.pdf"), None, PDF),
("options", None, None, raw),
])
.await;
assert_eq!(
s,
StatusCode::BAD_REQUEST,
"{}: {v}",
String::from_utf8_lossy(raw)
);
assert_eq!(v["code"], code, "{}: {v}", String::from_utf8_lossy(raw));
}
let (s, v) = parse(&[
("file", Some("a.pdf"), None, PDF),
(
"options",
None,
None,
br#"{"proxy":"auto","headers":{"x":"y"},"zeroDataRetention":false,"integration":null,"parsers":["pdf"]}"#,
),
])
.await;
assert_eq!(s, StatusCode::OK, "{v}");
}
#[tokio::test]
async fn oversized_upload_is_413_before_reading() {
let req = Request::post("/v2/parse")
.header(
"content-type",
format!("multipart/form-data; boundary={BOUNDARY}"),
)
.header("content-length", 60 * 1024 * 1024)
.body(Body::from(multipart(&[("file", Some("a.pdf"), None, PDF)])))
.unwrap();
let (s, v) = send(&app(&[]), req).await;
assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(v["code"], "TOO_LARGE");
}
#[tokio::test]
async fn oversized_file_is_413_while_streaming() {
let big = vec![b'a'; MAX_PARSE_FILE_BYTES + 1];
let body = multipart(&[("file", Some("a.pdf"), None, &big)]);
let stream = futures_stream(body);
let req = Request::post("/v2/parse")
.header(
"content-type",
format!("multipart/form-data; boundary={BOUNDARY}"),
)
.body(stream)
.unwrap();
let (s, v) = send(&app(&[]), req).await;
assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE, "{v}");
assert_eq!(v["code"], "TOO_LARGE");
let exact = vec![b'a'; MAX_PARSE_FILE_BYTES];
let (s, v) = parse(&[("file", Some("a.pdf"), None, &exact)]).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
fn futures_stream(body: Vec<u8>) -> Body {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
for c in body.chunks(1024 * 1024) {
tx.send(axum::body::Bytes::copy_from_slice(c)).unwrap();
}
Body::new(Chunks(rx))
}
struct Chunks(tokio::sync::mpsc::UnboundedReceiver<axum::body::Bytes>);
impl http_body::Body for Chunks {
type Data = axum::body::Bytes;
type Error = std::io::Error;
fn poll_frame(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
self.0
.poll_recv(cx)
.map(|c| c.map(|c| Ok(http_body::Frame::data(c))))
}
}
#[tokio::test]
async fn auth_applies_to_parse() {
let app = app(&["k1"]);
let body = multipart(&[("file", Some("a.pdf"), None, PDF)]);
let (s, v) = send(&app, request(body.clone())).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
assert_eq!(v["code"], "UNAUTHORIZED");
let mut req = request(body);
req.headers_mut()
.insert("authorization", "Bearer k1".parse().unwrap());
let (s, _) = send(&app, req).await;
assert_eq!(s, StatusCode::OK);
}
#[tokio::test]
async fn upload_body_deadline_is_separate() {
let t = Timeouts::default();
assert!(t.upload_body > t.body);
let app = router(
Arc::new(Fake),
Config {
timeouts: Timeouts {
body: Duration::from_millis(100),
upload_body: Duration::from_millis(300),
..Default::default()
},
..Default::default()
},
);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let head = format!(
"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.pdf\"\r\n\r\n%PDF-"
);
tx.send(head.into()).unwrap();
let body = Body::new(Chunks(rx));
let req = Request::post("/v2/parse")
.header(
"content-type",
format!("multipart/form-data; boundary={BOUNDARY}"),
)
.body(body)
.unwrap();
let started = std::time::Instant::now();
let (s, v) = send(&app, req).await;
assert_eq!(s, StatusCode::REQUEST_TIMEOUT, "{v}");
assert_eq!(v["code"], "TIMEOUT");
assert!(started.elapsed() >= Duration::from_millis(280));
drop(tx);
}