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#[inline]
66pub async fn post_conversion(
67 request_id: Option<axum::extract::Extension<tower_http::request_id::RequestId>>,
68 headers: HeaderMap,
69 body: Bytes,
70) -> Result<Response<Body>, HttpError> {
71 let input_mime_label = crate::mime_parser::bucket_input_mime(headers.get(header::CONTENT_TYPE));
72 let trace_id_owned: Option<String> = headers
73 .get(axum::http::HeaderName::from_static("x-trace-id"))
74 .and_then(|header_value| header_value.to_str().ok())
75 .map(str::to_owned);
76 let body_len_for_logging = body.len();
77
78 let conversion_start = std::time::Instant::now();
79 let outcome = do_conversion(input_mime_label, headers, body).await;
80 let conversion_duration = conversion_start.elapsed();
81 let conversion_duration_secs = conversion_duration.as_secs_f64();
82 let conversion_duration_ms =
83 u64::try_from(conversion_duration.as_millis().min(u128::from(u64::MAX)))
84 .unwrap_or(u64::MAX);
85
86 let (response_or_error, output_bytes, chosen_format) = match outcome {
87 Ok((response, bytes, format)) => (Ok(response), bytes, Some(format)),
88 Err(http_error) => (Err(http_error), 0, None),
89 };
90 let conversion_ok = response_or_error.is_ok();
91 let output_mime_label = crate::mime_parser::bucket_output_mime(chosen_format);
92
93 let (result_label, error_class_label) = match &response_or_error {
94 Ok(_) => (
95 crate::metrics::RESULT_SUCCESS,
96 crate::metrics::ERROR_CLASS_NONE,
97 ),
98 Err(http_error) => (http_error.result_class(), http_error.error_class()),
99 };
100
101 metrics::counter!(
102 crate::metrics::METRIC_CONVERSIONS_TOTAL,
103 crate::metrics::LABEL_RESULT => result_label,
104 crate::metrics::LABEL_ERROR_CLASS => error_class_label,
105 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
106 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
107 )
108 .increment(1);
109
110 metrics::histogram!(
111 crate::metrics::METRIC_CONVERSION_DURATION_SECONDS,
112 crate::metrics::LABEL_RESULT => result_label,
113 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
114 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
115 )
116 .record(conversion_duration_secs);
117
118 if conversion_ok {
119 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
121 let output_bytes_f64 = output_bytes as f64;
122 metrics::histogram!(
123 crate::metrics::METRIC_CONVERSION_OUTPUT_BYTES,
124 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
125 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
126 )
127 .record(output_bytes_f64);
128 }
129
130 let request_id_opt: Option<&str> = request_id
131 .as_ref()
132 .and_then(|axum::extract::Extension(req_id)| req_id.header_value().to_str().ok());
133 tracing::info!(
134 event = "conversion_completed",
135 result = result_label,
136 error_class = error_class_label,
137 input_mime_type = input_mime_label,
138 output_mime_type = output_mime_label,
139 input_bytes = body_len_for_logging,
140 output_bytes,
141 duration_ms = conversion_duration_ms,
142 request_id = request_id_opt,
143 trace_id = trace_id_owned.as_deref(),
144 );
145
146 response_or_error
147}
148
149#[allow(clippy::too_many_lines)]
154async fn do_conversion(
155 input_mime_label: &'static str,
156 headers: HeaderMap,
157 body: Bytes,
158) -> Result<(Response<Body>, u64, OutputFormat), HttpError> {
159 let input_format = mime_parser::validate_content_type(headers.get(header::CONTENT_TYPE))?;
160 let output_format = mime_parser::negotiate_accept(headers.get(header::ACCEPT))?;
161
162 if body.is_empty() {
163 return Err(HttpError::EmptyBody);
164 }
165
166 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
172 let body_len_bytes = body.len() as f64;
173 metrics::histogram!(
174 crate::metrics::METRIC_HTTP_REQUEST_BODY_BYTES,
175 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
176 )
177 .record(body_len_bytes);
178
179 let utf8_prevalidation = match input_format {
180 InputFormat::Markdown | InputFormat::Html => Utf8Prevalidation::Required,
181 InputFormat::Docx => {
182 Utf8Prevalidation::SkippedBinary
184 }
185 _ => {
186 Utf8Prevalidation::SkippedFutureFormat
190 }
191 };
192
193 if matches!(utf8_prevalidation, Utf8Prevalidation::Required) {
194 core::str::from_utf8(&body).map_err(|_error| {
195 tracing::debug!("request body is not valid UTF-8");
196 HttpError::BodyNotUtf8
197 })?;
198 }
199
200 let join_result = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, u64), HttpError> {
201 let mut output_buffer = Vec::new();
202 let body_vec: Vec<u8> = body.into();
203 let reader = docspec::AnyReader::from_reader(input_format, std::io::Cursor::new(body_vec))
204 .map_err(|error| {
205 tracing::debug!(error = %error, "reader construction failed");
206 HttpError::Unprocessable {
207 detail: error.to_string(),
208 }
209 })?;
210 let mut reader = docspec_core::SkipEmptyBlocks::new(reader);
211 let mut sink = docspec::AnyWriter::new(output_format, &mut output_buffer);
212
213 loop {
214 match reader.next_event() {
215 Ok(Some(event)) => sink.handle_event(event).map_err(|error| {
216 tracing::debug!(error = %error, "conversion sink failed");
217 HttpError::Unprocessable {
218 detail: error.to_string(),
219 }
220 })?,
221 Ok(None) => break,
222 Err(error) => {
223 tracing::debug!(error = %error, "reader failed");
224 return Err(HttpError::Unprocessable {
225 detail: error.to_string(),
226 });
227 }
228 }
229 }
230
231 sink.finish().map_err(|error| {
232 tracing::debug!(error = %error, "conversion sink finish failed");
233 HttpError::Internal
234 })?;
235
236 let output_bytes =
239 u64::try_from(output_buffer.len()).map_err(|_conversion_error| HttpError::Internal)?;
240 Ok((output_buffer, output_bytes))
241 })
242 .await;
243
244 let content_type = match output_format {
245 OutputFormat::Blocknote => {
246 HeaderValue::from_static("application/vnd.docspec.blocknote+json; charset=utf-8")
247 }
248 OutputFormat::Html => HeaderValue::from_static("text/html; charset=utf-8"),
249 OutputFormat::Oxa => HeaderValue::from_static("application/vnd.oxa+json; charset=utf-8"),
250 OutputFormat::PandocNative => {
251 HeaderValue::from_static("application/vnd.pandoc.native; charset=utf-8")
252 }
253 _ => HeaderValue::from_static("application/vnd.docspec.blocknote+json; charset=utf-8"),
254 };
255
256 match join_result {
257 Ok(Ok((output, output_bytes))) => Response::builder()
258 .status(StatusCode::OK)
259 .header(header::CONTENT_TYPE, content_type)
260 .body(Body::from(output))
261 .map(|response| (response, output_bytes, output_format))
262 .map_err(|error| {
263 tracing::error!(error = %error, "failed to build conversion response");
264 HttpError::Internal
265 }),
266 Ok(Err(http_error)) => Err(http_error),
267 Err(join_error) => {
268 tracing::error!(error = %join_error, "spawn_blocking join failed");
269 Err(HttpError::Internal)
270 }
271 }
272}