222 lines
7.4 KiB
Rust
222 lines
7.4 KiB
Rust
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<WorkspacePath>,
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
multipart: Result<Multipart, MultipartRejection>,
|
|
) -> Result<Json<Value>, 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<OpenApiUpload, ApiError> {
|
|
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::<f32>()
|
|
.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<WorkspaceImportPath>,
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(payload): Json<OpenApiImportCreatePayload>,
|
|
) -> Result<Json<Value>, 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);
|
|
}
|
|
}
|