use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use toolkit::api::OpenApiRegistry;
use toolkit::{Gear, GearCtx, RestApiCapability};
use tracing::{debug, info};
use file_parser_sdk::FileParserClientV1;
use crate::config::FileParserConfig;
use crate::domain::local_client::FileParserLocalClient;
use crate::domain::service::{FileParserService, ServiceConfig};
use crate::infra::parsers::{
DocxParser, ImageParser, KreuzbergParser, PlainTextParser, StubParser,
};
#[toolkit::gear(
name = "file-parser",
capabilities = [rest]
)]
pub struct FileParserGear {
service: OnceLock<Arc<FileParserService>>,
}
impl Default for FileParserGear {
fn default() -> Self {
Self {
service: OnceLock::new(),
}
}
}
#[cfg(feature = "magika")]
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MagikaInitError {
#[error(
"Magika content-type detector did not finish loading within {}s; the ONNX Runtime is \
likely missing, the wrong architecture, or incompatible with the version `ort` was \
built against — check ORT_DYLIB_PATH (the init thread is abandoned, not killed, and \
will leak until the process exits)",
timeout.as_secs()
)]
Timeout { timeout: std::time::Duration },
#[error("Magika content-type detector initialization cancelled by gear shutdown")]
Cancelled,
#[error("Magika detector init thread died unexpectedly: {0}")]
InitThreadDied(#[source] tokio::sync::oneshot::error::RecvError),
#[error("failed to load Magika content-type detector: {0}")]
SessionLoad(#[source] magika::Error),
}
#[cfg(feature = "magika")]
impl MagikaInitError {
#[must_use]
pub const fn leaked_init_thread(&self) -> bool {
match self {
Self::Timeout { .. } | Self::Cancelled => true,
Self::InitThreadDied(_) | Self::SessionLoad(_) => false,
}
}
}
#[cfg(feature = "magika")]
#[doc(hidden)]
pub async fn init_magika_detector(
parsers: &[Arc<dyn crate::domain::parser::FileParserBackend>],
intra_op_threads: Option<std::num::NonZeroUsize>,
cancellation_token: &tokio_util::sync::CancellationToken,
) -> Result<Arc<dyn crate::domain::detector::ContentTypeDetector>, MagikaInitError> {
const MAGIKA_INIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
if cancellation_token.is_cancelled() {
return Err(MagikaInitError::Cancelled);
}
let supported_extensions: Vec<String> = parsers
.iter()
.flat_map(|p| p.supported_extensions().iter().map(|ext| (*ext).to_owned()))
.collect();
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
drop(tx.send(crate::infra::MagikaDetector::with_config(
supported_extensions,
intra_op_threads,
)));
});
let detector = tokio::select! {
result = tokio::time::timeout(MAGIKA_INIT_TIMEOUT, rx) => {
result
.map_err(|_elapsed| MagikaInitError::Timeout { timeout: MAGIKA_INIT_TIMEOUT })?
.map_err(MagikaInitError::InitThreadDied)?
.map_err(MagikaInitError::SessionLoad)?
}
() = cancellation_token.cancelled() => {
return Err(MagikaInitError::Cancelled);
}
};
info!("Magika content-type detection enabled");
Ok(Arc::new(detector) as Arc<dyn crate::domain::detector::ContentTypeDetector>)
}
#[async_trait]
impl Gear for FileParserGear {
#[allow(clippy::cast_possible_truncation)]
async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> {
const BYTES_IN_MB: u64 = 1024_u64 * 1024;
let cfg: FileParserConfig = ctx.config()?;
debug!(
"Loaded file-parser config: max_file_size_mb={}",
cfg.max_file_size_mb
);
let max_file_size_bytes = cfg.max_file_size_mb.saturating_mul(BYTES_IN_MB);
let parsers: Vec<Arc<dyn crate::domain::parser::FileParserBackend>> = vec![
Arc::new(PlainTextParser::new().with_max_bytes(max_file_size_bytes)),
Arc::new(KreuzbergParser::new()),
Arc::new(DocxParser::new()),
Arc::new(ImageParser::new()),
Arc::new(StubParser::new().with_max_bytes(max_file_size_bytes)),
];
info!("Registered {} parser backends", parsers.len());
let allowed_local_base_dir = cfg.allowed_local_base_dir.canonicalize().map_err(|e| {
anyhow::anyhow!(
"allowed_local_base_dir '{}' cannot be resolved: {e}",
cfg.allowed_local_base_dir.display()
)
})?;
if !allowed_local_base_dir.is_dir() {
return Err(anyhow::anyhow!(
"allowed_local_base_dir '{}' is not a directory",
allowed_local_base_dir.display()
));
}
info!(
allowed_local_base_dir = %allowed_local_base_dir.display(),
"Local file parsing restricted to base directory"
);
let service_config = ServiceConfig {
max_file_size_bytes: usize::try_from(max_file_size_bytes).unwrap_or(usize::MAX),
allowed_local_base_dir,
};
#[cfg(feature = "magika")]
let detector = match init_magika_detector(
&parsers,
cfg.magika_intra_op_threads,
ctx.cancellation_token(),
)
.await
{
Ok(detector) => detector,
Err(e) => {
if e.leaked_init_thread() {
tracing::error!(
error = %e,
"Magika init left an abandoned OS thread; this is only bounded because \
gear init failure terminates the process"
);
}
return Err(e.into());
}
};
let detection_confidence_threshold =
crate::domain::detector::Confidence::new(cfg.detection_confidence_threshold)
.ok_or_else(|| {
anyhow::anyhow!(
"detection_confidence_threshold ({}) must be a number in [0.0, 1.0]",
cfg.detection_confidence_threshold
)
})?;
#[allow(unused_mut)]
let mut service = FileParserService::new(parsers, service_config)
.with_detection_confidence_threshold(detection_confidence_threshold);
#[cfg(feature = "magika")]
{
service = service.with_detector(detector);
}
let file_parser_service = Arc::new(service);
let client: Arc<dyn FileParserClientV1> =
Arc::new(FileParserLocalClient::new(file_parser_service.clone()));
ctx.client_hub().register::<dyn FileParserClientV1>(client);
self.service
.set(file_parser_service)
.map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?;
Ok(())
}
}
impl RestApiCapability for FileParserGear {
fn register_rest(
&self,
_ctx: &GearCtx,
router: axum::Router,
openapi: &dyn OpenApiRegistry,
) -> anyhow::Result<axum::Router> {
info!("Registering file-parser REST routes");
let service = self
.service
.get()
.ok_or_else(|| anyhow::anyhow!("Service not initialized"))?
.clone();
let router = crate::api::rest::routes::register_routes(router, openapi, service);
info!("File parser REST routes registered successfully");
Ok(router)
}
}