From e27e2ab6d1f167c111bbac45f78358e3c983701d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 14:29:52 +0200 Subject: [PATCH] wire utoipa OpenAPI: #[utoipa::path] on all handlers, Scalar UI (#14) --- Cargo.lock | 14 ++ Cargo.toml | 1 + crates/api-types/src/admin.rs | 4 +- crates/api-types/src/iptv.rs | 4 +- crates/api-types/src/library.rs | 12 +- crates/mcp/src/server.rs | 12 +- crates/presentation/Cargo.toml | 4 + crates/presentation/src/handlers/admin.rs | 35 ++++ crates/presentation/src/handlers/auth.rs | 48 ++++++ crates/presentation/src/handlers/channels.rs | 151 ++++++++++++++++++ crates/presentation/src/handlers/config.rs | 8 + crates/presentation/src/handlers/iptv.rs | 17 ++ crates/presentation/src/handlers/library.rs | 90 +++++++++++ crates/presentation/src/handlers/providers.rs | 56 +++++++ crates/presentation/src/handlers/schedule.rs | 105 +++++++++--- crates/presentation/src/main.rs | 2 + crates/presentation/src/openapi.rs | 124 ++++++++++++++ crates/presentation/src/routes.rs | 12 +- 18 files changed, 651 insertions(+), 48 deletions(-) create mode 100644 crates/presentation/src/openapi.rs diff --git a/Cargo.lock b/Cargo.lock index fd8f574..82d505c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,6 +1843,8 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-scalar", "uuid", ] @@ -3029,6 +3031,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + [[package]] name = "uuid" version = "1.23.4" diff --git a/Cargo.toml b/Cargo.toml index 5ed400a..8d4027b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } reqwest = { version = "0.12", features = ["json"] } utoipa = { version = "5", features = ["chrono", "uuid"] } +utoipa-scalar = { version = "0.3", features = ["axum"] } jsonwebtoken = "9" # Internal crates diff --git a/crates/api-types/src/admin.rs b/crates/api-types/src/admin.rs index f036908..2f0a9d5 100644 --- a/crates/api-types/src/admin.rs +++ b/crates/api-types/src/admin.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -17,7 +17,7 @@ pub struct ActivityEventResponse { pub channel_id: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] pub struct ActivityLogParams { pub limit: Option, } diff --git a/crates/api-types/src/iptv.rs b/crates/api-types/src/iptv.rs index b5a45ea..cfc52e8 100644 --- a/crates/api-types/src/iptv.rs +++ b/crates/api-types/src/iptv.rs @@ -1,7 +1,7 @@ use serde::Deserialize; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] pub struct IptvParams { pub token: Option, } diff --git a/crates/api-types/src/library.rs b/crates/api-types/src/library.rs index 0894529..06c3dbf 100644 --- a/crates/api-types/src/library.rs +++ b/crates/api-types/src/library.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use crate::common::enum_to_string; @@ -128,7 +128,7 @@ impl From for SyncStatusEntry { } } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] pub struct LibrarySearchParams { pub provider: Option, pub content_type: Option, @@ -144,12 +144,12 @@ pub struct LibrarySearchParams { pub limit: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] pub struct ProviderParam { pub provider: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] pub struct ShowsParams { pub provider: Option, pub search_term: Option, @@ -157,13 +157,13 @@ pub struct ShowsParams { pub genres: Vec, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] pub struct SeasonsParams { pub series_name: String, pub provider: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] pub struct GenresParams { pub content_type: Option, pub provider: Option, diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 5e518c7..fe1af8b 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -14,7 +14,7 @@ use schemars::JsonSchema; use serde::Deserialize; use uuid::Uuid; -use crate::tools::{channels, ical, library, schedule}; +use crate::tools::{channels, library, schedule}; const SERVER_NAME: &str = "k-tv-mcp"; @@ -201,16 +201,6 @@ impl KTvMcpServer { ) .await } - - #[tool( - description = "Export a channel's schedule as iCalendar (.ics). Returns RFC 5545 text." - )] - async fn export_schedule_ical(&self, #[tool(aggr)] p: ChannelIdParam) -> String { - match parse_uuid(&p.channel_id) { - Ok(id) => ical::export_schedule_ical(&self.channel_query, id).await, - Err(e) => e, - } - } } #[tool(tool_box)] diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 55f5584..4766e14 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -28,6 +28,10 @@ adapter-sqlite = { workspace = true, optional = true } adapter-jellyfin = { workspace = true, optional = true } adapter-local-files = { workspace = true, optional = true } +# OpenAPI +utoipa = { workspace = true } +utoipa-scalar = { workspace = true } + # Framework axum = { workspace = true } axum-extra = { workspace = true, features = ["typed-header"] } diff --git a/crates/presentation/src/handlers/admin.rs b/crates/presentation/src/handlers/admin.rs index 8344396..fb67248 100644 --- a/crates/presentation/src/handlers/admin.rs +++ b/crates/presentation/src/handlers/admin.rs @@ -11,6 +11,17 @@ use crate::state::AppState; const DEFAULT_ACTIVITY_LIMIT: u32 = 50; +#[utoipa::path( + get, + path = "/api/v1/admin/settings", + tag = "admin", + security(("bearer" = [])), + responses( + (status = 200, body = SettingsResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + ) +)] pub async fn get_settings( State(state): State, AdminUser(_user): AdminUser, @@ -20,6 +31,18 @@ pub async fn get_settings( Ok(Json(SettingsResponse { settings })) } +#[utoipa::path( + put, + path = "/api/v1/admin/settings", + tag = "admin", + security(("bearer" = [])), + request_body = HashMap, + responses( + (status = 200, body = SettingsResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + ) +)] pub async fn update_settings( State(state): State, AdminUser(_user): AdminUser, @@ -34,6 +57,18 @@ pub async fn update_settings( Ok(Json(SettingsResponse { settings })) } +#[utoipa::path( + get, + path = "/api/v1/admin/activity", + tag = "admin", + security(("bearer" = [])), + params(ActivityLogParams), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + ) +)] pub async fn get_activity_log( State(state): State, AdminUser(_user): AdminUser, diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index 26fd728..4431a5b 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -10,6 +10,16 @@ use crate::state::AppState; const TOKEN_TYPE_BEARER: &str = "Bearer"; +#[utoipa::path( + post, + path = "/api/v1/auth/register", + tag = "auth", + request_body = RegisterRequest, + responses( + (status = 200, body = UserResponse), + (status = 409, body = api_types::ErrorResponse), + ) +)] pub async fn register( State(state): State, Json(req): Json, @@ -22,6 +32,16 @@ pub async fn register( Ok(Json(UserResponse::from(user))) } +#[utoipa::path( + post, + path = "/api/v1/auth/login", + tag = "auth", + request_body = LoginRequest, + responses( + (status = 200, body = TokenResponse), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn login( State(state): State, Json(req): Json, @@ -40,15 +60,43 @@ pub async fn login( })) } +#[utoipa::path( + post, + path = "/api/v1/auth/logout", + tag = "auth", + responses( + (status = 200, body = serde_json::Value), + ) +)] pub async fn logout() -> Result, AppError> { Ok(Json(serde_json::json!({"message": "logged out"}))) } +#[utoipa::path( + get, + path = "/api/v1/auth/me", + tag = "auth", + security(("bearer" = [])), + responses( + (status = 200, body = UserResponse), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn me(CurrentUser(user): CurrentUser) -> Result, AppError> { Ok(Json(UserResponse::from(user))) } #[cfg(feature = "auth-jwt")] +#[utoipa::path( + post, + path = "/api/v1/auth/refresh", + tag = "auth", + request_body = RefreshRequest, + responses( + (status = 200, body = TokenResponse), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn refresh_token( State(state): State, Json(req): Json, diff --git a/crates/presentation/src/handlers/channels.rs b/crates/presentation/src/handlers/channels.rs index 8499f6a..cb81ee6 100644 --- a/crates/presentation/src/handlers/channels.rs +++ b/crates/presentation/src/handlers/channels.rs @@ -13,6 +13,16 @@ use crate::errors::AppError; use crate::extractors::CurrentUser; use crate::state::AppState; +#[utoipa::path( + get, + path = "/api/v1/channels", + tag = "channels", + security(("bearer" = [])), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn list_channels( State(state): State, CurrentUser(_user): CurrentUser, @@ -21,6 +31,16 @@ pub async fn list_channels( Ok(Json(channels.into_iter().map(ChannelResponse::from).collect())) } +#[utoipa::path( + get, + path = "/api/v1/channels/mine", + tag = "channels", + security(("bearer" = [])), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn list_my_channels( State(state): State, CurrentUser(user): CurrentUser, @@ -29,6 +49,18 @@ pub async fn list_my_channels( Ok(Json(channels.into_iter().map(ChannelResponse::from).collect())) } +#[utoipa::path( + post, + path = "/api/v1/channels", + tag = "channels", + security(("bearer" = [])), + request_body = CreateChannelRequest, + responses( + (status = 200, body = ChannelResponse), + (status = 400, body = api_types::ErrorResponse), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn create_channel( State(state): State, CurrentUser(user): CurrentUser, @@ -43,6 +75,20 @@ pub async fn create_channel( Ok(Json(ChannelResponse::from(channel))) } +#[utoipa::path( + get, + path = "/api/v1/channels/{id}", + tag = "channels", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = ChannelResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn get_channel( State(state): State, CurrentUser(_user): CurrentUser, @@ -56,6 +102,22 @@ pub async fn get_channel( Ok(Json(ChannelResponse::from(channel))) } +#[utoipa::path( + put, + path = "/api/v1/channels/{id}", + tag = "channels", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + request_body = UpdateChannelRequest, + responses( + (status = 200, body = ChannelResponse), + (status = 400, body = api_types::ErrorResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn update_channel( State(state): State, CurrentUser(user): CurrentUser, @@ -77,6 +139,21 @@ pub async fn update_channel( Ok(Json(ChannelResponse::from(channel))) } +#[utoipa::path( + delete, + path = "/api/v1/channels/{id}", + tag = "channels", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 204), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn delete_channel( State(state): State, CurrentUser(user): CurrentUser, @@ -90,6 +167,20 @@ pub async fn delete_channel( Ok(axum::http::StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/api/v1/channels/{id}/snapshots", + tag = "channels", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = ConfigSnapshotResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn save_snapshot( State(state): State, CurrentUser(_user): CurrentUser, @@ -103,6 +194,20 @@ pub async fn save_snapshot( Ok(Json(ConfigSnapshotResponse::from(snap))) } +#[utoipa::path( + get, + path = "/api/v1/channels/{id}/snapshots", + tag = "channels", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn list_snapshots( State(state): State, CurrentUser(_user): CurrentUser, @@ -112,6 +217,21 @@ pub async fn list_snapshots( Ok(Json(snaps.into_iter().map(ConfigSnapshotResponse::from).collect())) } +#[utoipa::path( + get, + path = "/api/v1/channels/{id}/snapshots/{snapshot_id}", + tag = "channels", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"), + ), + responses( + (status = 200, body = ConfigSnapshotResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn get_snapshot( State(state): State, CurrentUser(_user): CurrentUser, @@ -125,6 +245,22 @@ pub async fn get_snapshot( Ok(Json(ConfigSnapshotResponse::from(snap))) } +#[utoipa::path( + patch, + path = "/api/v1/channels/{id}/snapshots/{snapshot_id}", + tag = "channels", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"), + ), + request_body = PatchSnapshotRequest, + responses( + (status = 200, body = ConfigSnapshotResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn patch_snapshot( State(state): State, CurrentUser(_user): CurrentUser, @@ -140,6 +276,21 @@ pub async fn patch_snapshot( Ok(Json(ConfigSnapshotResponse::from(snap))) } +#[utoipa::path( + post, + path = "/api/v1/channels/{id}/snapshots/{snapshot_id}/restore", + tag = "channels", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"), + ), + responses( + (status = 200, body = ChannelResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn restore_snapshot( State(state): State, CurrentUser(_user): CurrentUser, diff --git a/crates/presentation/src/handlers/config.rs b/crates/presentation/src/handlers/config.rs index 8e448bc..90e0bac 100644 --- a/crates/presentation/src/handlers/config.rs +++ b/crates/presentation/src/handlers/config.rs @@ -7,6 +7,14 @@ use application::config::GetConfigQuery; use crate::errors::AppError; use crate::state::AppState; +#[utoipa::path( + get, + path = "/api/v1/config", + tag = "config", + responses( + (status = 200, body = ConfigResponse), + ) +)] pub async fn get_config( State(state): State, ) -> Result, AppError> { diff --git a/crates/presentation/src/handlers/iptv.rs b/crates/presentation/src/handlers/iptv.rs index e709908..6cf13da 100644 --- a/crates/presentation/src/handlers/iptv.rs +++ b/crates/presentation/src/handlers/iptv.rs @@ -12,6 +12,15 @@ use crate::state::AppState; const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8"; const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8"; +#[utoipa::path( + get, + path = "/api/v1/iptv/playlist.m3u", + tag = "iptv", + params(IptvParams), + responses( + (status = 200, content_type = "audio/x-mpegurl", body = String), + ) +)] pub async fn m3u_playlist( State(state): State, OptionalCurrentUser(_user): OptionalCurrentUser, @@ -25,6 +34,14 @@ pub async fn m3u_playlist( Ok(([(header::CONTENT_TYPE, M3U_CONTENT_TYPE)], content)) } +#[utoipa::path( + get, + path = "/api/v1/iptv/epg.xml", + tag = "iptv", + responses( + (status = 200, content_type = "application/xml", body = String), + ) +)] pub async fn xmltv_epg( State(state): State, OptionalCurrentUser(_user): OptionalCurrentUser, diff --git a/crates/presentation/src/handlers/library.rs b/crates/presentation/src/handlers/library.rs index 62427a7..28dde30 100644 --- a/crates/presentation/src/handlers/library.rs +++ b/crates/presentation/src/handlers/library.rs @@ -15,6 +15,17 @@ use crate::state::AppState; const DEFAULT_SEARCH_LIMIT: u32 = 50; +#[utoipa::path( + get, + path = "/api/v1/library/items", + tag = "library", + security(("bearer" = [])), + params(LibrarySearchParams), + responses( + (status = 200, body = PaginatedResponse), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn search_items( State(state): State, CurrentUser(_user): CurrentUser, @@ -39,6 +50,20 @@ pub async fn search_items( ))) } +#[utoipa::path( + get, + path = "/api/v1/library/items/{id}", + tag = "library", + security(("bearer" = [])), + params( + ("id" = String, Path, description = "Library item ID"), + ), + responses( + (status = 200, body = LibraryItemResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn get_item( State(state): State, CurrentUser(_user): CurrentUser, @@ -52,6 +77,17 @@ pub async fn get_item( Ok(Json(LibraryItemResponse::from(item))) } +#[utoipa::path( + get, + path = "/api/v1/library/collections", + tag = "library", + security(("bearer" = [])), + params(ProviderParam), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn list_collections( State(state): State, CurrentUser(_user): CurrentUser, @@ -69,6 +105,17 @@ pub async fn list_collections( )) } +#[utoipa::path( + get, + path = "/api/v1/library/shows", + tag = "library", + security(("bearer" = [])), + params(ShowsParams), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn list_shows( State(state): State, CurrentUser(_user): CurrentUser, @@ -85,6 +132,17 @@ pub async fn list_shows( Ok(Json(shows.into_iter().map(ShowResponse::from).collect())) } +#[utoipa::path( + get, + path = "/api/v1/library/seasons", + tag = "library", + security(("bearer" = [])), + params(SeasonsParams), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn list_seasons( State(state): State, CurrentUser(_user): CurrentUser, @@ -99,6 +157,17 @@ pub async fn list_seasons( )) } +#[utoipa::path( + get, + path = "/api/v1/library/genres", + tag = "library", + security(("bearer" = [])), + params(GenresParams), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn list_genres( State(state): State, CurrentUser(_user): CurrentUser, @@ -116,6 +185,16 @@ pub async fn list_genres( Ok(Json(genres)) } +#[utoipa::path( + get, + path = "/api/v1/library/sync/status", + tag = "library", + security(("bearer" = [])), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn sync_status( State(state): State, CurrentUser(_user): CurrentUser, @@ -124,6 +203,17 @@ pub async fn sync_status( Ok(Json(entries.into_iter().map(SyncStatusEntry::from).collect())) } +#[utoipa::path( + post, + path = "/api/v1/library/sync", + tag = "library", + security(("bearer" = [])), + responses( + (status = 202), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + ) +)] pub async fn trigger_sync( State(state): State, AdminUser(_user): AdminUser, diff --git a/crates/presentation/src/handlers/providers.rs b/crates/presentation/src/handlers/providers.rs index e972303..7317044 100644 --- a/crates/presentation/src/handlers/providers.rs +++ b/crates/presentation/src/handlers/providers.rs @@ -9,6 +9,17 @@ use crate::errors::AppError; use crate::extractors::AdminUser; use crate::state::AppState; +#[utoipa::path( + get, + path = "/api/v1/admin/providers", + tag = "providers", + security(("bearer" = [])), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + ) +)] pub async fn list_providers( State(state): State, AdminUser(_user): AdminUser, @@ -22,6 +33,21 @@ pub async fn list_providers( )) } +#[utoipa::path( + get, + path = "/api/v1/admin/providers/{id}", + tag = "providers", + security(("bearer" = [])), + params( + ("id" = String, Path, description = "Provider ID"), + ), + responses( + (status = 200, body = ProviderConfigResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn get_provider( State(state): State, AdminUser(_user): AdminUser, @@ -35,6 +61,21 @@ pub async fn get_provider( Ok(Json(ProviderConfigResponse::from(provider))) } +#[utoipa::path( + put, + path = "/api/v1/admin/providers/{id}", + tag = "providers", + security(("bearer" = [])), + params( + ("id" = String, Path, description = "Provider ID"), + ), + request_body = ProviderConfigRequest, + responses( + (status = 200, body = serde_json::Value), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + ) +)] pub async fn upsert_provider( State(state): State, AdminUser(_user): AdminUser, @@ -51,6 +92,21 @@ pub async fn upsert_provider( Ok(Json(serde_json::json!({"status": "ok"}))) } +#[utoipa::path( + delete, + path = "/api/v1/admin/providers/{id}", + tag = "providers", + security(("bearer" = [])), + params( + ("id" = String, Path, description = "Provider ID"), + ), + responses( + (status = 204), + (status = 401, body = api_types::ErrorResponse), + (status = 403, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn delete_provider( State(state): State, AdminUser(_user): AdminUser, diff --git a/crates/presentation/src/handlers/schedule.rs b/crates/presentation/src/handlers/schedule.rs index 0b787a8..48f84b1 100644 --- a/crates/presentation/src/handlers/schedule.rs +++ b/crates/presentation/src/handlers/schedule.rs @@ -1,6 +1,6 @@ use axum::Json; use axum::extract::{Path, State}; -use axum::http::{StatusCode, header}; +use axum::http::StatusCode; use axum::response::IntoResponse; use chrono::Utc; @@ -10,13 +10,26 @@ use api_types::{ use application::schedule::{ GenerateScheduleCommand, GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery, }; -use domain::DomainError; use domain::value_objects::ChannelId; use crate::errors::AppError; use crate::extractors::CurrentUser; use crate::state::AppState; +#[utoipa::path( + post, + path = "/api/v1/channels/{id}/schedule", + tag = "schedule", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = ScheduleResponse), + (status = 401, body = api_types::ErrorResponse), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn generate_schedule( State(state): State, CurrentUser(_user): CurrentUser, @@ -27,6 +40,20 @@ pub async fn generate_schedule( Ok(Json(ScheduleResponse::from(schedule))) } +#[utoipa::path( + get, + path = "/api/v1/channels/{id}/schedule", + tag = "schedule", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = ScheduleResponse), + (status = 204), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn get_active_schedule( State(state): State, CurrentUser(_user): CurrentUser, @@ -39,6 +66,19 @@ pub async fn get_active_schedule( } } +#[utoipa::path( + get, + path = "/api/v1/channels/{id}/now", + tag = "schedule", + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = CurrentBroadcastResponse), + (status = 204), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn get_current_broadcast( State(state): State, Path(id): Path, @@ -58,6 +98,18 @@ pub async fn get_current_broadcast( } } +#[utoipa::path( + get, + path = "/api/v1/channels/{id}/epg", + tag = "schedule", + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = Vec), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn get_epg( State(state): State, Path(id): Path, @@ -67,6 +119,19 @@ pub async fn get_epg( Ok(Json(slots.into_iter().map(SlotResponse::from).collect())) } +#[utoipa::path( + get, + path = "/api/v1/channels/{id}/stream", + tag = "schedule", + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = String), + (status = 204), + (status = 404, body = api_types::ErrorResponse), + ) +)] pub async fn get_stream( State(state): State, Path(id): Path, @@ -78,6 +143,19 @@ pub async fn get_stream( } } +#[utoipa::path( + get, + path = "/api/v1/channels/{id}/schedule/history", + tag = "schedule", + security(("bearer" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Channel ID"), + ), + responses( + (status = 200, body = Vec), + (status = 401, body = api_types::ErrorResponse), + ) +)] pub async fn list_schedule_history( State(state): State, CurrentUser(_user): CurrentUser, @@ -92,26 +170,3 @@ pub async fn list_schedule_history( .collect(), )) } - -pub async fn export_ical( - State(state): State, - Path(id): Path, -) -> Result { - let channel = state - .channel_query - .find_by_id(id.into()) - .await? - .ok_or_else(|| AppError(DomainError::NotFound(format!("Channel {id} not found"))))?; - - let ical = domain::generate_ical(channel.name(), channel.timezone(), channel.schedule_config()); - let disposition = format!("attachment; filename=\"{}.ics\"", channel.name()); - - Ok(( - [ - (header::CONTENT_TYPE, "text/calendar; charset=utf-8".to_string()), - (header::CONTENT_DISPOSITION, disposition), - ], - ical, - ) - .into_response()) -} diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 1088768..b6245a2 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -9,6 +9,7 @@ mod extractors; mod factory; mod handlers; mod mappers; +mod openapi; mod routes; mod state; @@ -52,6 +53,7 @@ async fn main() -> anyhow::Result<()> { let app = axum::Router::new() .nest("/api/v1", routes::api_v1_router()) + .nest("/api", routes::docs_router()) .layer(cors) .layer(TraceLayer::new_for_http()) .with_state(app_state); diff --git a/crates/presentation/src/openapi.rs b/crates/presentation/src/openapi.rs new file mode 100644 index 0000000..94aa520 --- /dev/null +++ b/crates/presentation/src/openapi.rs @@ -0,0 +1,124 @@ +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; +use utoipa::{Modify, OpenApi}; + +struct BearerAuth; + +impl Modify for BearerAuth { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "bearer", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ); + } + } +} + +#[derive(OpenApi)] +#[openapi( + info( + title = "K-TV API", + version = "1.0.0", + description = "Self-hosted linear TV channel orchestration", + ), + modifiers(&BearerAuth), + paths( + crate::handlers::auth::register, + crate::handlers::auth::login, + crate::handlers::auth::logout, + crate::handlers::auth::me, + crate::handlers::auth::refresh_token, + crate::handlers::channels::list_channels, + crate::handlers::channels::list_my_channels, + crate::handlers::channels::create_channel, + crate::handlers::channels::get_channel, + crate::handlers::channels::update_channel, + crate::handlers::channels::delete_channel, + crate::handlers::channels::save_snapshot, + crate::handlers::channels::list_snapshots, + crate::handlers::channels::get_snapshot, + crate::handlers::channels::patch_snapshot, + crate::handlers::channels::restore_snapshot, + crate::handlers::schedule::generate_schedule, + crate::handlers::schedule::get_active_schedule, + crate::handlers::schedule::get_current_broadcast, + crate::handlers::schedule::get_epg, + crate::handlers::schedule::get_stream, + crate::handlers::schedule::list_schedule_history, + crate::handlers::admin::get_settings, + crate::handlers::admin::update_settings, + crate::handlers::admin::get_activity_log, + crate::handlers::providers::list_providers, + crate::handlers::providers::get_provider, + crate::handlers::providers::upsert_provider, + crate::handlers::providers::delete_provider, + crate::handlers::config::get_config, + crate::handlers::iptv::m3u_playlist, + crate::handlers::iptv::xmltv_epg, + crate::handlers::library::search_items, + crate::handlers::library::get_item, + crate::handlers::library::list_collections, + crate::handlers::library::list_shows, + crate::handlers::library::list_seasons, + crate::handlers::library::list_genres, + crate::handlers::library::sync_status, + crate::handlers::library::trigger_sync, + ), + components(schemas( + api_types::LoginRequest, + api_types::RegisterRequest, + api_types::RefreshRequest, + api_types::TokenResponse, + api_types::UserResponse, + api_types::ChannelResponse, + api_types::CreateChannelRequest, + api_types::UpdateChannelRequest, + api_types::ConfigSnapshotResponse, + api_types::PatchSnapshotRequest, + api_types::ScheduleResponse, + api_types::SlotResponse, + api_types::MediaItemResponse, + api_types::CurrentBroadcastResponse, + api_types::ScheduleHistoryEntry, + api_types::SettingsResponse, + api_types::ActivityEventResponse, + api_types::ActivityLogParams, + api_types::ProviderConfigRequest, + api_types::ProviderConfigResponse, + api_types::ConfigResponse, + api_types::ProviderCapabilitiesResponse, + api_types::ProviderInfo, + api_types::IptvParams, + api_types::LibraryItemResponse, + api_types::CollectionResponse, + api_types::ShowResponse, + api_types::SeasonResponse, + api_types::SyncStatusEntry, + api_types::LibrarySearchParams, + api_types::ProviderParam, + api_types::ShowsParams, + api_types::SeasonsParams, + api_types::GenresParams, + api_types::PaginatedResponse, + api_types::ErrorResponse, + )), + security( + ("bearer" = []), + ), + tags( + (name = "auth", description = "Authentication"), + (name = "channels", description = "Channel management"), + (name = "schedule", description = "Schedule generation and playback"), + (name = "admin", description = "Admin settings and activity"), + (name = "providers", description = "Media provider configuration"), + (name = "config", description = "Public system configuration"), + (name = "iptv", description = "IPTV playlist and EPG feeds"), + (name = "library", description = "Media library browsing and sync"), + ), +)] +pub struct ApiDoc; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 86c74f5..7a0502d 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -1,6 +1,9 @@ -use axum::{Router, routing::{delete, get, post, put}}; +use axum::{Json, Router, routing::{delete, get, post, put}}; +use utoipa::OpenApi; +use utoipa_scalar::{Scalar, Servable}; use crate::handlers; +use crate::openapi::ApiDoc; use crate::state::AppState; pub fn api_v1_router() -> Router { @@ -15,6 +18,12 @@ pub fn api_v1_router() -> Router { .merge(local_files_router()) } +pub fn docs_router() -> Router { + Router::new() + .route("/docs", get(|| async { Json(ApiDoc::openapi()) })) + .merge(Scalar::with_url("/docs/ui", ApiDoc::openapi())) +} + fn auth_router() -> Router { let r = Router::new() .route("/register", post(handlers::auth::register)) @@ -44,7 +53,6 @@ fn channel_router() -> Router { .route("/{id}/now", get(handlers::schedule::get_current_broadcast)) .route("/{id}/epg", get(handlers::schedule::get_epg)) .route("/{id}/stream", get(handlers::schedule::get_stream)) - .route("/{id}/export.ics", get(handlers::schedule::export_ical)) .route("/{id}/snapshots", post(handlers::channels::save_snapshot)) .route("/{id}/snapshots", get(handlers::channels::list_snapshots)) .route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))