docspec_http/handlers/
conversion.rs1use axum::{
4 body::{Body, Bytes},
5 http::{header, HeaderMap, HeaderValue, Response, StatusCode},
6 response::IntoResponse,
7};
8use docspec_blocknote_writer::BlockNoteWriter;
9use docspec_core::{EventSink as _, EventSource as _, StackTrackingSink};
10use docspec_markdown_reader::MarkdownReader;
11
12use crate::{error::HttpError, mime_parser};
13
14#[allow(clippy::unused_async)]
16#[inline]
18pub async fn options_conversion() -> impl IntoResponse {
19 (
20 StatusCode::NO_CONTENT,
21 [(header::ALLOW, HeaderValue::from_static("POST, OPTIONS"))],
22 )
23}
24
25#[inline]
56pub async fn post_conversion(
57 request_id: Option<axum::extract::Extension<tower_http::request_id::RequestId>>,
58 headers: HeaderMap,
59 body: Bytes,
60) -> Result<Response<Body>, HttpError> {
61 let input_mime_label = crate::mime_parser::bucket_input_mime(headers.get(header::CONTENT_TYPE));
62 let trace_id_owned: Option<String> = headers
63 .get(axum::http::HeaderName::from_static("x-trace-id"))
64 .and_then(|header_value| header_value.to_str().ok())
65 .map(str::to_owned);
66 let body_len_for_logging = body.len();
67
68 let conversion_start = std::time::Instant::now();
69 let outcome = do_conversion(input_mime_label, headers, body).await;
70 let conversion_duration = conversion_start.elapsed();
71 let conversion_duration_secs = conversion_duration.as_secs_f64();
72 let conversion_duration_ms =
73 u64::try_from(conversion_duration.as_millis().min(u128::from(u64::MAX)))
74 .unwrap_or(u64::MAX);
75
76 let (response_or_error, output_bytes) = match outcome {
77 Ok((response, bytes)) => (Ok(response), bytes),
78 Err(http_error) => (Err(http_error), 0),
79 };
80 let conversion_ok = response_or_error.is_ok();
81 let output_mime_label = crate::mime_parser::bucket_output_mime(conversion_ok);
82
83 let (result_label, error_class_label) = match &response_or_error {
84 Ok(_) => (
85 crate::metrics::RESULT_SUCCESS,
86 crate::metrics::ERROR_CLASS_NONE,
87 ),
88 Err(http_error) => (http_error.result_class(), http_error.error_class()),
89 };
90
91 metrics::counter!(
92 crate::metrics::METRIC_CONVERSIONS_TOTAL,
93 crate::metrics::LABEL_RESULT => result_label,
94 crate::metrics::LABEL_ERROR_CLASS => error_class_label,
95 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
96 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
97 )
98 .increment(1);
99
100 metrics::histogram!(
101 crate::metrics::METRIC_CONVERSION_DURATION_SECONDS,
102 crate::metrics::LABEL_RESULT => result_label,
103 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
104 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
105 )
106 .record(conversion_duration_secs);
107
108 if conversion_ok {
109 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
111 let output_bytes_f64 = output_bytes as f64;
112 metrics::histogram!(
113 crate::metrics::METRIC_CONVERSION_OUTPUT_BYTES,
114 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
115 crate::metrics::LABEL_OUTPUT_MIME_TYPE => output_mime_label,
116 )
117 .record(output_bytes_f64);
118 }
119
120 let request_id_opt: Option<&str> = request_id
121 .as_ref()
122 .and_then(|axum::extract::Extension(req_id)| req_id.header_value().to_str().ok());
123 tracing::info!(
124 event = "conversion_completed",
125 result = result_label,
126 error_class = error_class_label,
127 input_mime_type = input_mime_label,
128 output_mime_type = output_mime_label,
129 input_bytes = body_len_for_logging,
130 output_bytes,
131 duration_ms = conversion_duration_ms,
132 request_id = request_id_opt,
133 trace_id = trace_id_owned.as_deref(),
134 );
135
136 response_or_error
137}
138
139async fn do_conversion(
144 input_mime_label: &'static str,
145 headers: HeaderMap,
146 body: Bytes,
147) -> Result<(Response<Body>, u64), HttpError> {
148 mime_parser::validate_content_type(headers.get(header::CONTENT_TYPE))?;
149 mime_parser::negotiate_accept(headers.get(header::ACCEPT))?;
150
151 if body.is_empty() {
152 return Err(HttpError::EmptyBody);
153 }
154
155 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
161 let body_len_bytes = body.len() as f64;
162 metrics::histogram!(
163 crate::metrics::METRIC_HTTP_REQUEST_BODY_BYTES,
164 crate::metrics::LABEL_INPUT_MIME_TYPE => input_mime_label,
165 )
166 .record(body_len_bytes);
167
168 let markdown = String::from_utf8(body.into()).map_err(|error| {
169 tracing::debug!(error = %error, "request body is not valid UTF-8");
170 HttpError::BodyNotUtf8
171 })?;
172
173 let join_result = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, u64), HttpError> {
174 let mut output_buffer = Vec::new();
175 let mut reader = MarkdownReader::new(&markdown);
176 let mut sink = StackTrackingSink::new(BlockNoteWriter::new(&mut output_buffer));
177
178 loop {
179 match reader.next_event() {
180 Ok(Some(event)) => sink.handle_event(event).map_err(|error| {
181 tracing::debug!(error = %error, "conversion sink failed");
182 HttpError::Unprocessable {
183 detail: error.to_string(),
184 }
185 })?,
186 Ok(None) => break,
187 Err(error) => {
188 tracing::debug!(error = %error, "markdown reader failed");
189 return Err(HttpError::Unprocessable {
190 detail: error.to_string(),
191 });
192 }
193 }
194 }
195
196 sink.finish().map_err(|error| {
197 tracing::debug!(error = %error, "conversion sink finish failed");
198 HttpError::Internal
199 })?;
200
201 let output_bytes =
204 u64::try_from(output_buffer.len()).map_err(|_conversion_error| HttpError::Internal)?;
205 Ok((output_buffer, output_bytes))
206 })
207 .await;
208
209 match join_result {
210 Ok(Ok((output, output_bytes))) => Response::builder()
211 .status(StatusCode::OK)
212 .header(
213 header::CONTENT_TYPE,
214 HeaderValue::from_static("application/vnd.docspec.blocknote+json; charset=utf-8"),
215 )
216 .body(Body::from(output))
217 .map(|response| (response, output_bytes))
218 .map_err(|error| {
219 tracing::error!(error = %error, "failed to build conversion response");
220 HttpError::Internal
221 }),
222 Ok(Err(http_error)) => Err(http_error),
223 Err(join_error) => {
224 tracing::error!(error = %join_error, "spawn_blocking join failed");
225 Err(HttpError::Internal)
226 }
227 }
228}