feat(backend): add swagger-ui endpoint via utoipa

This commit is contained in:
2026-06-24 19:22:39 +03:00
parent d153e1c251
commit a40c1f7396
7 changed files with 373 additions and 11 deletions

View File

@@ -7,6 +7,7 @@ use axum::{
routing::get,
};
use sqlx::types::uuid;
use utoipa::OpenApi;
use crate::{
ctx::Ctx,
@@ -14,6 +15,20 @@ use crate::{
model::{FileRecord, FileRepository},
};
#[derive(OpenApi)]
#[openapi(
paths(
list_files,
upload_file,
get_file,
delete_file,
download_file,
stream_part
),
components(schemas(FileRecord))
)]
pub struct FileApi;
pub fn routes_file(file_repository: FileRepository) -> Router {
Router::new()
.route("/files", get(list_files).post(upload_file))
@@ -23,11 +38,25 @@ pub fn routes_file(file_repository: FileRepository) -> Router {
.with_state(file_repository)
}
/// Upload a file
#[utoipa::path(
post,
path = "/api/files",
tag = "files",
security(("cookie_auth" = [])),
request_body(content_type = "multipart/form-data", description = "Multipart form with a `file` field"),
responses(
(status = 201, description = "Uploaded file metadata", body = FileRecord),
(status = 400, description = "No file provided or malformed upload"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn upload_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
mut multipart: Multipart,
) -> Result<Json<FileRecord>> {
) -> Result<(StatusCode, Json<FileRecord>)> {
let mut uploaded: Option<(String, String, usize)> = None;
while let Some(field) = multipart.next_field().await? {
@@ -45,9 +74,23 @@ async fn upload_file(
.create_file_record(ctx.user_id(), &name, size, &key)
.await?;
Ok(Json(file_record))
Ok((StatusCode::CREATED, Json(file_record)))
}
/// Get a file's metadata
#[utoipa::path(
get,
path = "/api/files/{id}",
tag = "files",
security(("cookie_auth" = [])),
params(("id" = u64, Path, description = "File id")),
responses(
(status = 200, description = "File metadata", body = FileRecord),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn get_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -59,6 +102,20 @@ async fn get_file(
Ok(Json(record))
}
/// Download a file
#[utoipa::path(
get,
path = "/api/files/{id}/download",
tag = "files",
security(("cookie_auth" = [])),
params(("id" = u64, Path, description = "File id")),
responses(
(status = 200, description = "File bytes", content_type = "application/octet-stream"),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn download_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -70,6 +127,24 @@ async fn download_file(
Ok(Body::from_stream(stream))
}
/// Stream a byte range of a file (HTTP 206)
#[utoipa::path(
get,
path = "/api/files/{id}/stream_part",
tag = "files",
security(("cookie_auth" = [])),
params(
("id" = u64, Path, description = "File id"),
("Range" = Option<String>, Header, description = "Byte range, e.g. bytes=0-1023")
),
responses(
(status = 206, description = "Partial content"),
(status = 400, description = "Invalid range"),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn stream_part(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -117,6 +192,20 @@ fn parse_range(headers: &HeaderMap, file_size: u64) -> Result<(u64, u64)> {
Ok((start, end))
}
/// Delete a file
#[utoipa::path(
delete,
path = "/api/files/{id}",
tag = "files",
security(("cookie_auth" = [])),
params(("id" = u64, Path, description = "File id")),
responses(
(status = 200, description = "Deleted file metadata", body = FileRecord),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn delete_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -128,6 +217,18 @@ async fn delete_file(
Ok(Json(file))
}
/// List the current user's files
#[utoipa::path(
get,
path = "/api/files",
tag = "files",
security(("cookie_auth" = [])),
responses(
(status = 200, description = "List user's files", body = Vec<FileRecord>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn list_files(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -138,7 +239,11 @@ async fn list_files(
#[cfg(test)]
mod tests {
use axum::{Router, http::header, middleware};
use axum::{
Router,
http::{StatusCode, header},
middleware,
};
use axum_test::{
TestServer,
multipart::{MultipartForm, Part},
@@ -284,7 +389,7 @@ mod tests {
))
.await;
res.assert_status_ok();
res.assert_status(StatusCode::CREATED);
let file = res.json::<serde_json::Value>();
assert_eq!(file["name"], "a.jpg");