Write OpenAPI documentation

This commit is contained in:
roaming97 2025-04-17 17:30:04 -06:00
parent 9247a39094
commit bc3469fa88
12 changed files with 323 additions and 43 deletions

209
Cargo.lock generated
View File

@ -49,7 +49,17 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"uuid", "utoipa",
"utoipa-swagger-ui",
]
[[package]]
name = "arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
dependencies = [
"derive_arbitrary",
] ]
[[package]] [[package]]
@ -178,6 +188,12 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bumpalo"
version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@ -244,6 +260,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@ -280,6 +305,17 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[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",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -362,6 +398,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "flate2"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@ -792,6 +838,7 @@ checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown",
"serde",
] ]
[[package]] [[package]]
@ -863,6 +910,12 @@ dependencies = [
"scopeguard", "scopeguard",
] ]
[[package]]
name = "lockfree-object-pool"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.27" version = "0.4.27"
@ -897,6 +950,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.8" version = "0.8.8"
@ -1199,6 +1262,40 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rust-embed"
version = "8.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fbc0ee50fcb99af7cebb442e5df7b5b45e9460ffa3f8f549cd26b862bec49d"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bf418c9a2e3f6663ca38b8a7134cc2c2167c9d69688860e8961e3faa731702e"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d55b95147fe01265d06b3955db798bdaed52e60e2211c41137701b3aba8e21"
dependencies = [
"sha2",
"walkdir",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
@ -1230,6 +1327,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -1365,6 +1471,12 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -1886,6 +1998,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@ -1937,12 +2055,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "uuid" name = "utoipa"
version = "1.16.0" version = "5.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0"
dependencies = [ dependencies = [
"getrandom 0.3.2", "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",
]
[[package]]
name = "utoipa-swagger-ui"
version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29519b3c485df6b13f4478ac909a491387e9ef70204487c3b64b53749aec0be"
dependencies = [
"axum",
"base64",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"url",
"utoipa",
"zip",
] ]
[[package]] [[package]]
@ -1963,6 +2114,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.0+wasi-snapshot-preview1" version = "0.11.0+wasi-snapshot-preview1"
@ -2010,6 +2171,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
@ -2286,3 +2456,32 @@ dependencies = [
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "zip"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"flate2",
"indexmap",
"memchr",
"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",
]

View File

@ -22,4 +22,5 @@ toml = "0.8.20"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
url = "2.5.4" url = "2.5.4"
uuid = { version = "1.16.0", features = ["v4"] } utoipa = { version = "5.3.1", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.1", features = ["axum"] }

View File

@ -4,5 +4,10 @@ Tasks that have been completed in commits before this one have been omitted from
- [x] /video PUT route - [x] /video PUT route
- [x] /channel PUT route - [x] /channel PUT route
- [ ] OpenAPI documentation - [x] OpenAPI documentation
## Planned features
- [ ] Video versioning - [ ] Video versioning
- [ ] Subtitles support
- [ ] Cookies support

View File

@ -6,6 +6,7 @@ use thiserror::Error;
use tokio::{fs, process::Command}; use tokio::{fs, process::Command};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use url::Url; use url::Url;
use utoipa::ToSchema;
use crate::string::ToUnquotedString; use crate::string::ToUnquotedString;
@ -29,7 +30,7 @@ pub enum ChannelError {
JsonKey(String), JsonKey(String),
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, ToSchema)]
pub struct Channel { pub struct Channel {
pub id: i64, pub id: i64,
pub url: String, pub url: String,

View File

@ -5,6 +5,7 @@ use serde_json::Value;
use thiserror::Error; use thiserror::Error;
use tokio::fs; use tokio::fs;
use tracing::{error, warn}; use tracing::{error, warn};
use utoipa::ToSchema;
use crate::string::ToUnquotedString; use crate::string::ToUnquotedString;
@ -19,7 +20,7 @@ pub enum CommentsError {
} }
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, ToSchema)]
pub struct Comment { pub struct Comment {
pub id: String, pub id: String,
pub video_id: String, pub video_id: String,

45
src/doc.rs Executable file
View File

@ -0,0 +1,45 @@
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
// Because apparently the OpenApi macro can't import these on its own
// if the routes are not in the same location as the router
use crate::routes::{
__path_index,
channel::{
__path_delete_channel, __path_get_channel, __path_get_channel_videos, __path_list_channels,
__path_update_channel, __path_upload_channel,
},
comment::__path_get_video_comments,
video::{
__path_delete_video, __path_get_video, __path_list_videos, __path_update_video,
__path_upload_video,
},
};
#[derive(OpenApi)]
#[openapi(
paths(
index,
list_channels,
list_videos,
upload_video,
upload_channel,
update_channel,
update_video,
delete_channel,
delete_video,
get_video,
get_channel,
get_video_comments,
get_channel_videos
),
info(
title = "Almond API",
description = "Interface to archive YouTube videos."
)
)]
struct ApiDoc;
pub fn docs_router() -> SwaggerUi {
SwaggerUi::new("/docs").url("/api-doc/openapi.json", ApiDoc::openapi())
}

View File

@ -4,13 +4,18 @@ use axum::{
Router, Router,
routing::{delete, get, post, put}, routing::{delete, get, post, put},
}; };
use doc::docs_router;
use instance::Instance; use instance::Instance;
use routes::{ use routes::{
channel::{delete_channel, get_channel, list_channels, update_channel, upload_channel}, channel::{
delete_channel, get_channel, get_channel_videos, list_channels, update_channel,
upload_channel,
},
comment::get_video_comments, comment::get_video_comments,
index, index,
middleware::auth, middleware::auth,
video::{delete_video, get_channel_videos, get_video, list_videos, update_video, upload_video}, video::{delete_video, get_video, list_videos, update_video, upload_video},
}; };
use tokio::signal; use tokio::signal;
use tracing::info; use tracing::info;
@ -18,6 +23,7 @@ use tracing::info;
mod channel; mod channel;
mod comment; mod comment;
mod config; mod config;
mod doc;
mod instance; mod instance;
mod routes; mod routes;
mod string; mod string;
@ -60,7 +66,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/channel/{id}", delete(delete_channel)) .route("/channel/{id}", delete(delete_channel))
.route_layer(axum::middleware::from_fn_with_state(instance.clone(), auth)); .route_layer(axum::middleware::from_fn_with_state(instance.clone(), auth));
let almond = router.merge(protected).with_state(instance); let almond = router
.merge(protected)
.merge(docs_router())
.with_state(instance);
let listener = tokio::net::TcpListener::bind(address).await?; let listener = tokio::net::TcpListener::bind(address).await?;

View File

@ -6,19 +6,21 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, info}; use tracing::{error, info};
use url::Url; use url::Url;
use utoipa::ToSchema;
use crate::{ use crate::{
channel::{Channel, ChannelError}, channel::{Channel, ChannelError},
instance::Instance, instance::Instance,
url::is_youtube_url, url::is_youtube_url,
video::Video,
}; };
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct ListChannelsQuery { pub struct ListChannelsQuery {
page: Option<usize>, page: Option<usize>,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct ListChannelsResponse { pub struct ListChannelsResponse {
channels: Vec<Channel>, channels: Vec<Channel>,
page: usize, page: usize,
@ -28,6 +30,7 @@ pub struct ListChannelsResponse {
} }
/// Retrieve video list as JSON (paged) /// Retrieve video list as JSON (paged)
#[utoipa::path(get, path = "/channel", request_body = ListChannelsQuery, params(("page" = Option<usize>, Query)), tags = ["channel"], responses((status = 200, body = ListChannelsResponse), (status = 500, description = "Error fetching channels from database")))]
pub async fn list_channels( pub async fn list_channels(
State(state): State<Instance>, State(state): State<Instance>,
Query(query): Query<ListChannelsQuery>, Query(query): Query<ListChannelsQuery>,
@ -64,6 +67,7 @@ pub async fn list_channels(
} }
/// Get a single channel from the database by its ID /// Get a single channel from the database by its ID
#[utoipa::path(get, path = "/channel/{id}", tags = ["channel"], responses((status = 200, body = Channel), (status = 404, description = "Not found"), (status = 500, description = "Failed to get channel from database")))]
pub async fn get_channel( pub async fn get_channel(
State(state): State<Instance>, State(state): State<Instance>,
Path(id): Path<String>, Path(id): Path<String>,
@ -76,12 +80,31 @@ pub async fn get_channel(
}) })
} }
#[derive(Deserialize)] /// Get all videos belonging to a channel ID from the database
#[utoipa::path(get, path = "/channel/{id}/videos", tags = ["channel"], responses((status = 200, body = Vec<Video>), (status = 404, description = "Not found"), (status = 500, description = "Error fetching videos from database")))]
pub async fn get_channel_videos(
State(state): State<Instance>,
Path(id): Path<String>,
) -> Result<Json<Vec<Video>>, StatusCode> {
sqlx::query_as!(Video, "SELECT * FROM video WHERE author_id = ?", id)
.fetch_all(&state.pool)
.await
.map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |videos| {
if videos.is_empty() {
Err(StatusCode::NOT_FOUND)
} else {
Ok(Json(videos))
}
})
}
#[derive(Deserialize, ToSchema)]
pub struct UploadChannelQuery { pub struct UploadChannelQuery {
url: String, url: String,
} }
/// Upload a channel's metadata to the database /// Upload a channel's metadata to the database
#[utoipa::path(post, path = "/channel", request_body = UploadChannelQuery, params(("url" = String, Query), ("almond-api-key" = String, description = "API key")), tags = ["channel"], responses((status = 200, description = "Channel already exists, skipping"), (status = 201, description = "Channel uploaded successfully"), (status = 400, description = "Bad request, likely a malformed URL"), (status = 401, description = "Unauthorized"), (status = 500, description = "Failed to upload channel to database")))]
pub async fn upload_channel( pub async fn upload_channel(
State(state): State<Instance>, State(state): State<Instance>,
Query(query): Query<UploadChannelQuery>, Query(query): Query<UploadChannelQuery>,
@ -141,7 +164,7 @@ pub async fn upload_channel(
{ {
Ok(result) => { Ok(result) => {
info!("Inserted channel to database successfully! {result:?}"); info!("Inserted channel to database successfully! {result:?}");
StatusCode::OK StatusCode::CREATED
} }
Err(e) => { Err(e) => {
error!("Error inserting channel to database: {e:?}"); error!("Error inserting channel to database: {e:?}");
@ -154,6 +177,7 @@ pub async fn upload_channel(
} }
/// Update an existing channel from the database /// Update an existing channel from the database
#[utoipa::path(put, path = "/channel/{id}", params(("almond-api-key" = String, description = "API key")), tags = ["channel"], responses((status = 204, description = "Channel updated successfully"), (status = 404, description = "Video with specified ID not found"), (status = 500, description = "Failed to update channel from database")))]
pub async fn update_channel(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode { pub async fn update_channel(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode {
match sqlx::query_as!(Channel, "SELECT * FROM channel WHERE youtube_id = ?", id) match sqlx::query_as!(Channel, "SELECT * FROM channel WHERE youtube_id = ?", id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
@ -175,7 +199,7 @@ pub async fn update_channel(State(state): State<Instance>, Path(id): Path<String
).execute(&state.pool).await { ).execute(&state.pool).await {
Ok(r) => { Ok(r) => {
info!("Updated channel '{id}' successfully: {r:?}"); info!("Updated channel '{id}' successfully: {r:?}");
StatusCode::OK StatusCode::NO_CONTENT
}, },
Err(e) => { Err(e) => {
error!("Update channel failed: {e:?}"); error!("Update channel failed: {e:?}");
@ -198,6 +222,7 @@ pub async fn update_channel(State(state): State<Instance>, Path(id): Path<String
} }
/// Delete a channel from the database /// Delete a channel from the database
#[utoipa::path(delete, path = "/channel/{id}", params(("almond-api-key" = String, description = "API key")), tags = ["channel"], responses((status = 204, description = "Channel deleted successfully"), (status = 404, description = "Video with specified ID not found"), (status = 500, description = "Failed to delete channel from database")))]
pub async fn delete_channel(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode { pub async fn delete_channel(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode {
match sqlx::query!("DELETE FROM channel WHERE youtube_id = ?", id) match sqlx::query!("DELETE FROM channel WHERE youtube_id = ?", id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)

View File

@ -5,6 +5,7 @@ use axum::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::error; use tracing::error;
use utoipa::ToSchema;
use crate::{ use crate::{
comment::{Comment, CommentsError, get_comments_from_video}, comment::{Comment, CommentsError, get_comments_from_video},
@ -16,7 +17,7 @@ pub struct VideoCommentsQuery {
page: Option<usize>, page: Option<usize>,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct VideoCommentsResponse { pub struct VideoCommentsResponse {
comments: Vec<Comment>, comments: Vec<Comment>,
page: usize, page: usize,
@ -26,6 +27,7 @@ pub struct VideoCommentsResponse {
} }
/// Fetches the comments from a video, will return an empty vec if the video has no comments /// Fetches the comments from a video, will return an empty vec if the video has no comments
#[utoipa::path(get, path = "/video/{id}/comments", tags = ["comment"], responses((status = 200, body = VideoCommentsResponse), (status = 404, description = "Not found"), (status = 500, description = "Failed to get video comments")))]
pub async fn get_video_comments( pub async fn get_video_comments(
State(state): State<Instance>, State(state): State<Instance>,
Path(id): Path<String>, Path(id): Path<String>,

View File

@ -1,18 +1,20 @@
use axum::Json; use axum::Json;
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema;
pub mod channel; pub mod channel;
pub mod comment; pub mod comment;
pub mod middleware; pub mod middleware;
pub mod video; pub mod video;
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct IndexResponse { pub struct IndexResponse {
app_name: String, app_name: String,
version: String, version: String,
} }
/// Get general information from the current Almond instance /// Get general information from the current Almond instance
#[utoipa::path(get, path = "/", tags = ["application"], responses((status = 200, body = IndexResponse)))]
pub async fn index() -> Json<IndexResponse> { pub async fn index() -> Json<IndexResponse> {
let app_name = "Almond".into(); let app_name = "Almond".into();
let version = env!("CARGO_PKG_VERSION").into(); let version = env!("CARGO_PKG_VERSION").into();

View File

@ -6,6 +6,7 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, info}; use tracing::{error, info};
use url::Url; use url::Url;
use utoipa::ToSchema;
use crate::{ use crate::{
instance::Instance, instance::Instance,
@ -13,12 +14,12 @@ use crate::{
video::{Video, VideoError}, video::{Video, VideoError},
}; };
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct ListVideosQuery { pub struct ListVideosQuery {
page: Option<usize>, page: Option<usize>,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct ListVideosResponse { pub struct ListVideosResponse {
videos: Vec<Video>, videos: Vec<Video>,
page: usize, page: usize,
@ -28,6 +29,7 @@ pub struct ListVideosResponse {
} }
/// Retrieve video list as JSON (paged) /// Retrieve video list as JSON (paged)
#[utoipa::path(get, path = "/video", request_body = ListVideosQuery, params(("page" = Option<usize>, Query)), tags = ["video"], responses((status = 200, body = ListVideosResponse), (status = 500, description = "Error fetching videos from database")))]
pub async fn list_videos( pub async fn list_videos(
State(state): State<Instance>, State(state): State<Instance>,
Query(query): Query<ListVideosQuery>, Query(query): Query<ListVideosQuery>,
@ -63,24 +65,8 @@ pub async fn list_videos(
})) }))
} }
/// Get all videos belonging to a channel ID from the database
pub async fn get_channel_videos(
State(state): State<Instance>,
Path(id): Path<String>,
) -> Result<Json<Vec<Video>>, StatusCode> {
sqlx::query_as!(Video, "SELECT * FROM video WHERE author_id = ?", id)
.fetch_all(&state.pool)
.await
.map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |videos| {
if videos.is_empty() {
Err(StatusCode::NOT_FOUND)
} else {
Ok(Json(videos))
}
})
}
/// Get a single video from the database by its ID /// Get a single video from the database by its ID
#[utoipa::path(get, path = "/video/{id}", tags = ["video"], responses((status = 200, body = Video), (status = 404, description = "Not found"), (status = 500, description = "Failed to get video from database")))]
pub async fn get_video( pub async fn get_video(
State(state): State<Instance>, State(state): State<Instance>,
Path(id): Path<String>, Path(id): Path<String>,
@ -93,12 +79,13 @@ pub async fn get_video(
}) })
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct UploadVideoQuery { pub struct UploadVideoQuery {
url: String, url: String,
} }
/// Upload a video to the database /// Upload a video to the database
#[utoipa::path(post, path = "/video", request_body = UploadVideoQuery, params(("url" = String, Query), ("almond-api-key" = String, description = "API key")), tags = ["video"], responses((status = 200, description = "Video already exists, skipping"), (status = 201, description = "Video uploaded successfully"), (status = 400, description = "Bad request, likely a malformed URL"), (status = 401, description = "Unauthorized"), (status = 500, description = "Failed to upload video to database")))]
pub async fn upload_video( pub async fn upload_video(
State(state): State<Instance>, State(state): State<Instance>,
Query(query): Query<UploadVideoQuery>, Query(query): Query<UploadVideoQuery>,
@ -162,7 +149,7 @@ pub async fn upload_video(
{ {
Ok(result) => { Ok(result) => {
info!("Inserted video to database successfully! {result:?}"); info!("Inserted video to database successfully! {result:?}");
StatusCode::OK StatusCode::CREATED
} }
Err(e) => { Err(e) => {
error!("Error inserting video to database: {e:?}"); error!("Error inserting video to database: {e:?}");
@ -175,6 +162,7 @@ pub async fn upload_video(
} }
/// Update an existing video from the database /// Update an existing video from the database
#[utoipa::path(put, path = "/video/{id}", params(("almond-api-key" = String, description = "API key")), tags = ["video"], responses((status = 204, description = "Video updated successfully"), (status = 404, description = "Video with specified ID not found"), (status = 500, description = "Failed to update video from database")))]
pub async fn update_video(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode { pub async fn update_video(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode {
match sqlx::query_as!(Video, "SELECT * FROM video WHERE youtube_id = ?", id) match sqlx::query_as!(Video, "SELECT * FROM video WHERE youtube_id = ?", id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
@ -194,7 +182,7 @@ pub async fn update_video(State(state): State<Instance>, Path(id): Path<String>)
).execute(&state.pool).await { ).execute(&state.pool).await {
Ok(r) => { Ok(r) => {
info!("Updated video '{id}' successfully: {r:?}"); info!("Updated video '{id}' successfully: {r:?}");
StatusCode::OK StatusCode::NO_CONTENT
}, },
Err(e) => { Err(e) => {
error!("Update video failed: {e:?}"); error!("Update video failed: {e:?}");
@ -217,6 +205,7 @@ pub async fn update_video(State(state): State<Instance>, Path(id): Path<String>)
} }
/// Delete a video from the database /// Delete a video from the database
#[utoipa::path(delete, path = "/video/{id}", params(("almond-api-key" = String, description = "API key")), tags = ["video"], responses((status = 204, description = "Video deleted successfully"), (status = 404, description = "Video with specified ID not found"), (status = 500, description = "Failed to delete video from database")))]
pub async fn delete_video(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode { pub async fn delete_video(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode {
match sqlx::query!("DELETE FROM video WHERE youtube_id = ?", id) match sqlx::query!("DELETE FROM video WHERE youtube_id = ?", id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
@ -226,7 +215,7 @@ pub async fn delete_video(State(state): State<Instance>, Path(id): Path<String>)
}) { }) {
Ok(_) => { Ok(_) => {
info!("Deleted video '{id}' successfully"); info!("Deleted video '{id}' successfully");
StatusCode::OK StatusCode::NO_CONTENT
} }
Err(status) => status, Err(status) => status,
} }

View File

@ -7,6 +7,7 @@ use thiserror::Error;
use tokio::fs; use tokio::fs;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use url::Url; use url::Url;
use utoipa::ToSchema;
use crate::string::ToUnquotedString; use crate::string::ToUnquotedString;
@ -28,7 +29,7 @@ pub enum VideoError {
MissingVideoFile, MissingVideoFile,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, ToSchema)]
pub struct Video { pub struct Video {
pub id: i64, pub id: i64,
pub url: String, pub url: String,