Add openapi docs

This commit is contained in:
Oystein Kristoffer Tveit 2024-10-30 01:29:09 +01:00
parent 9934b11766
commit 0e19d7c184
Signed by: oysteikt
GPG Key ID: 9F2F7D8250F35146
5 changed files with 1041 additions and 70 deletions

850
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ readme = "README.md"
[dependencies] [dependencies]
anyhow = "1.0.82" anyhow = "1.0.82"
axum = { version = "0.6.20", features = ["macros"] } axum = { version = "0.7.7", features = ["macros"] }
clap = { version = "4.4.1", features = ["derive"] } clap = { version = "4.4.1", features = ["derive"] }
clap-verbosity-flag = "2.2.2" clap-verbosity-flag = "2.2.2"
env_logger = "0.10.0" env_logger = "0.10.0"
@ -24,6 +24,9 @@ tempfile = "3.11.0"
tokio = { version = "1.32.0", features = ["full"] } tokio = { version = "1.32.0", features = ["full"] }
tower = { version = "0.4.13", features = ["full"] } tower = { version = "0.4.13", features = ["full"] }
tower-http = { version = "0.4.3", features = ["full"] } tower-http = { version = "0.4.3", features = ["full"] }
utoipa = { version = "5.1.3", features = ["axum_extras"] }
utoipa-axum = "0.1.2"
utoipa-swagger-ui = { version = "8.0.3", features = ["axum", "reqwest"] }
[profile.release] [profile.release]
strip = true strip = true

View File

@ -1,4 +1,4 @@
mod base; mod base;
mod rest_wrapper_v1; mod rest_wrapper_v1;
pub use rest_wrapper_v1::rest_api_routes; pub use rest_wrapper_v1::{rest_api_routes, rest_api_docs};

View File

@ -8,6 +8,10 @@ use axum::{
use mpvipc_async::Mpv; use mpvipc_async::Mpv;
use serde_json::{json, Value}; use serde_json::{json, Value};
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
use utoipa_swagger_ui::SwaggerUi;
use super::base; use super::base;
pub fn rest_api_routes(mpv: Mpv) -> Router { pub fn rest_api_routes(mpv: Mpv) -> Router {
@ -31,6 +35,63 @@ pub fn rest_api_routes(mpv: Mpv) -> Router {
.with_state(mpv) .with_state(mpv)
} }
pub fn rest_api_docs(mpv: Mpv) -> Router {
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(loadfile))
.routes(routes!(play_get, play_set))
.routes(routes!(volume_get, volume_set))
.routes(routes!(time_get, time_set))
.routes(routes!(
playlist_get,
playlist_remove_or_clear
))
.routes(routes!(playlist_next))
.routes(routes!(playlist_previous))
.routes(routes!(playlist_goto))
.routes(routes!(playlist_move))
.routes(routes!(playlist_get_looping, playlist_set_looping))
.routes(routes!(shuffle))
.with_state(mpv)
.split_for_parts();
router.merge(SwaggerUi::new("/swagger").url("/openapi.json", api))
}
#[derive(OpenApi)]
#[openapi(
info(
description = "The legacy Grzegorz Brzeczyszczykiewicz API, used to control a running mpv instance",
version = "1.0.0",
),
)]
struct ApiDoc;
#[derive(serde::Serialize, utoipa::ToSchema)]
struct EmptySuccessResponse {
success: bool,
error: bool,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
struct SuccessResponse {
#[schema(example = true)]
success: bool,
#[schema(example = false)]
error: bool,
#[schema(example = json!({ some: "arbitrary json value" }))]
value: Value,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
struct ErrorResponse {
#[schema(example = "error....")]
error: String,
#[schema(example = "error....")]
errortext: String,
#[schema(example = false)]
success: bool,
}
pub struct RestResponse(anyhow::Result<Value>); pub struct RestResponse(anyhow::Result<Value>);
impl From<anyhow::Result<Value>> for RestResponse { impl From<anyhow::Result<Value>> for RestResponse {
@ -64,73 +125,178 @@ impl IntoResponse for RestResponse {
// TODO: These could possibly be generated with a proc macro // TODO: These could possibly be generated with a proc macro
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
struct LoadFileArgs { struct LoadFileArgs {
path: String, path: String,
} }
/// Add item to playlist
#[utoipa::path(
post,
path = "/load",
params(LoadFileArgs),
responses(
(status = 200, description = "Success", body = EmptySuccessResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
)
)]
async fn loadfile(State(mpv): State<Mpv>, Query(query): Query<LoadFileArgs>) -> RestResponse { async fn loadfile(State(mpv): State<Mpv>, Query(query): Query<LoadFileArgs>) -> RestResponse {
base::loadfile(mpv, &query.path).await.into() base::loadfile(mpv, &query.path).await.into()
} }
/// Check whether the player is paused or playing
#[utoipa::path(
get,
path = "/play",
responses(
(status = 200, description = "Success", body = SuccessResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
)
)]
async fn play_get(State(mpv): State<Mpv>) -> RestResponse { async fn play_get(State(mpv): State<Mpv>) -> RestResponse {
base::play_get(mpv).await.into() base::play_get(mpv).await.into()
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
struct PlaySetArgs { struct PlaySetArgs {
play: String, play: String,
} }
/// Set whether the player is paused or playing
#[utoipa::path(
post,
path = "/play",
params(PlaySetArgs),
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn play_set(State(mpv): State<Mpv>, Query(query): Query<PlaySetArgs>) -> RestResponse { async fn play_set(State(mpv): State<Mpv>, Query(query): Query<PlaySetArgs>) -> RestResponse {
let play = query.play.to_lowercase() == "true"; let play = query.play.to_lowercase() == "true";
base::play_set(mpv, play).await.into() base::play_set(mpv, play).await.into()
} }
/// Get the current player volume
#[utoipa::path(
get,
path = "/volume",
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn volume_get(State(mpv): State<Mpv>) -> RestResponse { async fn volume_get(State(mpv): State<Mpv>) -> RestResponse {
base::volume_get(mpv).await.into() base::volume_get(mpv).await.into()
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
struct VolumeSetArgs { struct VolumeSetArgs {
volume: f64, volume: f64,
} }
/// Set the player volume
#[utoipa::path(
post,
path = "/volume",
params(VolumeSetArgs),
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn volume_set(State(mpv): State<Mpv>, Query(query): Query<VolumeSetArgs>) -> RestResponse { async fn volume_set(State(mpv): State<Mpv>, Query(query): Query<VolumeSetArgs>) -> RestResponse {
base::volume_set(mpv, query.volume).await.into() base::volume_set(mpv, query.volume).await.into()
} }
/// Get current playback position
#[utoipa::path(
get,
path = "/time",
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn time_get(State(mpv): State<Mpv>) -> RestResponse { async fn time_get(State(mpv): State<Mpv>) -> RestResponse {
base::time_get(mpv).await.into() base::time_get(mpv).await.into()
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
struct TimeSetArgs { struct TimeSetArgs {
pos: Option<f64>, pos: Option<f64>,
percent: Option<f64>, percent: Option<f64>,
} }
/// Set playback position
#[utoipa::path(
post,
path = "/time",
params(TimeSetArgs),
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn time_set(State(mpv): State<Mpv>, Query(query): Query<TimeSetArgs>) -> RestResponse { async fn time_set(State(mpv): State<Mpv>, Query(query): Query<TimeSetArgs>) -> RestResponse {
base::time_set(mpv, query.pos, query.percent).await.into() base::time_set(mpv, query.pos, query.percent).await.into()
} }
/// Get the current playlist
#[utoipa::path(
get,
path = "/playlist",
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn playlist_get(State(mpv): State<Mpv>) -> RestResponse { async fn playlist_get(State(mpv): State<Mpv>) -> RestResponse {
base::playlist_get(mpv).await.into() base::playlist_get(mpv).await.into()
} }
/// Go to the next item in the playlist
#[utoipa::path(
post,
path = "/playlist/next",
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn playlist_next(State(mpv): State<Mpv>) -> RestResponse { async fn playlist_next(State(mpv): State<Mpv>) -> RestResponse {
base::playlist_next(mpv).await.into() base::playlist_next(mpv).await.into()
} }
/// Go back to the previous item in the playlist
#[utoipa::path(
post,
path = "/playlist/previous",
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn playlist_previous(State(mpv): State<Mpv>) -> RestResponse { async fn playlist_previous(State(mpv): State<Mpv>) -> RestResponse {
base::playlist_previous(mpv).await.into() base::playlist_previous(mpv).await.into()
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
struct PlaylistGotoArgs { struct PlaylistGotoArgs {
index: usize, index: usize,
} }
/// Go to a specific item in the playlist
#[utoipa::path(
post,
path = "/playlist/goto",
params(PlaylistGotoArgs),
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn playlist_goto( async fn playlist_goto(
State(mpv): State<Mpv>, State(mpv): State<Mpv>,
Query(query): Query<PlaylistGotoArgs>, Query(query): Query<PlaylistGotoArgs>,
@ -138,11 +304,21 @@ async fn playlist_goto(
base::playlist_goto(mpv, query.index).await.into() base::playlist_goto(mpv, query.index).await.into()
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
struct PlaylistRemoveOrClearArgs { struct PlaylistRemoveOrClearArgs {
index: Option<usize>, index: Option<usize>,
} }
/// Clears a single item or the entire playlist
#[utoipa::path(
delete,
path = "/playlist",
params(PlaylistRemoveOrClearArgs),
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn playlist_remove_or_clear( async fn playlist_remove_or_clear(
State(mpv): State<Mpv>, State(mpv): State<Mpv>,
Query(query): Query<PlaylistRemoveOrClearArgs>, Query(query): Query<PlaylistRemoveOrClearArgs>,
@ -153,12 +329,22 @@ async fn playlist_remove_or_clear(
} }
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
struct PlaylistMoveArgs { struct PlaylistMoveArgs {
index1: usize, index1: usize,
index2: usize, index2: usize,
} }
/// Move a playlist item to a different position
#[utoipa::path(
post,
path = "/playlist/move",
params(PlaylistMoveArgs),
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn playlist_move( async fn playlist_move(
State(mpv): State<Mpv>, State(mpv): State<Mpv>,
Query(query): Query<PlaylistMoveArgs>, Query(query): Query<PlaylistMoveArgs>,
@ -168,19 +354,47 @@ async fn playlist_move(
.into() .into()
} }
/// Shuffle the playlist
#[utoipa::path(
post,
path = "/playlist/shuffle",
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn shuffle(State(mpv): State<Mpv>) -> RestResponse { async fn shuffle(State(mpv): State<Mpv>) -> RestResponse {
base::shuffle(mpv).await.into() base::shuffle(mpv).await.into()
} }
/// Check whether the playlist is looping
#[utoipa::path(
get,
path = "/playlist/loop",
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn playlist_get_looping(State(mpv): State<Mpv>) -> RestResponse { async fn playlist_get_looping(State(mpv): State<Mpv>) -> RestResponse {
base::playlist_get_looping(mpv).await.into() base::playlist_get_looping(mpv).await.into()
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize, utoipa::IntoParams)]
struct PlaylistSetLoopingArgs { struct PlaylistSetLoopingArgs {
r#loop: bool, r#loop: bool,
} }
/// Set whether the playlist should loop
#[utoipa::path(
post,
path = "/playlist/loop",
params(PlaylistSetLoopingArgs),
responses(
(status = 200, description = "Success", body = Value, content_type = "application/json"),
(status = 500, description = "Internal server error", body = Value, content_type = "application/json"),
)
)]
async fn playlist_set_looping( async fn playlist_set_looping(
State(mpv): State<Mpv>, State(mpv): State<Mpv>,
Query(query): Query<PlaylistSetLoopingArgs>, Query(query): Query<PlaylistSetLoopingArgs>,

View File

@ -1,5 +1,5 @@
use anyhow::Context; use anyhow::Context;
use axum::{Router, Server}; use axum::Router;
use clap::Parser; use clap::Parser;
use clap_verbosity_flag::Verbosity; use clap_verbosity_flag::Verbosity;
use mpv_setup::{connect_to_mpv, create_mpv_config_file, show_grzegorz_image}; use mpv_setup::{connect_to_mpv, create_mpv_config_file, show_grzegorz_image};
@ -89,8 +89,12 @@ async fn setup_systemd_watchdog_thread() -> anyhow::Result<()> {
async fn shutdown(mpv: Mpv, proc: Option<tokio::process::Child>) { async fn shutdown(mpv: Mpv, proc: Option<tokio::process::Child>) {
log::info!("Shutting down"); log::info!("Shutting down");
sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]) sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]).unwrap_or_else(|e| {
.unwrap_or_else(|e| log::warn!("Failed to notify systemd that the service is stopping: {}", e)); log::warn!(
"Failed to notify systemd that the service is stopping: {}",
e
)
});
mpv.disconnect() mpv.disconnect()
.await .await
@ -156,11 +160,15 @@ async fn main() -> anyhow::Result<()> {
let socket_addr = SocketAddr::new(addr, args.port); let socket_addr = SocketAddr::new(addr, args.port);
log::info!("Starting API on {}", socket_addr); log::info!("Starting API on {}", socket_addr);
let app = Router::new().nest("/api", api::rest_api_routes(mpv.clone())); let app = Router::new()
let server = match Server::try_bind(&socket_addr.clone()) .nest("/api", api::rest_api_routes(mpv.clone()))
.merge(api::rest_api_docs(mpv.clone()));
let listener = match tokio::net::TcpListener::bind(&socket_addr)
.await
.context(format!("Failed to bind API server to '{}'", &socket_addr)) .context(format!("Failed to bind API server to '{}'", &socket_addr))
{ {
Ok(server) => server, Ok(listener) => listener,
Err(e) => { Err(e) => {
log::error!("{}", e); log::error!("{}", e);
shutdown(mpv, proc).await; shutdown(mpv, proc).await;
@ -191,7 +199,7 @@ async fn main() -> anyhow::Result<()> {
log::info!("Received Ctrl-C, exiting"); log::info!("Received Ctrl-C, exiting");
shutdown(mpv, Some(proc)).await; shutdown(mpv, Some(proc)).await;
} }
result = server.serve(app.into_make_service()) => { result = axum::serve(listener, app.into_make_service()) => {
log::info!("API server exited"); log::info!("API server exited");
shutdown(mpv, Some(proc)).await; shutdown(mpv, Some(proc)).await;
result?; result?;
@ -203,7 +211,7 @@ async fn main() -> anyhow::Result<()> {
log::info!("Received Ctrl-C, exiting"); log::info!("Received Ctrl-C, exiting");
shutdown(mpv.clone(), None).await; shutdown(mpv.clone(), None).await;
} }
result = server.serve(app.into_make_service()) => { result = axum::serve(listener, app.into_make_service()) => {
log::info!("API server exited"); log::info!("API server exited");
shutdown(mpv.clone(), None).await; shutdown(mpv.clone(), None).await;
result?; result?;