use axum::{Json, body::Bytes, extract::State, http::HeaderMap};
use tower::Service;
use crate::service::ExtractionRequest;
use super::{
error::{ApiError, MultipartApi},
types::{ApiState, DoclingCompatDocument, DoclingCompatResponse, OpenWebDocumentMetadata, OpenWebDocumentResponse},
};
async fn resolve_openweb_bytes(
data: Vec<u8>,
mime_type: String,
filename: Option<String>,
config: &crate::core::config::ExtractionConfig,
) -> Result<(Vec<u8>, String), ApiError> {
crate::core::mime::resolve_owned_bytes_mime(data, filename, Some(mime_type), config.mime_detection_policy)
.await
.map_err(ApiError::from)
}
#[utoipa::path(
put,
path = "/process",
tag = "openweb",
request_body(content_type = "application/octet-stream", content = Vec<u8>),
responses(
(status = 200, description = "Document extracted", body = OpenWebDocumentResponse),
(status = 400, description = "Bad request", body = crate::api::types::ErrorResponse),
(status = 500, description = "Internal server error", body = crate::api::types::ErrorResponse),
)
)]
#[cfg_attr(
feature = "otel",
tracing::instrument(name = "api.openweb_process", skip(state, headers, body))
)]
pub(crate) async fn openweb_external_handler(
State(state): State<ApiState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<OpenWebDocumentResponse>, ApiError> {
if body.is_empty() {
return Err(ApiError::validation(crate::error::XbergError::validation(
"Empty request body — upload a file as the raw request body",
)));
}
let mime_type = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| v.split(';').next().unwrap_or(v).trim())
.unwrap_or(crate::core::mime::OCTET_STREAM_MIME_TYPE)
.to_string();
let filename_hint = headers
.get("X-Filename")
.and_then(|v| v.to_str().ok())
.map(|v| urlencoding::decode(v).unwrap_or_else(|_| v.into()).into_owned());
let filename = filename_hint.clone().unwrap_or_else(|| "unknown".to_string());
let config_json = headers.get("X-Config").and_then(|value| value.to_str().ok());
let mut config = crate::core::config::merge::build_config_from_json(&state.default_config, config_json)
.map_err(|e| ApiError::validation(crate::error::XbergError::validation(e)))?;
if config.output_format == crate::core::config::OutputFormat::Plain {
config.output_format = crate::core::config::OutputFormat::Markdown;
}
let (data, mime_type) = resolve_openweb_bytes(body.to_vec(), mime_type, filename_hint, &config).await?;
let request = ExtractionRequest::bytes(data, mime_type, config);
let mut svc = state
.extraction_service
.lock()
.expect("extraction service lock poisoned")
.clone();
let result = svc.call(request).await?;
Ok(Json(OpenWebDocumentResponse {
page_content: result.content,
metadata: OpenWebDocumentMetadata { source: filename },
}))
}
#[utoipa::path(
post,
path = "/v1/convert/file",
tag = "openweb",
request_body(content_type = "multipart/form-data"),
responses(
(status = 200, description = "Document converted", body = DoclingCompatResponse),
(status = 400, description = "Bad request", body = crate::api::types::ErrorResponse),
(status = 500, description = "Internal server error", body = crate::api::types::ErrorResponse),
)
)]
#[cfg_attr(
feature = "otel",
tracing::instrument(name = "api.openweb_docling", skip(state, multipart))
)]
pub(crate) async fn openweb_docling_handler(
State(state): State<ApiState>,
MultipartApi(mut multipart): MultipartApi,
) -> Result<Json<DoclingCompatResponse>, ApiError> {
let mut file_data: Option<(Vec<u8>, String, Option<String>)> = None;
let mut config_json: Option<String> = None;
let mut flat_config = serde_json::Map::new();
let config_keys = match serde_json::to_value(&*state.default_config) {
Ok(serde_json::Value::Object(keys)) => keys,
_ => serde_json::Map::new(),
};
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ApiError::validation(crate::error::XbergError::validation(e.to_string())))?
{
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"files" | "file" => {
let file_name = field.file_name().map(|s| s.to_string());
let content_type = field.content_type().map(|s| s.to_string());
let data = field
.bytes()
.await
.map_err(|e| ApiError::validation(crate::error::XbergError::validation(e.to_string())))?;
let mime_type = content_type.unwrap_or_else(|| crate::core::mime::OCTET_STREAM_MIME_TYPE.to_string());
file_data = Some((data.to_vec(), mime_type, file_name));
}
"config" | "parameters" => {
let text = field
.text()
.await
.map_err(|e| ApiError::validation(crate::error::XbergError::validation(e.to_string())))?;
if !text.trim().is_empty() {
config_json = Some(text);
}
}
name if config_keys.contains_key(name) => {
let text = field
.text()
.await
.map_err(|e| ApiError::validation(crate::error::XbergError::validation(e.to_string())))?;
flat_config.insert(name.to_string(), form_value_to_json(&text));
}
_ => {}
}
}
if config_json.is_none() && !flat_config.is_empty() {
config_json = Some(serde_json::Value::Object(flat_config).to_string());
}
let (data, mime_type, filename) = file_data.ok_or_else(|| {
ApiError::validation(crate::error::XbergError::validation(
"No file provided. Upload a file with field name 'files'.",
))
})?;
let mut config = crate::core::config::merge::build_config_from_json(&state.default_config, config_json.as_deref())
.map_err(|e| ApiError::validation(crate::error::XbergError::validation(e)))?;
if config.output_format == crate::core::config::OutputFormat::Plain {
config.output_format = crate::core::config::OutputFormat::Markdown;
}
let (data, mime_type) = resolve_openweb_bytes(data, mime_type, filename, &config).await?;
let request = ExtractionRequest::bytes(data, mime_type, config);
let mut svc = state
.extraction_service
.lock()
.expect("extraction service lock poisoned")
.clone();
let result = svc.call(request).await?;
Ok(Json(DoclingCompatResponse {
document: DoclingCompatDocument {
md_content: result.content,
},
status: "success".to_string(),
}))
}
fn form_value_to_json(raw: &str) -> serde_json::Value {
match raw.trim() {
"True" => serde_json::Value::Bool(true),
"False" => serde_json::Value::Bool(false),
trimmed => serde_json::from_str(trimmed).unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
}
}
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::{post, put},
};
use tower::ServiceExt;
use super::*;
fn fixture_bytes() -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test_documents/text/plain.txt");
std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()))
}
fn test_router() -> Router {
test_router_with_config(crate::ExtractionConfig::default())
}
fn test_router_with_config(config: crate::ExtractionConfig) -> Router {
let extraction_service = crate::service::ExtractionServiceBuilder::new()
.build()
.expect("default extraction service configuration should be valid");
let state = ApiState {
default_config: std::sync::Arc::new(config),
extraction_service: std::sync::Arc::new(std::sync::Mutex::new(extraction_service)),
#[cfg(feature = "api")]
job_store: std::sync::Arc::new(crate::api::jobs::JobStore::new()),
#[cfg(feature = "prometheus")]
prometheus_registry: crate::telemetry::init_prometheus(),
};
Router::new()
.route("/process", put(openweb_external_handler))
.route("/v1/convert/file", post(openweb_docling_handler))
.with_state(state)
}
fn docling_body(boundary: &str, config: Option<&str>) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"files\"; filename=\"plain.txt\"\r\nContent-Type: text/plain\r\n\r\n"
)
.as_bytes(),
);
body.extend_from_slice(&fixture_bytes());
body.extend_from_slice(b"\r\n");
if let Some(cfg) = config {
body.extend_from_slice(
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"config\"\r\n\r\n{cfg}\r\n").as_bytes(),
);
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
body
}
fn docling_json_body(boundary: &str, content_type: &str, config: Option<&str>) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"files\"; filename=\"report.txt\"\r\nContent-Type: {content_type}\r\n\r\n"
)
.as_bytes(),
);
body.extend_from_slice(br#"{"kind":"report"}"#);
body.extend_from_slice(b"\r\n");
if let Some(config) = config {
body.extend_from_slice(
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"config\"\r\n\r\n{config}\r\n")
.as_bytes(),
);
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
body
}
fn docling_body_with_fields(boundary: &str, fields: &[(&str, &str)]) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"files\"; filename=\"plain.txt\"\r\nContent-Type: text/plain\r\n\r\n"
)
.as_bytes(),
);
body.extend_from_slice(&fixture_bytes());
body.extend_from_slice(b"\r\n");
for (name, value) in fields {
body.extend_from_slice(
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n")
.as_bytes(),
);
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
body
}
async fn docling_flat_response(fields: &[(&str, &str)]) -> (StatusCode, String) {
let app = test_router();
let boundary = "flatboundary";
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(docling_body_with_fields(boundary, fields)))
.expect("valid request"),
)
.await
.expect("handler responded");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let content = serde_json::from_slice::<DoclingCompatResponse>(&bytes)
.map(|parsed| parsed.document.md_content)
.unwrap_or_default();
(status, content)
}
async fn docling_md_content(config: Option<&str>) -> String {
let app = test_router();
let boundary = "cfgboundary";
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(docling_body(boundary, config)))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
serde_json::from_slice::<DoclingCompatResponse>(&bytes)
.expect("response parses")
.document
.md_content
}
#[tokio::test]
async fn openweb_process_returns_markdown_and_source() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/process")
.header("content-type", "text/plain")
.header("X-Filename", "plain.txt")
.body(Body::from(fixture_bytes()))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let parsed: OpenWebDocumentResponse =
serde_json::from_slice(&bytes).expect("response parses as OpenWebDocumentResponse");
assert!(!parsed.page_content.is_empty(), "page_content must be non-empty");
assert_eq!(parsed.metadata.source, "plain.txt");
}
#[tokio::test]
async fn openweb_process_detects_json_content_despite_txt_filename_by_default() {
let response = test_router()
.oneshot(
Request::builder()
.method("PUT")
.uri("/process")
.header("content-type", crate::core::mime::OCTET_STREAM_MIME_TYPE)
.header("X-Filename", "report.txt")
.body(Body::from(r#"{"kind":"report"}"#))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let parsed: OpenWebDocumentResponse = serde_json::from_slice(&bytes).expect("response parses");
assert_eq!(parsed.page_content, "kind: report\n");
}
#[tokio::test]
async fn openweb_process_detects_json_content_despite_txt_filename_in_content_only_mode() {
let response = test_router()
.oneshot(
Request::builder()
.method("PUT")
.uri("/process")
.header("content-type", crate::core::mime::OCTET_STREAM_MIME_TYPE)
.header("X-Filename", "report.txt")
.header("X-Config", r#"{"mime_detection_policy":"content_only"}"#)
.body(Body::from(r#"{"kind":"report"}"#))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let parsed: OpenWebDocumentResponse = serde_json::from_slice(&bytes).expect("response parses");
assert_eq!(parsed.page_content, "kind: report\n");
}
#[tokio::test]
async fn openweb_process_keeps_specific_content_type_authoritative() {
let response = test_router()
.oneshot(
Request::builder()
.method("PUT")
.uri("/process")
.header("content-type", "text/plain")
.header("X-Filename", "report.json")
.header("X-Config", r#"{"mime_detection_policy":"content_only"}"#)
.body(Body::from(r#"{"kind":"report"}"#))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let parsed: OpenWebDocumentResponse = serde_json::from_slice(&bytes).expect("response parses");
assert_eq!(parsed.page_content, "{\"kind\":\"report\"}\n");
}
#[tokio::test]
async fn openweb_process_rejects_empty_body() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/process")
.header("content-type", "text/plain")
.header("X-Filename", "empty.txt")
.body(Body::empty())
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn openweb_process_url_decodes_filename_header() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/process")
.header("content-type", "text/plain")
.header("X-Filename", "my%20file.txt")
.body(Body::from(fixture_bytes()))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let parsed: OpenWebDocumentResponse =
serde_json::from_slice(&bytes).expect("response parses as OpenWebDocumentResponse");
assert_eq!(parsed.metadata.source, "my file.txt");
}
#[tokio::test]
async fn openweb_docling_convert_returns_md_content() {
let app = test_router();
let boundary = "testboundary123";
let mut body = Vec::new();
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"files\"; filename=\"plain.txt\"\r\nContent-Type: text/plain\r\n\r\n"
)
.as_bytes(),
);
body.extend_from_slice(&fixture_bytes());
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(body))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let parsed: DoclingCompatResponse =
serde_json::from_slice(&bytes).expect("response parses as DoclingCompatResponse");
assert!(!parsed.document.md_content.is_empty(), "md_content must be non-empty");
assert_eq!(parsed.status, "success");
}
#[tokio::test]
async fn openweb_docling_detects_json_content_despite_txt_filename_by_default() {
let boundary = "doclingprefercontentboundary";
let response = test_router()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(docling_json_body(
boundary,
crate::core::mime::OCTET_STREAM_MIME_TYPE,
None,
)))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let parsed: DoclingCompatResponse = serde_json::from_slice(&bytes).expect("response parses");
assert_eq!(parsed.document.md_content, "kind: report\n");
}
#[tokio::test]
async fn openweb_docling_detects_json_content_despite_txt_filename_in_content_only_mode() {
let boundary = "doclingcontentonlyboundary";
let response = test_router()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(docling_json_body(
boundary,
crate::core::mime::OCTET_STREAM_MIME_TYPE,
Some(r#"{"mime_detection_policy":"content_only"}"#),
)))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let parsed: DoclingCompatResponse = serde_json::from_slice(&bytes).expect("response parses");
assert_eq!(parsed.document.md_content, "kind: report\n");
}
#[tokio::test]
async fn openweb_docling_rejects_missing_file() {
let app = test_router();
let boundary = "testboundary";
let body = format!("--{boundary}--\r\n");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(body))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn openweb_docling_rejects_invalid_config() {
let app = test_router();
let boundary = "badcfg";
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(docling_body(
boundary,
Some(r#"{"use_cache":"not_a_bool"}"#),
)))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn openweb_docling_config_field_changes_output() {
let markdown = docling_md_content(None).await;
let json = docling_md_content(Some(r#"{"output_format":"json"}"#)).await;
assert_ne!(
markdown, json,
"config output_format should change the rendered content"
);
}
#[tokio::test]
async fn openweb_docling_flat_form_field_changes_output() {
let markdown = docling_md_content(None).await;
let (status, json) = docling_flat_response(&[("output_format", "json")]).await;
assert_eq!(status, StatusCode::OK);
assert_ne!(markdown, json, "a flattened output_format field must reach the config");
}
#[tokio::test]
async fn openweb_docling_ignores_docling_only_form_fields() {
let markdown = docling_md_content(None).await;
let (status, json) = docling_flat_response(&[
("image_export_mode", "placeholder"),
("md_page_break_placeholder", "\u{c}"),
("output_format", "json"),
])
.await;
assert_eq!(status, StatusCode::OK);
assert_ne!(markdown, json);
}
#[tokio::test]
async fn openweb_docling_accepts_python_rendered_form_values() {
let markdown = docling_md_content(None).await;
let (status, json) = docling_flat_response(&[
("image_export_mode", "placeholder"),
("force_ocr", "False"),
("output_format", "json"),
("extraction_timeout_secs", "7200"),
("disable_ocr", "True"),
])
.await;
assert_eq!(status, StatusCode::OK, "Python-rendered values must coerce, not 400");
assert_ne!(markdown, json, "the flattened parameters must reach the config");
}
#[tokio::test]
async fn openweb_docling_config_blob_wins_over_flat_fields() {
let app = test_router();
let boundary = "bothboundary";
let mut body = docling_body_with_fields(boundary, &[("output_format", "json")]);
let closing = format!("--{boundary}--\r\n");
body.truncate(body.len() - closing.len());
body.extend_from_slice(
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"config\"\r\n\r\n{{\"output_format\":\"markdown\"}}\r\n")
.as_bytes(),
);
body.extend_from_slice(closing.as_bytes());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(body))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let content = serde_json::from_slice::<DoclingCompatResponse>(&bytes)
.expect("response parses")
.document
.md_content;
assert_eq!(content, docling_md_content(None).await, "the blob must win");
}
#[test]
fn form_values_coerce_python_literals_and_numbers() {
assert_eq!(form_value_to_json("True"), serde_json::json!(true));
assert_eq!(form_value_to_json("False"), serde_json::json!(false));
assert_eq!(form_value_to_json("7200"), serde_json::json!(7200));
assert_eq!(form_value_to_json("markdown"), serde_json::json!("markdown"));
}
#[tokio::test]
async fn openweb_docling_honors_default_config_format() {
let cfg = crate::ExtractionConfig {
output_format: crate::core::config::OutputFormat::Json,
..Default::default()
};
let app = test_router_with_config(cfg);
let boundary = "defcfg";
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/convert/file")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(docling_body(boundary, None)))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
let from_user_config = serde_json::from_slice::<DoclingCompatResponse>(&bytes)
.expect("response parses")
.document
.md_content;
let markdown_default = docling_md_content(None).await;
assert_ne!(
from_user_config, markdown_default,
"server-configured output_format must be honored, not overridden by Markdown"
);
}
#[tokio::test]
async fn openweb_external_rejects_invalid_x_config() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/process")
.header("content-type", "text/plain")
.header("X-Filename", "plain.txt")
.header("X-Config", r#"{"use_cache":"not_a_bool"}"#)
.body(Body::from(fixture_bytes()))
.expect("valid request"),
)
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn openweb_external_x_config_changes_output() {
async fn page_content(x_config: Option<&str>) -> String {
let app = test_router();
let mut builder = Request::builder()
.method("PUT")
.uri("/process")
.header("content-type", "text/plain")
.header("X-Filename", "plain.txt");
if let Some(cfg) = x_config {
builder = builder.header("X-Config", cfg);
}
let response = app
.oneshot(builder.body(Body::from(fixture_bytes())).expect("valid request"))
.await
.expect("handler responded");
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes readable");
serde_json::from_slice::<OpenWebDocumentResponse>(&bytes)
.expect("response parses")
.page_content
}
let markdown = page_content(None).await;
let json = page_content(Some(r#"{"output_format":"json"}"#)).await;
assert_ne!(markdown, json, "X-Config output_format should change rendered content");
}
}