#[cfg(feature = "api")]
use utoipa::OpenApi;
#[cfg(feature = "api")]
#[derive(OpenApi)]
#[openapi(
info(
title = "Xberg API",
version = env!("CARGO_PKG_VERSION"),
description = "High-performance document intelligence API for extracting text, metadata, and structured data from PDFs, Office documents, images, and 107 formats.",
contact(
name = "Xberg",
url = "https://xberg.io"
),
license(
name = "Apache-2.0 OR MIT"
)
),
servers(
(url = "http://localhost:8000", description = "Local development server"),
(url = "https://api.xberg.io", description = "Production server (example)")
),
paths(
crate::api::handlers::health_handler,
crate::api::handlers::info_handler,
crate::api::handlers::extract_handler,
crate::api::handlers::extract_async_handler,
crate::api::handlers::job_status_handler,
crate::api::handlers::cancel_job_handler,
crate::api::handlers::detect_handler,
crate::api::handlers::formats_handler,
crate::api::handlers::cache_stats_handler,
crate::api::handlers::cache_clear_handler,
crate::api::handlers::cache_manifest_handler,
crate::api::handlers::cache_warm_handler,
crate::api::handlers::version_handler,
crate::api::openweb::openweb_external_handler,
crate::api::openweb::openweb_docling_handler,
),
components(
schemas(
crate::api::types::HealthResponse,
crate::api::types::PluginStatus,
crate::api::types::InfoResponse,
crate::api::types::ErrorResponse,
crate::api::types::CacheStatsResponse,
crate::api::types::CacheClearResponse,
crate::api::types::VersionResponse,
crate::api::types::DetectResponse,
crate::api::types::AsyncJobResponse,
crate::api::types::JobState,
crate::api::types::JobStatus,
crate::api::types::ManifestResponse,
crate::api::types::ManifestEntryResponse,
crate::api::types::WarmRequest,
crate::api::types::WarmResponse,
crate::core::mime::SupportedFormat,
crate::core::config::MimeDetectionPolicy,
crate::core::config::ExtractionResult,
crate::core::config::ExtractionSummary,
crate::core::config::ExtractionErrorItem,
crate::types::extraction::ExtractedDocument,
crate::types::extraction::Chunk,
crate::types::extraction::ChunkMetadata,
crate::types::extraction::ExtractedImage,
crate::types::extraction::Element,
crate::types::extraction::ElementMetadata,
crate::types::extraction::ElementType,
crate::types::extraction::BoundingBox,
crate::types::ocr_elements::OcrElement,
crate::types::ocr_elements::OcrBoundingGeometry,
crate::types::ocr_elements::OcrConfidence,
crate::types::ocr_elements::OcrRotation,
crate::types::ocr_elements::OcrElementLevel,
crate::types::ocr_elements::OcrElementConfig,
crate::types::metadata::Metadata,
crate::types::tables::Table,
crate::types::page::PageContent,
crate::types::djot::DjotContent,
crate::types::djot::DjotAttributeGroup,
// Nested schemas reachable from the types above. utoipa emits a `$ref`
// for each of these but only defines a component for types listed here,
// so omitting one produces a dangling pointer (#251). ~keep
crate::types::extraction::ArchiveEntry,
crate::types::extraction::DocumentCounts,
crate::types::extraction::LanguageConfidence,
crate::types::extraction::ExtractionMethod,
crate::types::extraction::LlmUsage,
crate::types::extraction::ProcessingWarning,
crate::types::djot::DjotImage,
crate::types::djot::DjotLink,
crate::types::djot::Footnote,
crate::types::djot::FormattedBlock,
crate::types::annotations::PdfAnnotation,
crate::types::classification::PageClassification,
crate::types::document_structure::DocumentStructure,
crate::types::entity::Entity,
crate::types::form_field::PdfFormField,
crate::types::formula::Formula,
crate::types::redaction::RedactionReport,
crate::types::revisions::DocumentRevision,
crate::types::summary::DocumentSummary,
crate::types::translation::Translation,
crate::types::uri::ExtractedUri,
// Transitive closure of the above. `NodeIndex` is deliberately absent:
// it carries `schema(value_type = u32)`, so utoipa inlines it as a
// primitive rather than emitting a `$ref`. ~keep
crate::types::djot::BlockType,
crate::types::djot::InlineElement,
crate::types::djot::InlineType,
crate::types::djot::Attributes,
crate::types::document_structure::DocumentNode,
crate::types::document_structure::DocumentRelationship,
crate::types::document_structure::RelationshipKind,
crate::types::document_structure::NodeContent,
crate::types::document_structure::ContentLayer,
crate::types::document_structure::TextAnnotation,
crate::types::document_structure::AnnotationKind,
crate::types::document_structure::TableGrid,
crate::types::document_structure::GridCell,
crate::api::types::OpenWebDocumentResponse,
crate::api::types::OpenWebDocumentMetadata,
crate::api::types::DoclingCompatResponse,
crate::api::types::DoclingCompatDocument,
// Second-order nested schemas: reachable only through fields on the
// "transitive closure" types above (`Metadata::pages`/`::format`,
// `PageContent::layout_regions`/`::hierarchy`, `ExtractedImage::qr_codes`,
// the post-processor output fields on `ExtractedDocument` — redaction,
// revisions, classification, entities, summary, URIs, PDF
// annotations/forms — and `Chunk`'s sparse/late-interaction embeddings).
// Same rule as above: an unlisted type here leaves its `$ref` dangling
// (#1424). ~keep
crate::types::page::PageStructure,
crate::types::page::PageUnitType,
crate::types::page::PageBoundary,
crate::types::page::PageInfo,
crate::types::page::LayoutRegion,
crate::types::page::PageHierarchy,
crate::types::page::HierarchicalBlock,
crate::types::extraction::ChunkType,
crate::types::extraction::HeadingContext,
crate::types::extraction::HeadingLevel,
crate::types::extraction::PageSpan,
crate::types::extraction::ImageKind,
crate::types::qr::QrCode,
crate::types::qr::QrBoundingBox,
crate::types::classification::ClassificationLabel,
crate::types::entity::EntityCategory,
crate::types::form_field::FormFieldType,
crate::types::redaction::RedactionFinding,
crate::types::redaction::RedactionStrategy,
crate::types::redaction::PiiCategory,
crate::types::revisions::DiffLine,
crate::types::revisions::CellChange,
crate::types::revisions::PropertyChange,
crate::types::revisions::RevisionKind,
crate::types::revisions::RevisionAnchor,
crate::types::revisions::RevisionDelta,
crate::types::annotations::PdfAnnotationType,
crate::types::uri::UriKind,
crate::types::summary::SummaryStrategy,
crate::types::formats::ImagePreprocessingMetadata,
crate::SparseEmbedding,
crate::MultiVectorEmbedding,
// `Metadata::format`'s discriminated-union target and its format-specific
// variants. `PdfMetadata` (feature `pdf`), `DocxMetadata`/`BibtexMetadata`/
// `CitationMetadata`/`FictionBookMetadata`/`DbfMetadata`/`EpubMetadata`
// (feature `office`), `JatsMetadata` (feature `xml`), and `AudioMetadata`
// (feature `transcription-types`) are registered by the matching gated
// `OpenApi` document below since their struct definitions are themselves
// `#[cfg]`-gated; every other variant type here compiles unconditionally
crate::types::metadata::FormatMetadata,
crate::types::metadata::ExcelMetadata,
crate::types::metadata::EmailMetadata,
crate::types::metadata::PptxMetadata,
crate::types::metadata::ArchiveMetadata,
crate::types::metadata::ImageMetadata,
crate::types::metadata::XmlMetadata,
crate::types::metadata::TextMetadata,
crate::types::metadata::TextDirection,
crate::types::metadata::HeaderMetadata,
crate::types::metadata::LinkMetadata,
crate::types::metadata::LinkType,
crate::types::metadata::ImageMetadataType,
crate::types::metadata::ImageType,
crate::types::metadata::StructuredData,
crate::types::metadata::StructuredDataType,
crate::types::metadata::HtmlMetadata,
crate::types::metadata::OcrMetadata,
crate::types::metadata::CsvMetadata,
crate::types::metadata::PstMetadata,
crate::types::metadata::ErrorMetadata,
crate::types::metadata::BibtexMetadata,
crate::types::metadata::CitationMetadata,
crate::types::metadata::YearRange,
crate::types::metadata::FictionBookMetadata,
crate::types::metadata::DbfMetadata,
crate::types::metadata::DbfFieldInfo,
crate::types::metadata::JatsMetadata,
crate::types::metadata::ContributorRole,
crate::types::metadata::EpubMetadata,
)
),
tags(
(name = "health", description = "Health and status endpoints"),
(name = "extraction", description = "Document extraction endpoints"),
(name = "cache", description = "Cache management endpoints"),
(name = "openweb", description = "OpenWebUI compatibility endpoints")
)
)]
#[cfg_attr(alef, alef(skip))]
pub struct ApiDoc;
#[cfg(all(feature = "api", feature = "tree-sitter"))]
#[derive(OpenApi)]
#[openapi(components(schemas(
crate::types::metadata::CodeDataAttribute,
crate::types::metadata::CodeDataNodeKind,
crate::types::metadata::CodeMetadata,
crate::types::metadata::CodeChunkInfo,
crate::types::metadata::CodeDataNode,
)))]
#[cfg_attr(alef, alef(skip))]
struct TreeSitterSchemas;
#[cfg(all(feature = "api", feature = "heuristics"))]
#[derive(OpenApi)]
#[openapi(components(schemas(
crate::heuristics::confidence::ExtractionConfidence,
crate::heuristics::confidence::SchemaCompliance,
)))]
#[cfg_attr(alef, alef(skip))]
struct HeuristicsSchemas;
#[cfg(all(feature = "api", any(feature = "keywords-yake", feature = "keywords-rake")))]
#[derive(OpenApi)]
#[openapi(components(schemas(crate::keywords::types::Keyword, crate::keywords::types::KeywordAlgorithm,)))]
#[cfg_attr(alef, alef(skip))]
struct KeywordSchemas;
#[cfg(all(feature = "api", feature = "pdf"))]
#[derive(OpenApi)]
#[openapi(components(schemas(crate::pdf::metadata::PdfMetadata)))]
#[cfg_attr(alef, alef(skip))]
struct PdfSchemas;
#[cfg(all(feature = "api", feature = "office"))]
#[derive(OpenApi)]
#[openapi(components(schemas(crate::types::metadata::DocxMetadata)))]
#[cfg_attr(alef, alef(skip))]
struct OfficeSchemas;
#[cfg(all(feature = "api", feature = "transcription-types"))]
#[derive(OpenApi)]
#[openapi(components(schemas(crate::types::metadata::AudioMetadata)))]
#[cfg_attr(alef, alef(skip))]
struct TranscriptionSchemas;
#[cfg(all(feature = "api", feature = "prometheus"))]
#[derive(OpenApi)]
#[openapi(paths(crate::api::handlers::metrics_handler))]
#[cfg_attr(alef, alef(skip))]
struct PrometheusPaths;
#[cfg(feature = "api")]
#[cfg_attr(alef, alef(skip))]
pub fn openapi_json() -> String {
#[allow(unused_mut)]
let mut document = ApiDoc::openapi();
#[cfg(feature = "tree-sitter")]
document.merge(TreeSitterSchemas::openapi());
#[cfg(feature = "heuristics")]
document.merge(HeuristicsSchemas::openapi());
#[cfg(any(feature = "keywords-yake", feature = "keywords-rake"))]
document.merge(KeywordSchemas::openapi());
#[cfg(feature = "pdf")]
document.merge(PdfSchemas::openapi());
#[cfg(feature = "office")]
document.merge(OfficeSchemas::openapi());
#[cfg(feature = "transcription-types")]
document.merge(TranscriptionSchemas::openapi());
#[cfg(feature = "prometheus")]
document.merge(PrometheusPaths::openapi());
document.to_pretty_json().unwrap_or_else(|_| "{}".to_string())
}
#[cfg(not(feature = "api"))]
#[cfg_attr(alef, alef(skip))]
pub(crate) fn openapi_json() -> String {
r#"{"error": "API feature not enabled"}"#.to_string()
}
#[cfg(all(test, feature = "api"))]
fn collect_refs(value: &serde_json::Value, found: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map {
if key == "$ref" {
if let Some(reference) = child.as_str() {
found.push(reference.to_string());
}
} else {
collect_refs(child, found);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
collect_refs(item, found);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "api")]
use super::*;
#[test]
#[cfg(feature = "api")]
fn test_openapi_schema_generation() {
let schema = openapi_json();
assert!(!schema.is_empty());
assert!(schema.contains("Xberg API"));
assert!(schema.contains("/health"));
assert!(schema.contains("/extract"));
}
#[test]
#[cfg(feature = "api")]
fn test_openapi_schema_valid_json() {
let schema = openapi_json();
let parsed: serde_json::Value = serde_json::from_str(&schema).expect("Invalid JSON");
assert!(parsed.is_object());
assert!(parsed["openapi"].is_string());
}
#[test]
#[cfg(feature = "api")]
fn should_publish_mime_detection_policy_as_a_snake_case_enum_component() {
let document: serde_json::Value =
serde_json::from_str(&openapi_json()).expect("OpenAPI document must be valid JSON");
let schema = &document["components"]["schemas"]["MimeDetectionPolicy"];
assert_eq!(schema["type"], "string");
assert_eq!(
schema["enum"],
serde_json::json!(["prefer_content", "trust_extension", "content_only"])
);
}
#[test]
#[cfg(feature = "api")]
fn test_openapi_includes_all_endpoints() {
let schema = openapi_json();
assert!(schema.contains("/health"));
assert!(schema.contains("/info"));
assert!(schema.contains("/version"));
assert!(schema.contains("/extract"));
assert!(schema.contains("/detect"));
assert!(schema.contains("/cache/stats"));
assert!(schema.contains("/cache/clear"));
assert!(schema.contains("/cache/manifest"));
assert!(schema.contains("/cache/warm"));
}
#[test]
#[cfg(feature = "api")]
fn should_document_async_job_endpoints_in_openapi() {
let document: serde_json::Value =
serde_json::from_str(&openapi_json()).expect("OpenAPI document must be valid JSON");
assert!(document["paths"].is_object(), "document must have a paths object");
let paths = &document["paths"];
let async_operations: Vec<&str> = ["get", "post", "put", "delete"]
.into_iter()
.filter(|method| paths["/extract-async"].get(*method).is_some())
.collect();
assert_eq!(
async_operations,
vec!["post"],
"/extract-async must expose exactly POST"
);
let job_operations: Vec<&str> = ["get", "post", "put", "delete"]
.into_iter()
.filter(|method| paths["/jobs/{job_id}"].get(*method).is_some())
.collect();
assert_eq!(
job_operations,
vec!["get", "delete"],
"/jobs/{{job_id}} must expose exactly GET and DELETE"
);
let mut accepted_refs = Vec::new();
collect_refs(&paths["/extract-async"]["post"]["responses"]["202"], &mut accepted_refs);
assert_eq!(
accepted_refs,
vec!["#/components/schemas/AsyncJobResponse"],
"the 202 response must reference exactly AsyncJobResponse"
);
let schemas = document["components"]["schemas"]
.as_object()
.expect("document must have component schemas");
for name in ["AsyncJobResponse", "JobStatus", "JobState"] {
assert!(schemas.contains_key(name), "component schema '{name}' must be defined");
}
}
#[test]
#[cfg(feature = "api")]
fn should_declare_415_for_unsupported_content_type_on_extract_operations() {
let document: serde_json::Value =
serde_json::from_str(&openapi_json()).expect("OpenAPI document must be valid JSON");
let paths = &document["paths"];
for (path, method) in [("/extract", "post"), ("/extract-async", "post")] {
let responses = &paths[path][method]["responses"];
assert_eq!(
responses["415"]["description"].as_str(),
Some("Unsupported Content-Type"),
"{path} {method} must declare a 415 response"
);
let mut refs = Vec::new();
collect_refs(&responses["415"], &mut refs);
assert_eq!(
refs,
vec!["#/components/schemas/ErrorResponse"],
"{path} {method}'s 415 response must reference exactly ErrorResponse"
);
}
}
#[test]
#[cfg(feature = "api")]
fn should_resolve_every_openapi_ref_to_a_defined_component() {
let document: serde_json::Value =
serde_json::from_str(&openapi_json()).expect("OpenAPI document must be valid JSON");
let mut references = Vec::new();
collect_refs(&document, &mut references);
references.sort();
references.dedup();
assert!(
!references.is_empty(),
"document contains no $ref entries at all — the walker is broken or the spec is empty"
);
let dangling: Vec<String> = references
.iter()
.filter(|reference| {
reference
.strip_prefix('#')
.and_then(|pointer| document.pointer(pointer))
.is_none()
})
.cloned()
.collect();
assert_eq!(
dangling,
Vec::<String>::new(),
"these $ref targets are missing from the OpenAPI document; add the type to components(schemas(..))"
);
}
#[test]
#[cfg(feature = "api")]
fn should_define_second_order_nested_schemas_from_issue_1424() {
let document: serde_json::Value =
serde_json::from_str(&openapi_json()).expect("OpenAPI document must be valid JSON");
let schemas = document["components"]["schemas"]
.as_object()
.expect("document must have component schemas");
let expected_components = [
"PageStructure",
"PageUnitType",
"PageBoundary",
"PageInfo",
"LayoutRegion",
"PageHierarchy",
"HierarchicalBlock",
"ChunkType",
"HeadingContext",
"HeadingLevel",
"PageSpan",
"ImageKind",
"QrCode",
"QrBoundingBox",
"ClassificationLabel",
"EntityCategory",
"FormFieldType",
"RedactionFinding",
"RedactionStrategy",
"PiiCategory",
"DiffLine",
"CellChange",
"PropertyChange",
"RevisionKind",
"RevisionAnchor",
"RevisionDelta",
"PdfAnnotationType",
"UriKind",
"SummaryStrategy",
"ImagePreprocessingMetadata",
"SparseEmbedding",
"MultiVectorEmbedding",
"FormatMetadata",
"ExcelMetadata",
"EmailMetadata",
"PptxMetadata",
"ArchiveMetadata",
"ImageMetadata",
"XmlMetadata",
"TextMetadata",
"TextDirection",
"HeaderMetadata",
"LinkMetadata",
"LinkType",
"ImageMetadataType",
"ImageType",
"StructuredData",
"StructuredDataType",
"HtmlMetadata",
"OcrMetadata",
"CsvMetadata",
"PstMetadata",
"ErrorMetadata",
"BibtexMetadata",
"CitationMetadata",
"YearRange",
"FictionBookMetadata",
"DbfMetadata",
"DbfFieldInfo",
"JatsMetadata",
"ContributorRole",
"EpubMetadata",
];
let missing: Vec<&str> = expected_components
.into_iter()
.filter(|name| !schemas.contains_key(*name))
.collect();
assert_eq!(
missing,
Vec::<&str>::new(),
"these types are reachable from ExtractedDocument's response schema but missing \
from components.schemas — add them to ApiDoc's schemas(..) list"
);
}
#[test]
#[cfg(feature = "api")]
fn test_openapi_includes_schemas() {
let schema = openapi_json();
assert!(schema.contains("HealthResponse"));
assert!(schema.contains("ErrorResponse"));
assert!(schema.contains("VersionResponse"));
assert!(schema.contains("DetectResponse"));
assert!(schema.contains("ManifestResponse"));
assert!(schema.contains("WarmRequest"));
assert!(schema.contains("WarmResponse"));
}
}