docspec_http/handlers/conversion/
mod.rs1#[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#[allow(clippy::unused_async)]
24#[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#[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 #[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#[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 #[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 Utf8Prevalidation::SkippedBinary
200 }
201 _ => {
202 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 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}