Skip to main content

docspec_http/handlers/
conversion.rs

1//! Document conversion request handlers.
2
3use 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/// Handle `OPTIONS /conversion` — returns allowed methods.
20#[allow(clippy::unused_async)]
21// Reason: Axum handlers are async for route consistency even when no await is needed.
22#[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/// Builds a `conversion_completed` analytics event for `PostHog`.
51///
52/// Property insertion failures are logged and skipped (best-effort).
53#[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/// Handle `POST /conversion` — convert markdown or HTML to `BlockNote` JSON, HTML, `oxa.dev` JSON, or Pandoc native.
84///
85/// The input reader is selected by the request's `Content-Type` header (see
86/// [`crate::mime_parser::validate_content_type`]). The output writer is
87/// selected by the request's `Accept` header (see
88/// [`crate::mime_parser::negotiate_accept`]).
89///
90/// The request body is buffered, then converted to completion inside
91/// `spawn_blocking`, then returned in a single response. Conversion errors
92/// surface as 422 (parse / sink errors) or 500 (finalize errors) **before**
93/// any response body is sent — no truncated `200 OK` on failure.
94///
95/// `request_id` is accepted as `Option<Extension<RequestId>>` so the handler
96/// remains usable for downstream consumers that mount it standalone without
97/// the [`tower_http::request_id::SetRequestIdLayer`]. When the extension is
98/// absent, the `request_id` field is **omitted** from the structured
99/// `conversion_completed` event rather than logged as an empty string —
100/// "no correlation id supplied" is a distinct state from "supplied empty".
101/// The same treatment applies to `trace_id`, which is only set when the
102/// upstream `X-Trace-ID` header is present.
103///
104/// Conversion outcome metrics are recorded with intentionally different scopes:
105///
106/// - `docspec_conversions_total` and `docspec_conversion_duration_seconds` are
107///   recorded for **every** request to this endpoint — including early
108///   validation failures — so that all outcomes are visible in dashboards.
109/// - `docspec_http_request_body_bytes` is recorded only after `Content-Type`
110///   and `Accept` validation pass and the body is confirmed non-empty.
111/// - `docspec_conversion_output_bytes` is recorded only on successful
112///   conversions (failed conversions produce no output).
113///
114/// # Errors
115///
116/// Returns [`HttpError`] when request headers or body are invalid, the
117/// conversion fails, or the response cannot be constructed.
118#[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        // Reason: u64 → f64 is lossy at extreme values but bounded by realistic output sizes.
173        #[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/// Perform the actual validation and conversion without recording outcome metrics.
233///
234/// Body size is recorded here because it is only known after header validation
235/// succeeds and the body is confirmed non-empty.
236#[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    // Reason: Body sizes are bounded by request memory and never approach the 2^53
250    // f64 precision limit, so the cast is exact in practice. The Prometheus histogram
251    // API requires f64; usize has no native lossless f64 conversion. Workspace
252    // clippy bans both lints below as a general policy; this is the single
253    // documented false-positive exception for bounded numeric metric recording.
254    #[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            // Binary format; UTF-8 prevalidation does not apply.
266            Utf8Prevalidation::SkippedBinary
267        }
268        _ => {
269            // Future InputFormat variants. Conservative policy: skip UTF-8
270            // prevalidation and let the reader surface its own error.
271            // Update this arm deliberately when a new format is added.
272            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        // Capture byte count before output_buffer is consumed by Body::from.
320        // u64::try_from is lossless on 64-bit targets (usize ≤ u64::MAX).
321        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}