feat(openapi): complete upload preview and UI evidence
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
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, OpenApiImportPreviewPayload},
|
||||
service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -25,15 +28,118 @@ pub struct WorkspaceImportPath {
|
||||
pub async fn preview_openapi_import(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OpenApiImportPreviewPayload>,
|
||||
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(), payload)
|
||||
.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()
|
||||
.map(ToString::to_string)
|
||||
.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 prefers_russian = headers
|
||||
.get("accept-language")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| {
|
||||
value.split(',').any(|range| {
|
||||
let language = range.split(';').next().unwrap_or_default().trim();
|
||||
language.eq_ignore_ascii_case("ru")
|
||||
|| language.to_ascii_lowercase().starts_with("ru-")
|
||||
})
|
||||
});
|
||||
if prefers_russian {
|
||||
OpenApiUploadLocale::Ru
|
||||
} else {
|
||||
OpenApiUploadLocale::En
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_openapi_import(
|
||||
Path(path): Path<WorkspaceImportPath>,
|
||||
State(state): State<AppState>,
|
||||
|
||||
Reference in New Issue
Block a user