feat(backend): add swagger-ui endpoint via utoipa
This commit is contained in:
@@ -24,14 +24,16 @@ use tower_http::{
|
||||
};
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
use crate::{
|
||||
model::{FileRepository, UserRepository},
|
||||
web::{
|
||||
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
||||
routes_file::routes_file,
|
||||
routes_health::routes_health,
|
||||
routes_login::routes_auth,
|
||||
routes_file::{FileApi, routes_file},
|
||||
routes_health::{HealthApi, routes_health},
|
||||
routes_login::{AuthApi, routes_auth},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -84,10 +86,15 @@ async fn main() -> Result<()> {
|
||||
routes_auth = routes_auth.layer(GovernorLayer::new(governor_conf_auth));
|
||||
}
|
||||
|
||||
let mut openapi = AuthApi::openapi();
|
||||
openapi.merge(FileApi::openapi());
|
||||
openapi.merge(HealthApi::openapi());
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", routes_file)
|
||||
.nest("/api/auth", routes_auth)
|
||||
.merge(routes_health())
|
||||
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
user_repository,
|
||||
|
||||
@@ -6,7 +6,7 @@ use sqlx::{PgPool, prelude::FromRow};
|
||||
|
||||
use crate::error::{LoftError, Result};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, FromRow)]
|
||||
#[derive(Clone, Debug, Serialize, FromRow, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileRecord {
|
||||
pub id: i64,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
use axum::{Router, routing::get};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(paths(health))]
|
||||
pub struct HealthApi;
|
||||
|
||||
pub fn routes_health() -> Router {
|
||||
Router::new().route("/health", get(health))
|
||||
}
|
||||
|
||||
/// Health check
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
tag = "health",
|
||||
responses((status = 200, description = "Service is healthy"))
|
||||
)]
|
||||
async fn health() -> &'static str {
|
||||
"up"
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ use argon2::{
|
||||
};
|
||||
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
|
||||
use rand::RngExt;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
use tracing::warn;
|
||||
use utoipa::{
|
||||
Modify, OpenApi,
|
||||
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{LoftError, Result},
|
||||
@@ -14,6 +18,32 @@ use crate::{
|
||||
web::AUTH_TOKEN,
|
||||
};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "loft API",
|
||||
description = "Self-hosted media storage (Axum + SvelteKit)"
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
paths(login, logout, register),
|
||||
components(schemas(LoginPayload))
|
||||
)]
|
||||
pub struct AuthApi;
|
||||
|
||||
/// Registers the session-cookie auth scheme so protected endpoints show as secured.
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"cookie_auth",
|
||||
SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(AUTH_TOKEN))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes_auth(user_repository: UserRepository) -> Router {
|
||||
Router::new()
|
||||
.route("/login", post(login))
|
||||
@@ -22,6 +52,18 @@ pub fn routes_auth(user_repository: UserRepository) -> Router {
|
||||
.with_state(user_repository)
|
||||
}
|
||||
|
||||
/// Log in and start a session
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/login",
|
||||
tag = "auth",
|
||||
request_body = LoginPayload,
|
||||
responses(
|
||||
(status = 200, description = "Login successful"),
|
||||
(status = 401, description = "Login failed"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
async fn login(
|
||||
State(user_repository): State<UserRepository>,
|
||||
cookies: Cookies,
|
||||
@@ -50,6 +92,17 @@ async fn login(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Log out and clear the session
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/logout",
|
||||
tag = "auth",
|
||||
// request_body = LoginPayload,
|
||||
responses(
|
||||
(status = 200, description = "Logout successful"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
async fn logout(
|
||||
State(user_repository): State<UserRepository>,
|
||||
cookies: Cookies,
|
||||
@@ -64,6 +117,18 @@ async fn logout(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Register a new account
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/register",
|
||||
tag = "auth",
|
||||
// request_body = LoginPayload,
|
||||
responses(
|
||||
(status = 201, description = "Registration successful"),
|
||||
(status = 401, description = "Registration failed"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
async fn register(
|
||||
State(user_repository): State<UserRepository>,
|
||||
Json(payload): Json<LoginPayload>,
|
||||
@@ -76,7 +141,7 @@ async fn register(
|
||||
warn!(
|
||||
"Register fail, username {} already exists",
|
||||
&payload.username
|
||||
); // also fix "Login fail" typo
|
||||
);
|
||||
return Err(LoftError::RegisterFail);
|
||||
}
|
||||
|
||||
@@ -108,7 +173,7 @@ fn create_cookie() -> Cookie<'static> {
|
||||
.build()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
struct LoginPayload {
|
||||
username: String,
|
||||
password: String,
|
||||
|
||||
Reference in New Issue
Block a user