diff --git a/Cargo.lock b/Cargo.lock index 1fede50..4049ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -429,6 +438,8 @@ dependencies = [ "tracing", "tracing-subscriber", "urlencoding", + "utoipa", + "utoipa-swagger-ui", "uuid", ] @@ -764,6 +775,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -972,6 +994,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2618,6 +2641,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.115", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -2709,6 +2766,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3945,6 +4011,48 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.115", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.21.0" @@ -3975,6 +4083,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "want" version = "0.3.1" @@ -4155,6 +4273,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4668,12 +4795,44 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.13.0", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 4ae924e..d4baf90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,5 @@ jsonwebtoken = "9.3.1" anyhow = "1.0.95" reqwest = { version = "0.12.12", features = ["json", "rustls-tls"] } serde_json = "1.0.138" +utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "macros"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 48eb6b5..61a1736 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*; mod m20220101_000001_create_event_table; mod m20250215_213300_create_user_table; +mod m20260216_215000_refactor_user_id_to_sub; pub struct Migrator; @@ -11,6 +12,7 @@ impl MigratorTrait for Migrator { vec![ Box::new(m20220101_000001_create_event_table::Migration), Box::new(m20250215_213300_create_user_table::Migration), + Box::new(m20260216_215000_refactor_user_id_to_sub::Migration), ] } } diff --git a/migration/src/m20260216_215000_refactor_user_id_to_sub.rs b/migration/src/m20260216_215000_refactor_user_id_to_sub.rs new file mode 100644 index 0000000..cc20bc2 --- /dev/null +++ b/migration/src/m20260216_215000_refactor_user_id_to_sub.rs @@ -0,0 +1,123 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop foreign key + manager + .drop_foreign_key( + ForeignKey::drop() + .name("fk-event-user_id") + .table(Event::Table) + .to_owned(), + ) + .await?; + + // Add user_sub column + manager + .alter_table( + Table::alter() + .table(Event::Table) + .add_column(ColumnDef::new(Event::UserSub).string().null()) + .to_owned(), + ) + .await?; + + // Copy user_sub from user table via user_id + manager + .exec_stmt( + Query::update() + .table(Event::Table) + .values([( + Event::UserSub, + SimpleExpr::SubQuery( + None, + Box::new( + Query::select() + .column(User::Sub) + .from(User::Table) + .and_where( + Expr::col((User::Table, User::Id)) + .eq(Expr::col((Event::Table, Event::UserId))), + ) + .to_owned() + .into_sub_query_statement(), + ), + ), + )]) + .to_owned(), + ) + .await?; + + // Drop user_id column + manager + .alter_table( + Table::alter() + .table(Event::Table) + .drop_column(Event::UserId) + .to_owned(), + ) + .await?; + + // Create new foreign key to user.sub + manager + .create_foreign_key( + ForeignKey::create() + .name("fk-event-user_sub") + .from(Event::Table, Event::UserSub) + .to(User::Table, User::Sub) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_foreign_key( + ForeignKey::drop() + .name("fk-event-user_sub") + .table(Event::Table) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Event::Table) + .drop_column(Event::UserSub) + .add_column(ColumnDef::new(Event::UserId).integer()) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk-event-user_id") + .from(Event::Table, Event::UserId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum User { + Table, + Id, + Sub, +} + +#[derive(DeriveIden)] +enum Event { + Table, + UserId, + UserSub, +} diff --git a/src/auth.rs b/src/auth.rs index a714f7b..e5f41fa 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -11,6 +11,7 @@ use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; +use utoipa::ToSchema; #[derive(Clone, FromRef)] pub struct AppState { @@ -216,11 +217,12 @@ pub struct TokenResponse { pub struct Claims { pub sub: String, pub email: Option, - pub name: Option, + pub preferred_username: Option, pub exp: usize, + pub groups: Option>, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct CurrentUser { pub id: i32, pub sub: String, @@ -273,3 +275,48 @@ where Err((axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response()) } } + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ServiceAccount { + pub sub: String, +} + +impl FromRequestParts for ServiceAccount +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let state = AppState::from_ref(state); + let oidc = state.oidc; + + let auth_header = parts + .headers + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()); + + if let Some(auth_header) = auth_header { + if auth_header.starts_with("Bearer ") { + let token = &auth_header[7..]; + if let Ok(claims) = oidc.validate_token(token).await { + if claims + .groups + .as_ref() + .map(|g| g.contains(&"agent".to_string())) + .unwrap_or(false) + { + return Ok(ServiceAccount { sub: claims.sub }); + } + } + } + } + + Err(( + axum::http::StatusCode::UNAUTHORIZED, + "Unauthorized Service Account", + ) + .into_response()) + } +} diff --git a/src/entities/event.rs b/src/entities/event.rs index 495d3f3..03303eb 100644 --- a/src/entities/event.rs +++ b/src/entities/event.rs @@ -1,12 +1,13 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, ToSchema)] #[sea_orm(table_name = "event")] pub struct Model { #[sea_orm(primary_key)] pub id: i64, - pub user_id: Option, + pub user_sub: String, pub name: String, pub from: DateTime, pub to: DateTime, @@ -16,8 +17,8 @@ pub struct Model { pub enum Relation { #[sea_orm( belongs_to = "super::user::Entity", - from = "Column::UserId", - to = "super::user::Column::Id" + from = "Column::UserSub", + to = "super::user::Column::Sub" )] User, } diff --git a/src/entities/user.rs b/src/entities/user.rs index f68ec42..8a2bf14 100644 --- a/src/entities/user.rs +++ b/src/entities/user.rs @@ -1,7 +1,8 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)] #[sea_orm(table_name = "user")] pub struct Model { #[sea_orm(primary_key)] diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 58fb146..25940db 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,7 +1,7 @@ use crate::auth::AppState; use crate::entities::user; +use crate::handlers::Json; use axum::{ - Json, extract::{Query, State}, response::{IntoResponse, Redirect, Response}, }; @@ -15,12 +15,15 @@ pub struct AuthCallback { pub state: String, } +#[tracing::instrument(skip(state))] pub async fn login(State(state): State) -> Response { + tracing::info!("Initiating login redirect"); let oidc = state.oidc; let (auth_url, _csrf_token, _nonce) = oidc.auth_url(); Redirect::to(&auth_url).into_response() } +#[tracing::instrument(skip(state, params), fields(code = %params.code))] pub async fn callback( State(state): State, Query(params): Query, @@ -33,7 +36,7 @@ pub async fn callback( Ok(claims) => { let sub = claims.sub; let email = claims.email.unwrap_or_default(); - let name = claims.name.unwrap_or_default(); + let name = claims.preferred_username.unwrap_or_default(); let existing_user = user::Entity::find() .filter(user::Column::Sub.eq(&sub)) @@ -42,14 +45,22 @@ pub async fn callback( .unwrap_or(None); if existing_user.is_none() { + tracing::info!("Creating new user for sub {}", sub); let new_user = user::ActiveModel { - sub: Set(sub), + sub: Set(sub.clone()), email: Set(email), name: Set(name), ..Default::default() }; new_user.insert(&db).await.unwrap(); - }; + } else { + tracing::debug!("Updating existing user for sub {}", sub); + let mut user: user::ActiveModel = existing_user.unwrap().into(); + user.name = Set(name); + user.update(&db).await.unwrap(); + } + + tracing::info!("User {} logged in successfully", sub); let mut redirect_url = format!("/#access_token={}", tokens.id_token); if let Some(refresh_token) = tokens.refresh_token { @@ -78,6 +89,7 @@ pub async fn callback( } } +#[tracing::instrument(skip(state, payload))] pub async fn refresh( State(state): State, Json(payload): Json, @@ -85,7 +97,10 @@ pub async fn refresh( let oidc = state.oidc; match oidc.refresh_token(payload.refresh_token).await { - Ok(tokens) => Json(tokens).into_response(), + Ok(tokens) => { + tracing::info!("Token refreshed successfully"); + Json(tokens).into_response() + } Err(e) => { tracing::error!("Token refresh error: {:?}", e); (axum::http::StatusCode::UNAUTHORIZED, "Refresh failed").into_response() @@ -93,7 +108,7 @@ pub async fn refresh( } } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct RefreshRequest { pub refresh_token: String, } @@ -102,18 +117,19 @@ pub async fn logout() -> Response { Redirect::to("/").into_response() } -pub async fn me( - crate::auth::CurrentUser { - id, - sub, - email, - name, - }: crate::auth::CurrentUser, -) -> Json { - Json(crate::auth::CurrentUser { - id, - sub, - email, - name, - }) +#[utoipa::path( + get, + path = "/auth/me", + responses( + (status = 200, description = "Current user profile", body = crate::auth::CurrentUser), + (status = 401, description = "Unauthorized") + ), + security( + ("oidc" = []) + ) +)] +#[tracing::instrument(skip(user))] +pub async fn me(user: crate::auth::CurrentUser) -> Json { + tracing::debug!("User profile requested for {}", user.sub); + Json(user) } diff --git a/src/handlers/event.rs b/src/handlers/event.rs index 66ec494..a733f7d 100644 --- a/src/handlers/event.rs +++ b/src/handlers/event.rs @@ -1,25 +1,38 @@ use crate::auth::{AppState, CurrentUser}; use crate::entities::{event, event::Entity as Event}; +use crate::handlers::Json; use axum::{ - Json, extract::{Path, Query, State}, http::StatusCode, }; +use chrono::{DateTime, TimeZone, Utc}; use sea_orm::*; +use sea_orm::{ActiveValue::Set, sea_query::Expr}; use serde::{Deserialize, Serialize}; -#[derive(Clone, Serialize, Deserialize)] +use utoipa::{IntoParams, ToSchema}; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct CreateEventRequest { pub name: String, pub from: String, pub to: String, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize, IntoParams)] pub struct ListEventsQuery { pub upcoming: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct EventResponse { + pub id: i64, + pub user_sub: String, + pub name: String, + pub from: DateTime, + pub to: DateTime, +} + fn parse_datetime(dt_str: &str) -> Result { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M") { return Ok(dt); @@ -30,9 +43,23 @@ fn parse_datetime(dt_str: &str) -> Result { dt_str.parse().map_err(|_| StatusCode::BAD_REQUEST) } +#[utoipa::path( + post, + path = "/events", + request_body = CreateEventRequest, + responses( + (status = 200, description = "Event created successfully", body = event::Model), + (status = 400, description = "Invalid request payload"), + (status = 401, description = "Unauthorized") + ), + security( + ("oidc" = []) + ) +)] +#[tracing::instrument(skip(state, payload), fields(user_sub = %sub, event_name = %payload.name))] pub async fn create_event( State(state): State, - CurrentUser { id, .. }: CurrentUser, + CurrentUser { sub, .. }: CurrentUser, Json(payload): Json, ) -> Result, StatusCode> { let db = state.db; @@ -43,7 +70,7 @@ pub async fn create_event( name: Set(payload.name), from: Set(from), to: Set(to), - user_id: Set(Some(id)), + user_sub: Set(sub.clone()), ..Default::default() }; @@ -52,97 +79,174 @@ pub async fn create_event( StatusCode::INTERNAL_SERVER_ERROR })?; - tracing::info!("Created event {} for user {}", result.id, id); + tracing::info!("Created event {} for user {}", result.id, sub); Ok(Json(result)) } +#[utoipa::path( + get, + path = "/events", + params(ListEventsQuery), + responses( + (status = 200, description = "List of events", body = [event::Model]), + (status = 401, description = "Unauthorized") + ), + security( + ("oidc" = []) + ) +)] +#[tracing::instrument(skip(state), fields(user_sub = %sub))] pub async fn list_events( State(state): State, - CurrentUser { id, .. }: CurrentUser, + CurrentUser { sub, .. }: CurrentUser, Query(query): Query, ) -> Result>, StatusCode> { let db = state.db; - let mut find = Event::find().filter(event::Column::UserId.eq(id)); + tracing::debug!("Listing events for user"); + let mut find = Event::find().filter(event::Column::UserSub.eq(sub.clone())); if let Some(true) = query.upcoming { let now = chrono::Utc::now().naive_utc(); find = find.filter(event::Column::From.gte(now)); } - let events = find - .all(&db) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let events = find.all(&db).await.map_err(|e| { + tracing::error!("Failed to list events: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tracing::info!("Listed {} events for user {}", events.len(), sub); Ok(Json(events)) } +#[utoipa::path( + get, + path = "/events/{id}", + params( + ("id" = i32, Path, description = "Event database id") + ), + responses( + (status = 200, description = "Event details", body = event::Model), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Event not found") + ), + security( + ("oidc" = []) + ) +)] +#[tracing::instrument(skip(state), fields(user_sub = %sub, event_id = %event_id))] pub async fn get_event( State(state): State, - CurrentUser { id, .. }: CurrentUser, + CurrentUser { sub, .. }: CurrentUser, Path(event_id): Path, ) -> Result, StatusCode> { let db = state.db; let event = Event::find_by_id(event_id) - .filter(event::Column::UserId.eq(id)) + .filter(event::Column::UserSub.eq(sub.clone())) .one(&db) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; + .map_err(|e| { + tracing::error!("Failed to fetch event {}: {:?}", event_id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or_else(|| { + tracing::warn!("Event {} not found for user {}", event_id, sub); + StatusCode::NOT_FOUND + })?; + + tracing::info!("Fetched event {} for user {}", event_id, sub); Ok(Json(event)) } +#[utoipa::path( + put, + path = "/events/{id}", + params( + ("id" = i32, Path, description = "Event database id") + ), + request_body = CreateEventRequest, + responses( + (status = 200, description = "Event updated successfully", body = event::Model), + (status = 400, description = "Invalid request payload"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Event not found") + ), + security( + ("oidc" = []) + ) +)] +#[tracing::instrument(skip(state, payload), fields(user_sub = %sub, event_id = %event_id, event_name = %payload.name))] pub async fn update_event( State(state): State, - CurrentUser { id, .. }: CurrentUser, + CurrentUser { sub, .. }: CurrentUser, Path(event_id): Path, Json(payload): Json, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let db = state.db; let from = parse_datetime(&payload.from)?; let to = parse_datetime(&payload.to)?; - let event = Event::find_by_id(event_id) - .filter(event::Column::UserId.eq(id)) - .one(&db) + let result = Event::update_many() + .col_expr(event::Column::Name, Expr::value(payload.name.clone())) + .col_expr(event::Column::From, Expr::value(from)) + .col_expr(event::Column::To, Expr::value(to)) + .filter(event::Column::Id.eq(event_id as i64)) + .filter(event::Column::UserSub.eq(sub.clone())) + .exec(&db) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; + .map_err(|e| { + tracing::error!("Service failed to update event {}: {:?}", event_id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; - let mut event: event::ActiveModel = event.into(); - event.name = Set(payload.name); - event.from = Set(from); - event.to = Set(to); + if result.rows_affected == 0 { + return Err(StatusCode::NOT_FOUND); + } - let result = event.update(&db).await.map_err(|e| { - tracing::error!("Failed to update event {}: {:?}", event_id, e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - tracing::info!("Updated event {} for user {}", event_id, id); - - Ok(Json(result)) + Ok(Json(EventResponse { + id: event_id as i64, + user_sub: sub, + name: payload.name, + from: Utc.from_utc_datetime(&from), + to: Utc.from_utc_datetime(&to), + })) } +#[utoipa::path( + delete, + path = "/events/{id}", + params( + ("id" = i32, Path, description = "Event database id") + ), + responses( + (status = 204, description = "Event deleted successfully"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Event not found") + ), + security( + ("oidc" = []) + ) +)] +#[tracing::instrument(skip(state), fields(user_sub = %sub, event_id = %event_id))] pub async fn delete_event( State(state): State, - CurrentUser { id, .. }: CurrentUser, + CurrentUser { sub, .. }: CurrentUser, Path(event_id): Path, ) -> Result { let db = state.db; - let event = Event::find_by_id(event_id) - .filter(event::Column::UserId.eq(id)) - .one(&db) + Event::delete_by_id(event_id as i64) + .filter(event::Column::UserSub.eq(sub.clone())) + .exec(&db) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; + .map_err(|e| { + tracing::error!("Failed to delete event {}: {:?}", event_id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; - event - .delete(&db) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + tracing::info!("Deleted event {} for user {}", event_id, sub); Ok(StatusCode::NO_CONTENT) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index ec21bb6..4e58d6c 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,2 +1,49 @@ pub mod auth; pub mod event; +pub mod service; + +use axum::{ + extract::Request, + extract::{FromRequest, rejection::JsonRejection}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use std::future::Future; +use std::pin::Pin; + +pub struct Json(pub T); + +impl FromRequest for Json +where + axum::Json: FromRequest, + S: Send + Sync, + T: 'static, +{ + type Rejection = Response; + + fn from_request( + req: Request, + state: &S, + ) -> Pin> + Send + '_>> { + let fut = axum::Json::::from_request(req, state); + Box::pin(async move { + match fut.await { + Ok(value) => Ok(Self(value.0)), + Err(rejection) => { + tracing::error!(%rejection, "Json extraction failed"); + Err((StatusCode::BAD_REQUEST, rejection.body_text()).into_response()) + } + } + }) + } +} + +impl IntoResponse for Json +where + T: Serialize, +{ + fn into_response(self) -> Response { + axum::Json(self.0).into_response() + } +} diff --git a/src/handlers/service.rs b/src/handlers/service.rs new file mode 100644 index 0000000..7d90c59 --- /dev/null +++ b/src/handlers/service.rs @@ -0,0 +1,228 @@ +use crate::auth::{AppState, ServiceAccount}; +use crate::entities::{event, event::Entity as Event}; +use crate::handlers::Json; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, +}; +use chrono::{DateTime, TimeZone, Utc}; +use sea_orm::{ActiveValue::Set, sea_query::Expr, *}; +use serde::{Deserialize, Serialize}; + +use utoipa::{IntoParams, ToSchema}; + +#[derive(Serialize, ToSchema)] +pub struct ServiceEventResponse { + pub id: i64, + pub user_sub: String, + pub name: String, + pub from: DateTime, + pub to: DateTime, +} + +impl From for ServiceEventResponse { + fn from(m: event::Model) -> Self { + Self { + id: m.id, + user_sub: m.user_sub, + name: m.name, + from: Utc.from_utc_datetime(&m.from), + to: Utc.from_utc_datetime(&m.to), + } + } +} + +#[derive(Clone, Serialize, Deserialize, ToSchema)] +pub struct ServiceCreateEventRequest { + pub name: String, + pub from: String, + pub to: String, + pub user_sub: String, +} + +#[derive(Clone, Serialize, Deserialize, ToSchema)] +pub struct ServiceUpdateEventRequest { + pub name: String, + pub from: String, + pub to: String, + pub user_sub: String, +} + +#[derive(Deserialize, IntoParams)] +pub struct ServiceListEventsQuery { + pub user_sub: String, + pub upcoming: Option, +} + +fn parse_datetime(dt_str: &str) -> Result { + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M") { + return Ok(dt); + } + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S") { + return Ok(dt); + } + dt_str.parse().map_err(|_| StatusCode::BAD_REQUEST) +} + +#[utoipa::path( + post, + path = "/service/v1/events", + request_body = ServiceCreateEventRequest, + responses( + (status = 200, description = "Event created successfully", body = ServiceEventResponse), + (status = 400, description = "Invalid request payload"), + (status = 401, description = "Unauthorized") + ), + security( + ("oidc" = []) + ) +)] +pub async fn service_create_event( + State(state): State, + _service_account: ServiceAccount, + Json(payload): Json, +) -> Result, StatusCode> { + let db = state.db; + let from = parse_datetime(&payload.from)?; + let to = parse_datetime(&payload.to)?; + + let new_event = event::ActiveModel { + name: Set(payload.name), + from: Set(from), + to: Set(to), + user_sub: Set(payload.user_sub), + ..Default::default() + }; + + let result = new_event.insert(&db).await.map_err(|e| { + tracing::error!("Service failed to create event: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tracing::info!("Service created event {}", result.id); + + Ok(Json(result.into())) +} + +#[utoipa::path( + get, + path = "/service/v1/events", + params(ServiceListEventsQuery), + responses( + (status = 200, description = "List of events", body = [ServiceEventResponse]), + (status = 401, description = "Unauthorized") + ), + security( + ("oidc" = []) + ) +)] +pub async fn service_list_events( + State(state): State, + _service_account: ServiceAccount, + Query(query): Query, +) -> Result>, StatusCode> { + let db = state.db; + let mut find = Event::find(); + + find = find.filter(event::Column::UserSub.eq(query.user_sub)); + + if let Some(true) = query.upcoming { + let now = chrono::Utc::now().naive_utc(); + find = find.filter(event::Column::From.gte(now)); + } + + let events = find + .all(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let response = events.into_iter().map(ServiceEventResponse::from).collect(); + + Ok(Json(response)) +} + +#[utoipa::path( + put, + path = "/service/v1/events/{id}", + params( + ("id" = i32, Path, description = "Event database id"), + ServiceListEventsQuery + ), + request_body = ServiceUpdateEventRequest, + responses( + (status = 200, description = "Event updated successfully", body = ServiceEventResponse), + (status = 400, description = "Invalid request payload"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Event not found") + ), + security( + ("oidc" = []) + ) +)] +pub async fn service_update_event( + State(state): State, + _service_account: ServiceAccount, + Path(event_id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + let db = state.db; + let from = parse_datetime(&payload.from)?; + let to = parse_datetime(&payload.to)?; + + let result = Event::update_many() + .col_expr(event::Column::Name, Expr::value(payload.name.clone())) + .col_expr(event::Column::From, Expr::value(from)) + .col_expr(event::Column::To, Expr::value(to)) + .filter(event::Column::Id.eq(event_id as i64)) + .filter(event::Column::UserSub.eq(payload.user_sub.clone())) + .exec(&db) + .await + .map_err(|e| { + tracing::error!("Service failed to update event {}: {:?}", event_id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if result.rows_affected == 0 { + return Err(StatusCode::NOT_FOUND); + } + + Ok(Json(ServiceEventResponse { + id: event_id as i64, + user_sub: payload.user_sub, + name: payload.name, + from: Utc.from_utc_datetime(&from), + to: Utc.from_utc_datetime(&to), + })) +} + +#[utoipa::path( + delete, + path = "/service/v1/events/{id}", + params( + ("id" = i32, Path, description = "Event database id") + ), + responses( + (status = 204, description = "Event deleted successfully"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Event not found") + ), + security( + ("oidc" = []) + ) +)] +pub async fn service_delete_event( + State(state): State, + _service_account: ServiceAccount, + Path(event_id): Path, +) -> Result { + let db = state.db; + + Event::delete_by_id(event_id as i64) + .exec(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + tracing::info!("Service deleted event {}", event_id); + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/main.rs b/src/main.rs index 60fec08..f0e3123 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod db; mod entities; mod handlers; +mod middleware; mod routes; use crate::auth::{AppState, OidcClient}; @@ -47,8 +48,9 @@ async fn main() { // Create router let app = create_router(state) - .layer(tower_http::cors::CorsLayer::permissive()) - .fallback_service(tower_http::services::ServeDir::new("static")); + .fallback_service(tower_http::services::ServeDir::new("static")) + .layer(axum::middleware::from_fn(middleware::log_errors)) + .layer(tower_http::cors::CorsLayer::permissive()); // Start server let addr = SocketAddr::from(([127, 0, 0, 1], config.port)); diff --git a/src/middleware.rs b/src/middleware.rs new file mode 100644 index 0000000..aafb250 --- /dev/null +++ b/src/middleware.rs @@ -0,0 +1,20 @@ +use axum::{body::Body, extract::Request, middleware::Next, response::Response}; +use tracing::error; + +pub async fn log_errors(req: Request, next: Next) -> Response { + let method = req.method().clone(); + let uri = req.uri().clone(); + + let response = next.run(req).await; + + if response.status().is_client_error() || response.status().is_server_error() { + error!( + method = ?method, + uri = ?uri, + status = ?response.status(), + "Error response returned" + ); + } + + response +} diff --git a/src/routes.rs b/src/routes.rs index f38888f..3997e8e 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2,12 +2,46 @@ use crate::auth::AppState; use crate::handlers::{auth, event}; use axum::{ Router, - routing::{get, post}, + routing::{get, post, put}, }; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::auth::me, + crate::handlers::event::create_event, + crate::handlers::event::list_events, + crate::handlers::event::get_event, + crate::handlers::event::update_event, + crate::handlers::event::delete_event, + crate::handlers::service::service_create_event, + crate::handlers::service::service_list_events, + crate::handlers::service::service_update_event, + crate::handlers::service::service_delete_event, + ), + components( + schemas( + crate::auth::CurrentUser, + crate::entities::user::Model, + crate::entities::event::Model, + crate::handlers::event::CreateEventRequest, + crate::handlers::service::ServiceCreateEventRequest, + crate::handlers::service::ServiceEventResponse, + ) + ), + tags( + (name = "calendar", description = "Calendar Management API") + ) +)] +struct ApiDoc; + pub fn create_router(state: AppState) -> Router { // Explicitly set the router state type to AppState Router::::new() + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) .route("/health", get(health_check)) .route("/auth/login", get(auth::login)) .route("/auth/callback", get(auth::callback)) @@ -21,6 +55,17 @@ pub fn create_router(state: AppState) -> Router { .put(event::update_event) .delete(event::delete_event), ) + // Service endpoints + .route( + "/service/v1/events", + post(crate::handlers::service::service_create_event) + .get(crate::handlers::service::service_list_events), + ) + .route( + "/service/v1/events/{id}", + put(crate::handlers::service::service_update_event) + .delete(crate::handlers::service::service_delete_event), + ) .with_state(state) }