From c995b3bf729da11c9ccfc1cd00095da75e5e1af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Tue, 18 Feb 2025 01:55:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=A0=E5=85=A5=E5=B8=A6=E6=9C=89?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E7=B1=BB=E5=9E=8B=E6=B3=A8=E9=87=8A=E7=9A=84?= =?UTF-8?q?=20swagger=20=E6=96=87=E6=A1=A3=20(#257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 145 ++++++++++++++++++++--- Cargo.toml | 3 +- crates/bili_sync/Cargo.toml | 3 +- crates/bili_sync/src/api/auth.rs | 20 +++- crates/bili_sync/src/api/handler.rs | 93 ++++++++++----- crates/bili_sync/src/api/mod.rs | 4 +- crates/bili_sync/src/api/payload.rs | 45 ------- crates/bili_sync/src/api/request.rs | 13 ++ crates/bili_sync/src/api/response.rs | 46 +++++++ crates/bili_sync/src/task/http_server.rs | 23 ++-- 10 files changed, 293 insertions(+), 102 deletions(-) delete mode 100644 crates/bili_sync/src/api/payload.rs create mode 100644 crates/bili_sync/src/api/request.rs create mode 100644 crates/bili_sync/src/api/response.rs diff --git a/Cargo.lock b/Cargo.lock index 161d782..31b7e15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,15 @@ dependencies = [ "backtrace", ] +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -486,9 +495,10 @@ dependencies = [ "tokio-util", "toml", "tower", - "tower-http", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", ] [[package]] @@ -585,9 +595,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.15.4" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "bytecheck" @@ -874,6 +884,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -938,6 +959,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1555,6 +1587,7 @@ checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown 0.15.2", + "serde", ] [[package]] @@ -1676,6 +1709,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + [[package]] name = "log" version = "0.4.22" @@ -2905,6 +2944,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simdutf8" version = "0.1.4" @@ -3502,20 +3547,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-http" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" -dependencies = [ - "bitflags 2.5.0", - "bytes", - "http", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-layer" version = "0.3.3" @@ -3659,6 +3690,55 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "utoipa" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.96", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161166ec520c50144922a625d8bc4925cc801b2dda958ab69878527c0e5c5d61" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "1.8.0" @@ -4087,3 +4167,34 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + +[[package]] +name = "zip" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd56a4d5921bc2f99947ac5b3abe5f510b1be7376fdc5e9fce4a23c6a93e87c" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 1.0.63", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 8eaa4df..581dab6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,9 +67,10 @@ tokio = { version = "1.43.0", features = ["full"] } tokio-util = { version = "0.7.13", features = ["io", "rt"] } toml = "0.8.19" tower = "0.5.2" -tower-http = { version = "0.6.2", features = ["normalize-path"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["chrono"] } +utoipa = { version = "5", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9.0.0", features = ["axum", "vendored"] } [workspace.metadata.release] release = false diff --git a/crates/bili_sync/Cargo.toml b/crates/bili_sync/Cargo.toml index 04bdeb9..71adf1e 100644 --- a/crates/bili_sync/Cargo.toml +++ b/crates/bili_sync/Cargo.toml @@ -47,9 +47,10 @@ tokio = { workspace = true } tokio-util = { workspace = true } toml = { workspace = true } tower = { workspace = true } -tower-http = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +utoipa = { workspace = true } +utoipa-swagger-ui = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } diff --git a/crates/bili_sync/src/api/auth.rs b/crates/bili_sync/src/api/auth.rs index 2281997..66f978b 100644 --- a/crates/bili_sync/src/api/auth.rs +++ b/crates/bili_sync/src/api/auth.rs @@ -3,11 +3,13 @@ use axum::http::HeaderMap; use axum::middleware::Next; use axum::response::Response; use reqwest::StatusCode; +use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; +use utoipa::Modify; use crate::config::CONFIG; pub async fn auth(headers: HeaderMap, request: Request, next: Next) -> Result { - if request.uri().path().starts_with("/api") && get_token(&headers) != CONFIG.auth_token { + if request.uri().path().starts_with("/api/") && get_token(&headers) != CONFIG.auth_token { return Err(StatusCode::UNAUTHORIZED); } Ok(next.run(request).await) @@ -19,3 +21,19 @@ fn get_token(headers: &HeaderMap) -> Option { .and_then(|v| v.to_str().ok()) .map(Into::into) } + +pub struct OpenAPIAuth; + +impl Modify for OpenAPIAuth { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(schema) = openapi.components.as_mut() { + schema.add_security_scheme( + "Token", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::with_description( + "Authorization", + "与配置文件中的 auth_token 相同", + ))), + ); + } + } +} diff --git a/crates/bili_sync/src/api/handler.rs b/crates/bili_sync/src/api/handler.rs index 4bcc44d..29607e4 100644 --- a/crates/bili_sync/src/api/handler.rs +++ b/crates/bili_sync/src/api/handler.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::sync::Arc; use anyhow::{anyhow, Result}; @@ -7,71 +6,100 @@ use axum::Json; use bili_sync_entity::*; use bili_sync_migration::Expr; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect}; +use utoipa::OpenApi; +use crate::api::auth::OpenAPIAuth; use crate::api::error::ApiError; -use crate::api::payload::{PageInfo, VideoDetail, VideoInfo, VideoList, VideoListModel, VideoListModelItem}; +use crate::api::request::VideosRequest; +use crate::api::response::{PageInfo, VideoInfo, VideoResponse, VideoSource, VideoSourcesResponse, VideosResponse}; -/// 列出所有视频列表 -pub async fn get_video_list_models( +#[derive(OpenApi)] +#[openapi( + paths(get_video_sources, get_videos, get_video), + modifiers(&OpenAPIAuth), + security( + ("Token" = []), + ) +)] +pub struct ApiDoc; + +/// 列出所有视频来源 +#[utoipa::path( + get, + path = "/api/video-sources", + responses( + (status = 200, body = VideoSourcesResponse), + ) +)] +pub async fn get_video_sources( Extension(db): Extension>, -) -> Result, ApiError> { - Ok(Json(VideoListModel { +) -> Result, ApiError> { + Ok(Json(VideoSourcesResponse { collection: collection::Entity::find() .select_only() .columns([collection::Column::Id, collection::Column::Name]) - .into_model::() + .into_model::() .all(db.as_ref()) .await?, favorite: favorite::Entity::find() .select_only() .columns([favorite::Column::Id, favorite::Column::Name]) - .into_model::() + .into_model::() .all(db.as_ref()) .await?, submission: submission::Entity::find() .select_only() .column(submission::Column::Id) .column_as(submission::Column::UpperName, "name") - .into_model::() + .into_model::() .all(db.as_ref()) .await?, watch_later: watch_later::Entity::find() .select_only() .column(watch_later::Column::Id) .column_as(Expr::value("稍后再看"), "name") - .into_model::() + .into_model::() .all(db.as_ref()) .await?, })) } -/// 列出所有视频的基本信息(支持根据视频列表筛选,支持分页) -pub async fn list_videos( +/// 列出视频的基本信息,支持根据视频来源筛选、名称查找和分页 +#[utoipa::path( + get, + path = "/api/videos", + params( + VideosRequest, + ), + responses( + (status = 200, body = VideosResponse), + ) +)] +pub async fn get_videos( Extension(db): Extension>, - Query(params): Query>, -) -> Result, ApiError> { + Query(params): Query, +) -> Result, ApiError> { let mut query = video::Entity::find(); - for (query_key, filter_column) in [ - ("collection", video::Column::CollectionId), - ("favorite", video::Column::FavoriteId), - ("submission", video::Column::SubmissionId), - ("watch_later", video::Column::WatchLaterId), + for (field, column) in [ + (params.collection, video::Column::CollectionId), + (params.favorite, video::Column::FavoriteId), + (params.submission, video::Column::SubmissionId), + (params.watch_later, video::Column::WatchLaterId), ] { - if let Some(value) = params.get(query_key) { - query = query.filter(filter_column.eq(value)); - break; + if let Some(id) = field { + query = query.filter(column.eq(id)); } } - if let Some(query_word) = params.get("q") { + if let Some(query_word) = params.query { query = query.filter(video::Column::Name.contains(query_word)); } let total_count = query.clone().count(db.as_ref()).await?; - let (page, page_size) = if let (Some(page), Some(page_size)) = (params.get("page"), params.get("page_size")) { - (page.parse::()?, page_size.parse::()?) + let (page, page_size) = if let (Some(page), Some(page_size)) = (params.page, params.page_size) { + (page, page_size) } else { (1, 10) }; - Ok(Json(VideoList { + Ok(Json(VideosResponse { videos: query .order_by_desc(video::Column::Id) .into_partial_model::() @@ -82,11 +110,18 @@ pub async fn list_videos( })) } -/// 根据 id 获取视频详细信息,包括关联的所有 page +/// 获取视频详细信息,包括关联的所有 page +#[utoipa::path( + get, + path = "/api/videos/{id}", + responses( + (status = 200, body = VideoResponse), + ) +)] pub async fn get_video( Path(id): Path, Extension(db): Extension>, -) -> Result, ApiError> { +) -> Result, ApiError> { let video_info = video::Entity::find_by_id(id) .into_partial_model::() .one(db.as_ref()) @@ -100,7 +135,7 @@ pub async fn get_video( .into_partial_model::() .all(db.as_ref()) .await?; - Ok(Json(VideoDetail { + Ok(Json(VideoResponse { video: video_info, pages, })) diff --git a/crates/bili_sync/src/api/mod.rs b/crates/bili_sync/src/api/mod.rs index 348cf01..0bf83a6 100644 --- a/crates/bili_sync/src/api/mod.rs +++ b/crates/bili_sync/src/api/mod.rs @@ -1,4 +1,6 @@ pub mod auth; pub mod error; pub mod handler; -pub mod payload; + +mod request; +mod response; diff --git a/crates/bili_sync/src/api/payload.rs b/crates/bili_sync/src/api/payload.rs deleted file mode 100644 index b4e9259..0000000 --- a/crates/bili_sync/src/api/payload.rs +++ /dev/null @@ -1,45 +0,0 @@ -use bili_sync_entity::*; -use sea_orm::{DerivePartialModel, FromQueryResult}; -use serde::Serialize; - -#[derive(FromQueryResult, Serialize)] -pub struct VideoListModelItem { - id: i32, - name: String, -} - -#[derive(Serialize)] -pub struct VideoListModel { - pub collection: Vec, - pub favorite: Vec, - pub submission: Vec, - pub watch_later: Vec, -} - -#[derive(DerivePartialModel, FromQueryResult, Serialize)] -#[sea_orm(entity = "video::Entity")] -pub struct VideoInfo { - id: i32, - name: String, - upper_name: String, -} - -#[derive(Serialize)] -pub struct VideoList { - pub videos: Vec, - pub total_count: u64, -} - -#[derive(DerivePartialModel, FromQueryResult, Serialize)] -#[sea_orm(entity = "page::Entity")] -pub struct PageInfo { - id: i32, - pid: i32, - name: String, -} - -#[derive(Serialize)] -pub struct VideoDetail { - pub video: VideoInfo, - pub pages: Vec, -} diff --git a/crates/bili_sync/src/api/request.rs b/crates/bili_sync/src/api/request.rs new file mode 100644 index 0000000..defb54d --- /dev/null +++ b/crates/bili_sync/src/api/request.rs @@ -0,0 +1,13 @@ +use serde::Deserialize; +use utoipa::IntoParams; + +#[derive(Deserialize, IntoParams)] +pub struct VideosRequest { + pub collection: Option, + pub favorite: Option, + pub submission: Option, + pub watch_later: Option, + pub query: Option, + pub page: Option, + pub page_size: Option, +} diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs new file mode 100644 index 0000000..8c34eb4 --- /dev/null +++ b/crates/bili_sync/src/api/response.rs @@ -0,0 +1,46 @@ +use bili_sync_entity::*; +use sea_orm::{DerivePartialModel, FromQueryResult}; +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +pub struct VideoSourcesResponse { + pub collection: Vec, + pub favorite: Vec, + pub submission: Vec, + pub watch_later: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct VideosResponse { + pub videos: Vec, + pub total_count: u64, +} + +#[derive(Serialize, ToSchema)] +pub struct VideoResponse { + pub video: VideoInfo, + pub pages: Vec, +} + +#[derive(FromQueryResult, Serialize, ToSchema)] +pub struct VideoSource { + id: i32, + name: String, +} + +#[derive(DerivePartialModel, FromQueryResult, Serialize, ToSchema)] +#[sea_orm(entity = "page::Entity")] +pub struct PageInfo { + id: i32, + pid: i32, + name: String, +} + +#[derive(DerivePartialModel, FromQueryResult, Serialize, ToSchema)] +#[sea_orm(entity = "video::Entity")] +pub struct VideoInfo { + id: i32, + name: String, + upper_name: String, +} diff --git a/crates/bili_sync/src/task/http_server.rs b/crates/bili_sync/src/task/http_server.rs index b03f651..bcf7621 100644 --- a/crates/bili_sync/src/task/http_server.rs +++ b/crates/bili_sync/src/task/http_server.rs @@ -9,11 +9,11 @@ use axum::{middleware, Extension, Router, ServiceExt}; use reqwest::StatusCode; use rust_embed::Embed; use sea_orm::DatabaseConnection; -use tower::Layer; -use tower_http::normalize_path::NormalizePathLayer; +use utoipa::OpenApi; +use utoipa_swagger_ui::{Config, SwaggerUi}; use crate::api::auth; -use crate::api::handler::{get_video, get_video_list_models, list_videos}; +use crate::api::handler::{get_video, get_video_sources, get_videos, ApiDoc}; use crate::config::CONFIG; #[derive(Embed)] @@ -22,13 +22,22 @@ struct Asset; pub async fn http_server(database_connection: Arc) -> Result<()> { let app = Router::new() - .route("/api/videos", get(list_videos)) - .route("/api/videos/{video_id}", get(get_video)) - .route("/api/video-list-models", get(get_video_list_models)) + .route("/api/video-sources", get(get_video_sources)) + .route("/api/videos", get(get_videos)) + .route("/api/video/{id}", get(get_video)) + .merge( + SwaggerUi::new("/swagger-ui/") + .url("/api-docs/openapi.json", ApiDoc::openapi()) + .config( + Config::default() + .try_it_out_enabled(true) + .persist_authorization(true) + .validator_url("none"), + ), + ) .fallback_service(get(frontend_files)) .layer(Extension(database_connection)) .layer(middleware::from_fn(auth::auth)); - let app = NormalizePathLayer::trim_trailing_slash().layer(app); let listener = tokio::net::TcpListener::bind(&CONFIG.bind_address) .await .context("bind address failed")?;