1use axum::{
4 body::{Body, Bytes},
5 http::{header, HeaderMap, HeaderValue, Response, StatusCode},
6 response::IntoResponse,
7};
8use docspec::{InputFormat, OutputFormat};
9use docspec_core::{EventSink as _, EventSource as _};
10
11use crate::{error::HttpError, mime_parser};
12
13enum Utf8Prevalidation {
14 Required,
15 SkippedBinary,
16 SkippedFutureFormat,
17}
18
19#[allow(clippy::unused_async)]
21#[inline]
23pub async fn options_conversion() -> impl IntoResponse {
24 (
25 StatusCode::NO_CONTENT,
26 [(header::ALLOW, HeaderValue::from_static("POST, OPTIONS"))],
27 )
28}
29
30#[cfg(feature = "posthog")]
31#[derive(Clone, Copy)]
32struct ConversionEventData<'a> {
33 distinct_id: &'a str,
34 is_anonymous: bool,
35 result_label: &'static str,
36 error_class_label: &'static str,
37 input_mime_label: &'static str,
38 output_mime_label: &'static str,
39 input_bytes: usize,
40 output_bytes: u64,
41 duration_ms: u64,
42 trace_id: Option<&'a str>,
43}
44
45#[cfg(feature = "posthog")]
46fn log_posthog_insert_error(key: &'static str, error: &posthog_rs::Error) {
47 tracing::warn!(%error, prop = key, "posthog insert_prop failed; skipping property");
48}
49
50#[cfg(feature = "posthog")]
54fn build_posthog_conversion_event(data: ConversionEventData<'_>) -> posthog_rs::Event {
55 let mut event = posthog_rs::Event::new("conversion_completed", data.distinct_id);
56 macro_rules! insert {
57 ($key:expr, $val:expr) => {
58 if let Err(ref error) = event.insert_prop($key, $val) {
59 log_posthog_insert_error($key, error);
60 }
61 };
62 }
63 insert!("result", data.result_label);
64 insert!("error_class", data.error_class_label);
65 insert!("input_mime_type", data.input_mime_label);
66 insert!("output_mime_type", data.output_mime_label);
67 insert!(
68 "input_bytes",
69 u64::try_from(data.input_bytes).unwrap_or(u64::MAX)
70 );
71 insert!("output_bytes", data.output_bytes);
72 insert!("duration_ms", data.duration_ms);
73 insert!("$app_version", crate::telemetry::posthog::RELEASE);
74 if let Some(tid) = data.trace_id {
75 insert!("trace_id", tid);
76 }
77 if data.is_anonymous {
78 insert!("$is_anonymous", true);
79 }
80 event
81}
82
83#[inline]
119pub async fn post_conversion(
120 request_id: Option<axum::extract::Extension<tower_http::request_id::RequestId>>,
121 headers: HeaderMap,
122 body: Bytes,
123) -> Result<Response<Body>, HttpError> {
124 let input_mime_label = crate::mime_parser::bucket_input_mime(headers.get(header::CONTENT_TYPE));
125 let trace_id_owned: Option<String> = headers
126 .get(axum::http::HeaderName::from_static("x-trace-id"))
127 .and_then(|header_value| header_value.to_str().ok())
128 .map(str::to_owned);
129 let body_len_for_logging = body.len();
130
131 let conversion_start = std::time::Instant::now();
132 let outcome = do_conversion(input_mime_label, headers, body).await;
133 let conversion_duration = conversion_start.elapsed();
134 let conversion_duration_secs = conversion_duration.as_secs_f64();
135 let conversion_duration_ms =
136 u64::try_from(conversion_duration.as_millis().min(u128::from(u64::MAX)))
137 .unwrap_or(u64::MAX);
138
139 let (response_or_error, output_bytes, chosen_format) = match outcome {
140 Ok((response, bytes, format)) => (Ok(response), bytes, Some(format)),
141 Err(http_error) => (Err(http_error), 0, None),
142 };
143 let conversion_ok = response_or_error.is_ok();
144 let output_mime_label = crate::mime_parser::bucket_output_mime(chosen_format);
145
146 let (result_label, error_class_label) = match &response_or_error {
147 Ok(_) => (
148 crate::metrics::RESULT_SUCCESS,
149 crate::metrics::ERROR_CLASS_NONE,
150 ),
151 Err(http_error) => (http_error.result_class(), http_error.error_class()),
152 };
153
154 metrics::counter!(
155 crate::metrics::METRIC_CONVERSIONS_TOTAL,
156 crate::metrics::LABEL_RESULT => result_label,
157 crate::metrics::LABEL_ERROR_CLASS => error_class_label,
158 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
159 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
160 )
161 .increment(1);
162
163 metrics::histogram!(
164 crate::metrics::METRIC_CONVERSION_DURATION_SECONDS,
165 crate::metrics::LABEL_RESULT => result_label,
166 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
167 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
168 )
169 .record(conversion_duration_secs);
170
171 if conversion_ok {
172 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
174 let output_bytes_f64 = output_bytes as f64;
175 metrics::histogram!(
176 crate::metrics::METRIC_CONVERSION_OUTPUT_BYTES,
177 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
178 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
179 )
180 .record(output_bytes_f64);
181 }
182
183 let request_id_opt: Option<&str> = request_id
184 .as_ref()
185 .and_then(|axum::extract::Extension(req_id)| req_id.header_value().to_str().ok());
186 tracing::info!(
187 event = "conversion_completed",
188 result = result_label,
189 error_class = error_class_label,
190 input_mime_type = input_mime_label,
191 output_mime_type = output_mime_label,
192 input_bytes = body_len_for_logging,
193 output_bytes,
194 duration_ms = conversion_duration_ms,
195 request_id = request_id_opt,
196 trace_id = trace_id_owned.as_deref(),
197 );
198
199 #[cfg(feature = "posthog")]
200 {
201 let client_arc: Option<std::sync::Arc<posthog_rs::Client>> =
202 match crate::telemetry::posthog_client_slot().read() {
203 Ok(guard) => guard.as_ref().map(std::sync::Arc::clone),
204 Err(_poisoned) => {
205 tracing::warn!("posthog slot poisoned; skipping conversion analytics");
206 None
207 }
208 };
209 if let Some(client) = client_arc {
210 let distinct_id_owned =
211 request_id_opt.map_or_else(|| uuid::Uuid::new_v4().to_string(), str::to_owned);
212 let is_anonymous = request_id_opt.is_none();
213 let event = build_posthog_conversion_event(ConversionEventData {
214 distinct_id: &distinct_id_owned,
215 is_anonymous,
216 result_label,
217 error_class_label,
218 input_mime_label,
219 output_mime_label,
220 input_bytes: body_len_for_logging,
221 output_bytes,
222 duration_ms: conversion_duration_ms,
223 trace_id: trace_id_owned.as_deref(),
224 });
225 client.capture(event);
226 }
227 }
228
229 response_or_error
230}
231
232#[allow(clippy::too_many_lines)]
237async fn do_conversion(
238 input_mime_label: &'static str,
239 headers: HeaderMap,
240 body: Bytes,
241) -> Result<(Response<Body>, u64, OutputFormat), HttpError> {
242 let input_format = mime_parser::validate_content_type(headers.get(header::CONTENT_TYPE))?;
243 let output_format = mime_parser::negotiate_accept(headers.get(header::ACCEPT))?;
244
245 if body.is_empty() {
246 return Err(HttpError::EmptyBody);
247 }
248
249 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
255 let body_len_bytes = body.len() as f64;
256 metrics::histogram!(
257 crate::metrics::METRIC_HTTP_REQUEST_BODY_BYTES,
258 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
259 )
260 .record(body_len_bytes);
261
262 let utf8_prevalidation = match input_format {
263 InputFormat::Markdown | InputFormat::Html => Utf8Prevalidation::Required,
264 InputFormat::Docx => {
265 Utf8Prevalidation::SkippedBinary
267 }
268 _ => {
269 Utf8Prevalidation::SkippedFutureFormat
273 }
274 };
275
276 if matches!(utf8_prevalidation, Utf8Prevalidation::Required) {
277 core::str::from_utf8(&body).map_err(|_error| {
278 tracing::debug!("request body is not valid UTF-8");
279 HttpError::BodyNotUtf8
280 })?;
281 }
282
283 let join_result = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, u64), HttpError> {
284 let mut output_buffer = Vec::new();
285 let body_vec: Vec<u8> = body.into();
286 let reader = docspec::AnyReader::from_reader(input_format, std::io::Cursor::new(body_vec))
287 .map_err(|error| {
288 tracing::debug!(error = %error, "reader construction failed");
289 HttpError::Unprocessable {
290 detail: error.to_string(),
291 }
292 })?;
293 let mut reader = docspec_core::SkipEmptyBlocks::new(reader);
294 let mut sink = docspec::AnyWriter::new(output_format, &mut output_buffer);
295
296 loop {
297 match reader.next_event() {
298 Ok(Some(event)) => sink.handle_event(event).map_err(|error| {
299 tracing::debug!(error = %error, "conversion sink failed");
300 HttpError::Unprocessable {
301 detail: error.to_string(),
302 }
303 })?,
304 Ok(None) => break,
305 Err(error) => {
306 tracing::debug!(error = %error, "reader failed");
307 return Err(HttpError::Unprocessable {
308 detail: error.to_string(),
309 });
310 }
311 }
312 }
313
314 sink.finish().map_err(|error| {
315 tracing::warn!(error = ?error, "conversion sink finish failed");
316 HttpError::internal(error)
317 })?;
318
319 let output_bytes = u64::try_from(output_buffer.len()).map_err(|conversion_error| {
322 tracing::warn!(error = ?conversion_error, "output size exceeded u64 range");
323 HttpError::internal(conversion_error)
324 })?;
325 Ok((output_buffer, output_bytes))
326 })
327 .await;
328
329 let content_type = match output_format {
330 OutputFormat::Blocknote => {
331 HeaderValue::from_static("application/vnd.docspec.blocknote+json; charset=utf-8")
332 }
333 OutputFormat::Html => HeaderValue::from_static("text/html; charset=utf-8"),
334 OutputFormat::Oxa => HeaderValue::from_static("application/vnd.oxa+json; charset=utf-8"),
335 OutputFormat::PandocNative => {
336 HeaderValue::from_static("application/vnd.pandoc.native; charset=utf-8")
337 }
338 _ => HeaderValue::from_static("application/vnd.docspec.blocknote+json; charset=utf-8"),
339 };
340
341 match join_result {
342 Ok(Ok((output, output_bytes))) => Response::builder()
343 .status(StatusCode::OK)
344 .header(header::CONTENT_TYPE, content_type)
345 .body(Body::from(output))
346 .map(|response| (response, output_bytes, output_format))
347 .map_err(|error| {
348 tracing::warn!(error = ?error, "failed to build conversion response");
349 HttpError::internal(error)
350 }),
351 Ok(Err(http_error)) => Err(http_error),
352 Err(join_error) => {
353 tracing::warn!(error = ?join_error, "spawn_blocking join failed");
354 Err(HttpError::internal(join_error))
355 }
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 #[cfg(feature = "posthog")]
362 mod posthog_tests {
363 #![allow(clippy::expect_used, clippy::unwrap_used)]
364
365 #[test]
366 fn build_posthog_conversion_event_smoke_test() {
367 let event =
368 super::super::build_posthog_conversion_event(super::super::ConversionEventData {
369 distinct_id: "test-user-123",
370 is_anonymous: false,
371 result_label: "success",
372 error_class_label: "none",
373 input_mime_label: "text/markdown",
374 output_mime_label: "application/vnd.docspec.blocknote+json",
375 input_bytes: 100,
376 output_bytes: 200,
377 duration_ms: 50,
378 trace_id: Some("trace-abc"),
379 });
380 drop(event);
381 }
382 }
383}