use axum::{ Json, extract::{Multipart, Path, State, multipart::MultipartRejection}, http::HeaderMap, response::IntoResponse, }; use crank_artifacts::MAX_ARTIFACT_BYTES; use serde::Deserialize; use serde_json::{Value, json}; use crate::{ error::ApiError, service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale}, state::AppState, }; #[derive(Deserialize)] pub struct WorkspacePath { pub workspace_id: String, } #[derive(Deserialize)] pub struct WorkspaceImportPath { pub workspace_id: String, pub job_id: String, } pub async fn preview_openapi_import( Path(path): Path, State(state): State, headers: HeaderMap, multipart: Result, ) -> Result, ApiError> { let locale = openapi_upload_locale(&headers); let upload = parse_openapi_upload( multipart.map_err(|rejection| multipart_rejection(locale, rejection))?, locale, ) .await?; let preview = state .service .preview_openapi_import(&path.workspace_id.as_str().into(), upload) .await?; Ok(Json(json!(preview))) } fn multipart_rejection(locale: OpenApiUploadLocale, rejection: MultipartRejection) -> ApiError { if rejection.into_response().status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE { ApiError::openapi_upload(locale, "file_too_large") } else { ApiError::openapi_upload(locale, "malformed_multipart") } } async fn parse_openapi_upload( mut multipart: Multipart, locale: OpenApiUploadLocale, ) -> Result { let mut upload = None; while let Some(field) = multipart .next_field() .await .map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))? { if upload.is_some() || field.name() != Some("file") { return Err(ApiError::openapi_upload(locale, "malformed_multipart")); } let filename = field .file_name() .ok_or_else(|| ApiError::openapi_upload(locale, "invalid_filename"))?; let mime_type = field .content_type() // Axum exposes the raw header value here. Persist only the MIME // essence so parameters and case cannot make route and service // validation disagree. .map(|mime| { mime.split(';') .next() .unwrap_or_default() .trim() .to_ascii_lowercase() }) .ok_or_else(|| ApiError::openapi_upload(locale, "invalid_media_type"))?; if !valid_upload_type(filename, &mime_type) { return Err(ApiError::openapi_upload(locale, "invalid_media_type")); } let mut bytes = Vec::with_capacity(8 * 1024); let mut field = field; while let Some(chunk) = field .chunk() .await .map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))? { if bytes.len().saturating_add(chunk.len()) > MAX_ARTIFACT_BYTES { return Err(ApiError::openapi_upload(locale, "file_too_large")); } bytes.extend_from_slice(&chunk); } if bytes.is_empty() { return Err(ApiError::openapi_upload(locale, "empty_file")); } if std::str::from_utf8(&bytes).is_err() { return Err(ApiError::openapi_upload(locale, "invalid_utf8")); } upload = Some(OpenApiUpload { bytes, mime_type, locale, }); } upload.ok_or_else(|| ApiError::openapi_upload(locale, "missing_file")) } fn valid_upload_type(filename: &str, mime_type: &str) -> bool { let filename = filename.to_ascii_lowercase(); let mime_type = mime_type.to_ascii_lowercase(); match filename.rsplit_once('.') { Some((_, "yaml" | "yml")) => matches!( mime_type.as_str(), "application/yaml" | "application/x-yaml" | "text/yaml" | "text/x-yaml" | "application/octet-stream" ), Some((_, "json")) => matches!( mime_type.as_str(), "application/json" | "application/openapi+json" | "application/octet-stream" ), _ => false, } } fn openapi_upload_locale(headers: &HeaderMap) -> OpenApiUploadLocale { let Some(value) = headers .get("accept-language") .and_then(|value| value.to_str().ok()) else { return OpenApiUploadLocale::En; }; // RFC 9110: highest q wins; ties preserve the header's order. Only the // locales served by this endpoint participate in negotiation. let mut preferred = (0_u16, usize::MAX, OpenApiUploadLocale::En); for (index, range) in value.split(',').enumerate() { let mut parts = range.split(';'); let language = parts.next().unwrap_or_default().trim(); let locale = if language.eq_ignore_ascii_case("ru") || language.to_ascii_lowercase().starts_with("ru-") { Some(OpenApiUploadLocale::Ru) } else if language.eq_ignore_ascii_case("en") || language.to_ascii_lowercase().starts_with("en-") || language == "*" { Some(OpenApiUploadLocale::En) } else { None }; let Some(locale) = locale else { continue }; let quality = match parts .filter_map(|parameter| { let (name, value) = parameter.trim().split_once('=')?; name.eq_ignore_ascii_case("q").then_some(value.trim()) }) .next() { None => Some(1_000_u16), Some(value) => value .parse::() .ok() .filter(|quality| (0.0..=1.0).contains(quality) && *quality > 0.0) .map(|quality| (quality * 1_000.0).round() as u16), }; let Some(quality) = quality else { continue }; if quality > preferred.0 || (quality == preferred.0 && index < preferred.1) { preferred = (quality, index, locale); } } preferred.2 } pub async fn create_openapi_import( Path(path): Path, State(state): State, headers: HeaderMap, Json(payload): Json, ) -> Result, ApiError> { let imported = state .service .create_openapi_import_with_locale( &path.workspace_id.as_str().into(), &path.job_id.as_str().into(), payload, openapi_upload_locale(&headers), ) .await?; Ok(Json(json!(imported))) } #[cfg(test)] mod tests { use axum::http::{HeaderMap, HeaderValue}; use super::*; fn locale(value: &str) -> OpenApiUploadLocale { let mut headers = HeaderMap::new(); headers.insert("accept-language", HeaderValue::from_str(value).unwrap()); openapi_upload_locale(&headers) } #[test] fn accept_language_honours_quality_zero_and_header_order() { assert_eq!(locale("ru;q=0, en;q=0.5"), OpenApiUploadLocale::En); assert_eq!(locale("en;q=0.5, ru;q=0.5"), OpenApiUploadLocale::En); assert_eq!(locale("ru-RU;q=0.9, en;q=1"), OpenApiUploadLocale::En); assert_eq!(locale("ru, en;q=0.5"), OpenApiUploadLocale::Ru); assert_eq!(locale("ru;q=bad, en;q=0.5"), OpenApiUploadLocale::En); } }