Skip to main content

docspec_http/handlers/conversion/
mod.rs

1//! Document conversion request handlers.
2
3#[cfg(feature = "posthog")]
4mod analytics;
5
6use axum::{
7    body::{Body, Bytes},
8    http::{header, HeaderMap, HeaderValue, Response, StatusCode},
9    response::IntoResponse,
10};
11use docspec::{InputFormat, OutputFormat};
12use docspec_core::{EventSink as _, EventSource as _};
13
14use crate::{error::HttpError, mime_parser};
15
16enum Utf8Prevalidation {
17    Required,
18    SkippedBinary,
19    SkippedFutureFormat,
20}
21
22/// Handle `OPTIONS /conversion` — returns allowed methods.
23#[allow(clippy::unused_async)]
24// Reason: Axum handlers are async for route consistency even when no await is needed.
25#[inline]
26pub async fn options_conversion() -> impl IntoResponse {
27    (
28        StatusCode::NO_CONTENT,
29        [(header::ALLOW, HeaderValue::from_static("POST, OPTIONS"))],
30    )
31}
32
33/// Handle `POST /conversion` — convert markdown or HTML to `BlockNote` JSON, HTML, `oxa.dev` JSON, or Pandoc native.
34///
35/// The input reader is selected by the request's `Content-Type` header (see
36/// [`crate::mime_parser::validate_content_type`]). The output writer is
37/// selected by the request's `Accept` header (see
38/// [`crate::mime_parser::negotiate_accept`]).
39///
40/// The request body is buffered, then converted to completion inside
41/// `spawn_blocking`, then returned in a single response. Conversion errors
42/// surface as 422 (parse / sink errors) or 500 (finalize errors) **before**
43/// any response body is sent — no truncated `200 OK` on failure.
44///
45/// `request_id` is accepted as `Option<Extension<RequestId>>` so the handler
46/// remains usable for downstream consumers that mount it standalone without
47/// the [`tower_http::request_id::SetRequestIdLayer`]. When the extension is
48/// absent, the `request_id` field is **omitted** from the structured
49/// `conversion_completed` event rather than logged as an empty string —
50/// "no correlation id supplied" is a distinct state from "supplied empty".
51/// The same treatment applies to `trace_id`, which is only set when the
52/// upstream `X-Trace-ID` header is present.
53///
54/// Conversion outcome metrics are recorded with intentionally different scopes:
55///
56/// - `docspec_conversions_total` and `docspec_conversion_duration_seconds` are
57///   recorded for **every** request to this endpoint — including early
58///   validation failures — so that all outcomes are visible in dashboards.
59/// - `docspec_http_request_body_bytes` is recorded only after `Content-Type`
60///   and `Accept` validation pass and the body is confirmed non-empty.
61/// - `docspec_conversion_output_bytes` is recorded only on successful
62///   conversions (failed conversions produce no output).
63///
64/// # Errors
65///
66/// Returns [`HttpError`] when request headers or body are invalid, the
67/// conversion fails, or the response cannot be constructed.
68#[inline]
69pub async fn post_conversion(
70    request_id: Option<axum::extract::Extension<tower_http::request_id::RequestId>>,
71    headers: HeaderMap,
72    body: Bytes,
73) -> Result<Response<Body>, HttpError> {
74    let input_mime_label = crate::mime_parser::bucket_input_mime(headers.get(header::CONTENT_TYPE));
75    let trace_id_owned: Option<String> = headers
76        .get(axum::http::HeaderName::from_static("x-trace-id"))
77        .and_then(|header_value| header_value.to_str().ok())
78        .map(str::to_owned);
79    let body_len_for_logging = body.len();
80
81    let conversion_start = std::time::Instant::now();
82    let outcome = do_conversion(input_mime_label, headers, body).await;
83    let conversion_duration = conversion_start.elapsed();
84    let conversion_duration_secs = conversion_duration.as_secs_f64();
85    let conversion_duration_ms =
86        u64::try_from(conversion_duration.as_millis().min(u128::from(u64::MAX)))
87            .unwrap_or(u64::MAX);
88
89    let (response_or_error, output_bytes, chosen_format) = match outcome {
90        Ok((response, bytes, format)) => (Ok(response), bytes, Some(format)),
91        Err(http_error) => (Err(http_error), 0, None),
92    };
93    let conversion_ok = response_or_error.is_ok();
94    let output_mime_label = crate::mime_parser::bucket_output_mime(chosen_format);
95
96    let (result_label, error_class_label) = match &response_or_error {
97        Ok(_) => (
98            crate::metrics::RESULT_SUCCESS,
99            crate::metrics::ERROR_CLASS_NONE,
100        ),
101        Err(http_error) => (http_error.result_class(), http_error.error_class()),
102    };
103
104    metrics::counter!(
105        crate::metrics::METRIC_CONVERSIONS_TOTAL,
106        crate::metrics::LABEL_RESULT => result_label,
107        crate::metrics::LABEL_ERROR_CLASS => error_class_label,
108        crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
109        crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
110    )
111    .increment(1);
112
113    metrics::histogram!(
114        crate::metrics::METRIC_CONVERSION_DURATION_SECONDS,
115        crate::metrics::LABEL_RESULT => result_label,
116        crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
117        crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
118    )
119    .record(conversion_duration_secs);
120
121    if conversion_ok {
122        // Reason: u64 → f64 is lossy at extreme values but bounded by realistic output sizes.
123        #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
124        let output_bytes_f64 = output_bytes as f64;
125        metrics::histogram!(
126            crate::metrics::METRIC_CONVERSION_OUTPUT_BYTES,
127            crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
128            crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
129        )
130        .record(output_bytes_f64);
131    }
132
133    let request_id_opt: Option<&str> = request_id
134        .as_ref()
135        .and_then(|axum::extract::Extension(req_id)| req_id.header_value().to_str().ok());
136    tracing::info!(
137        event = "conversion_completed",
138        result = result_label,
139        error_class = error_class_label,
140        input_mime_type = input_mime_label,
141        output_mime_type = output_mime_label,
142        input_bytes = body_len_for_logging,
143        output_bytes,
144        duration_ms = conversion_duration_ms,
145        request_id = request_id_opt,
146        trace_id = trace_id_owned.as_deref(),
147    );
148
149    #[cfg(feature = "posthog")]
150    analytics::record_completion(analytics::CompletionContext {
151        request_id_opt,
152        result_label,
153        error_class_label,
154        input_mime_label,
155        output_mime_label,
156        body_len_for_logging,
157        output_bytes,
158        conversion_duration_ms,
159        trace_id_owned: &trace_id_owned,
160    });
161
162    response_or_error
163}
164
165/// Perform the actual validation and conversion without recording outcome metrics.
166///
167/// Body size is recorded here because it is only known after header validation
168/// succeeds and the body is confirmed non-empty.
169#[allow(clippy::too_many_lines)]
170async fn do_conversion(
171    input_mime_label: &'static str,
172    headers: HeaderMap,
173    body: Bytes,
174) -> Result<(Response<Body>, u64, OutputFormat), HttpError> {
175    let input_format = mime_parser::validate_content_type(headers.get(header::CONTENT_TYPE))?;
176    let output_format = mime_parser::negotiate_accept(headers.get(header::ACCEPT))?;
177
178    if body.is_empty() {
179        return Err(HttpError::EmptyBody);
180    }
181
182    // Reason: Body sizes are bounded by request memory and never approach the 2^53
183    // f64 precision limit, so the cast is exact in practice. The Prometheus histogram
184    // API requires f64; usize has no native lossless f64 conversion. Workspace
185    // clippy bans both lints below as a general policy; this is the single
186    // documented false-positive exception for bounded numeric metric recording.
187    #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
188    let body_len_bytes = body.len() as f64;
189    metrics::histogram!(
190        crate::metrics::METRIC_HTTP_REQUEST_BODY_BYTES,
191        crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
192    )
193    .record(body_len_bytes);
194
195    let utf8_prevalidation = match input_format {
196        InputFormat::Markdown | InputFormat::Html => Utf8Prevalidation::Required,
197        InputFormat::Docx => {
198            // Binary format; UTF-8 prevalidation does not apply.
199            Utf8Prevalidation::SkippedBinary
200        }
201        _ => {
202            // Future InputFormat variants. Conservative policy: skip UTF-8
203            // prevalidation and let the reader surface its own error.
204            // Update this arm deliberately when a new format is added.
205            Utf8Prevalidation::SkippedFutureFormat
206        }
207    };
208
209    if matches!(utf8_prevalidation, Utf8Prevalidation::Required) {
210        core::str::from_utf8(&body).map_err(|_error| {
211            tracing::debug!("request body is not valid UTF-8");
212            HttpError::BodyNotUtf8
213        })?;
214    }
215
216    let join_result = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, u64), HttpError> {
217        let mut output_buffer = Vec::new();
218        let body_vec: Vec<u8> = body.into();
219        let reader = docspec::AnyReader::from_reader(input_format, std::io::Cursor::new(body_vec))
220            .map_err(|error| {
221                tracing::debug!(error = %error, "reader construction failed");
222                HttpError::Unprocessable {
223                    detail: error.to_string(),
224                }
225            })?;
226        let mut reader = docspec_core::SkipEmptyBlocks::new(reader);
227        let mut sink = docspec::AnyWriter::new(output_format, &mut output_buffer);
228
229        loop {
230            match reader.next_event() {
231                Ok(Some(event)) => sink.handle_event(event).map_err(|error| {
232                    tracing::debug!(error = %error, "conversion sink failed");
233                    HttpError::Unprocessable {
234                        detail: error.to_string(),
235                    }
236                })?,
237                Ok(None) => break,
238                Err(error) => {
239                    tracing::debug!(error = %error, "reader failed");
240                    return Err(HttpError::Unprocessable {
241                        detail: error.to_string(),
242                    });
243                }
244            }
245        }
246
247        sink.finish().map_err(|error| {
248            tracing::warn!(error = ?error, "conversion sink finish failed");
249            HttpError::internal(error)
250        })?;
251
252        // Capture byte count before output_buffer is consumed by Body::from.
253        // u64::try_from is lossless on 64-bit targets (usize ≤ u64::MAX).
254        let output_bytes = u64::try_from(output_buffer.len()).map_err(|conversion_error| {
255            tracing::warn!(error = ?conversion_error, "output size exceeded u64 range");
256            HttpError::internal(conversion_error)
257        })?;
258        Ok((output_buffer, output_bytes))
259    })
260    .await;
261
262    let content_type = match output_format {
263        OutputFormat::Blocknote => {
264            HeaderValue::from_static("application/vnd.docspec.blocknote+json; charset=utf-8")
265        }
266        OutputFormat::Html => HeaderValue::from_static("text/html; charset=utf-8"),
267        OutputFormat::Oxa => HeaderValue::from_static("application/vnd.oxa+json; charset=utf-8"),
268        OutputFormat::PandocNative => {
269            HeaderValue::from_static("application/vnd.pandoc.native; charset=utf-8")
270        }
271        _ => HeaderValue::from_static("application/vnd.docspec.blocknote+json; charset=utf-8"),
272    };
273
274    match join_result {
275        Ok(Ok((output, output_bytes))) => Response::builder()
276            .status(StatusCode::OK)
277            .header(header::CONTENT_TYPE, content_type)
278            .body(Body::from(output))
279            .map(|response| (response, output_bytes, output_format))
280            .map_err(|error| {
281                tracing::warn!(error = ?error, "failed to build conversion response");
282                HttpError::internal(error)
283            }),
284        Ok(Err(http_error)) => Err(http_error),
285        Err(join_error) => {
286            tracing::warn!(error = ?join_error, "spawn_blocking join failed");
287            Err(HttpError::internal(join_error))
288        }
289    }
290}