From 1951e73fdf96847ad0400012fb68e9088e03311b Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 8 Feb 2026 15:43:22 +0900 Subject: [PATCH 1/3] Refactor auth, calendar, and nango API crates - Extract shared env config types (SupabaseEnv, NangoEnv) into new api-env crate - Introduce AuthContext in api-auth middleware, removing duplicated extract_token helpers across crates - Move calendar routes and nango_http from api-nango to api-calendar where they belong - Add list_calendars and create_event endpoints to api-calendar - Replace hardcoded NangoIntegration enum with fluent client.integration().connection() API - Simplify config constructors to accept env structs directly Co-authored-by: Cursor --- Cargo.lock | 40 ++- Cargo.toml | 1 + apps/ai/src/auth.rs | 14 +- apps/ai/src/main.rs | 2 +- crates/api-auth/src/lib.rs | 45 ++- crates/api-calendar/Cargo.toml | 8 +- crates/api-calendar/src/config.rs | 17 +- crates/api-calendar/src/error.rs | 6 - crates/api-calendar/src/lib.rs | 4 +- .../src/nango_http.rs | 47 +--- crates/api-calendar/src/routes/calendar.rs | 258 ++++++++++++++++++ crates/api-calendar/src/routes/mod.rs | 37 ++- crates/api-calendar/src/state.rs | 19 +- crates/api-env/Cargo.toml | 7 + crates/api-env/src/lib.rs | 13 + crates/api-nango/Cargo.toml | 7 +- crates/api-nango/src/config.rs | 22 +- crates/api-nango/src/env.rs | 4 +- crates/api-nango/src/error.rs | 6 - crates/api-nango/src/lib.rs | 6 +- crates/api-nango/src/routes/calendar.rs | 122 --------- crates/api-nango/src/routes/connect.rs | 31 +-- crates/api-nango/src/routes/mod.rs | 6 - crates/api-subscription/Cargo.toml | 3 +- crates/api-subscription/src/config.rs | 36 +-- crates/api-subscription/src/env.rs | 4 +- crates/api-subscription/src/error.rs | 6 - crates/api-subscription/src/lib.rs | 5 +- crates/api-subscription/src/routes/billing.rs | 30 +- crates/api-subscription/src/routes/mod.rs | 2 +- crates/api-subscription/src/routes/rpc.rs | 34 +-- crates/google-calendar/src/client.rs | 24 +- crates/google-calendar/src/error.rs | 4 +- crates/google-calendar/src/types.rs | 53 ++++ crates/nango/src/client.rs | 39 ++- crates/nango/src/error.rs | 2 - crates/nango/src/lib.rs | 8 +- crates/nango/src/proxy.rs | 4 +- crates/nango/src/types.rs | 34 --- 39 files changed, 591 insertions(+), 419 deletions(-) rename crates/{api-nango => api-calendar}/src/nango_http.rs (53%) create mode 100644 crates/api-calendar/src/routes/calendar.rs create mode 100644 crates/api-env/Cargo.toml create mode 100644 crates/api-env/src/lib.rs delete mode 100644 crates/api-nango/src/routes/calendar.rs delete mode 100644 crates/nango/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 8dba246089..0cc0ad2dd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -538,19 +538,45 @@ dependencies = [ ] [[package]] -name = "api-integration" +name = "api-calendar" version = "0.1.0" dependencies = [ + "api-auth", + "api-env", "axum 0.8.8", "chrono", "google-calendar", - "hypr-http", + "hypr-http-utils", + "nango", + "reqwest 0.13.1", + "sentry", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", +] + +[[package]] +name = "api-env" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "api-nango" +version = "0.1.0" +dependencies = [ + "api-auth", + "api-env", + "axum 0.8.8", "nango", "reqwest 0.13.1", "sentry", "serde", "serde_json", - "supabase-auth", "thiserror 2.0.18", "tokio", "tracing", @@ -561,6 +587,8 @@ dependencies = [ name = "api-subscription" version = "0.1.0" dependencies = [ + "api-auth", + "api-env", "async-stripe", "async-stripe-billing", "async-stripe-core", @@ -570,7 +598,6 @@ dependencies = [ "sentry", "serde", "serde_json", - "supabase-auth", "thiserror 2.0.18", "tokio", "tracing", @@ -8115,11 +8142,12 @@ name = "google-calendar" version = "0.1.0" dependencies = [ "chrono", - "hypr-http", + "hypr-http-utils", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "urlencoding", ] [[package]] @@ -9074,7 +9102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74e25026c579b170c59f8d3ddfc523d7dab0abe079f09eb8edaebd2417044f60" [[package]] -name = "hypr-http" +name = "hypr-http-utils" version = "0.1.0" [[package]] diff --git a/Cargo.toml b/Cargo.toml index 82929deafa..ec559ab8b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ hypr-am2 = { path = "crates/am2", package = "am2" } hypr-analytics = { path = "crates/analytics", package = "analytics" } hypr-api-auth = { path = "crates/api-auth", package = "api-auth" } hypr-api-calendar = { path = "crates/api-calendar", package = "api-calendar" } +hypr-api-env = { path = "crates/api-env", package = "api-env" } hypr-api-nango = { path = "crates/api-nango", package = "api-nango" } hypr-api-subscription = { path = "crates/api-subscription", package = "api-subscription" } hypr-api-sync = { path = "crates/api-sync", package = "api-sync" } diff --git a/apps/ai/src/auth.rs b/apps/ai/src/auth.rs index db47b0a03a..bf81f17479 100644 --- a/apps/ai/src/auth.rs +++ b/apps/ai/src/auth.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use axum::{extract::Request, middleware::Next, response::Response}; -use hypr_api_auth::Claims; +use hypr_api_auth::AuthContext; pub use hypr_api_auth::{AuthState, require_auth}; const DEVICE_FINGERPRINT_HEADER: &str = "x-device-fingerprint"; @@ -14,21 +14,21 @@ pub async fn sentry_and_analytics(mut request: Request, next: Next) -> Response .and_then(|h| h.to_str().ok()) .map(String::from); - if let Some(claims) = request.extensions().get::() { + if let Some(auth) = request.extensions().get::() { sentry::configure_scope(|scope| { scope.set_user(Some(sentry::User { id: device_fingerprint.clone(), - email: claims.email.clone(), - username: Some(claims.sub.clone()), + email: auth.claims.email.clone(), + username: Some(auth.claims.sub.clone()), ..Default::default() })); - scope.set_tag("user.id", &claims.sub); + scope.set_tag("user.id", &auth.claims.sub); let mut ctx = BTreeMap::new(); ctx.insert( "entitlements".into(), sentry::protocol::Value::Array( - claims + auth.claims .entitlements .iter() .map(|e| sentry::protocol::Value::String(e.clone())) @@ -38,7 +38,7 @@ pub async fn sentry_and_analytics(mut request: Request, next: Next) -> Response scope.set_context("user_claims", sentry::protocol::Context::Other(ctx)); }); - let user_id = claims.sub.clone(); + let user_id = auth.claims.sub.clone(); request .extensions_mut() .insert(hypr_analytics::AuthenticatedUserId(user_id)); diff --git a/apps/ai/src/main.rs b/apps/ai/src/main.rs index bca5013be9..160e7c79f5 100644 --- a/apps/ai/src/main.rs +++ b/apps/ai/src/main.rs @@ -37,7 +37,7 @@ fn app() -> Router { let llm_config = hypr_llm_proxy::LlmProxyConfig::new(&env.llm).with_analytics(analytics.clone()); let stt_config = hypr_transcribe_proxy::SttProxyConfig::new(&env.stt).with_analytics(analytics); - let auth_state = AuthState::new(&env.supabase_url, "hyprnote_pro"); + let auth_state = AuthState::new(&env.supabase_url).with_required_entitlement("hyprnote_pro"); let protected_routes = Router::new() .merge(hypr_transcribe_proxy::listen_router(stt_config.clone())) diff --git a/crates/api-auth/src/lib.rs b/crates/api-auth/src/lib.rs index 9fba2b56bb..963062122b 100644 --- a/crates/api-auth/src/lib.rs +++ b/crates/api-auth/src/lib.rs @@ -8,19 +8,30 @@ use hypr_supabase_auth::{Error as SupabaseAuthError, SupabaseAuth}; pub use hypr_supabase_auth::Claims; +#[derive(Clone)] +pub struct AuthContext { + pub token: String, + pub claims: Claims, +} + #[derive(Clone)] pub struct AuthState { inner: SupabaseAuth, - required_entitlement: String, + required_entitlement: Option, } impl AuthState { - pub fn new(supabase_url: &str, required_entitlement: impl Into) -> Self { + pub fn new(supabase_url: &str) -> Self { Self { inner: SupabaseAuth::new(supabase_url), - required_entitlement: required_entitlement.into(), + required_entitlement: None, } } + + pub fn with_required_entitlement(mut self, entitlement: impl Into) -> Self { + self.required_entitlement = Some(entitlement.into()); + self + } } pub struct AuthError(SupabaseAuthError); @@ -63,15 +74,18 @@ pub async fn require_auth( .and_then(|h| h.to_str().ok()) .ok_or(SupabaseAuthError::MissingAuthHeader)?; - let token = - SupabaseAuth::extract_token(auth_header).ok_or(SupabaseAuthError::InvalidAuthHeader)?; + let token = SupabaseAuth::extract_token(auth_header) + .ok_or(SupabaseAuthError::InvalidAuthHeader)? + .to_owned(); - let claims = state - .inner - .require_entitlement(token, &state.required_entitlement) - .await?; + let claims = match &state.required_entitlement { + Some(entitlement) => state.inner.require_entitlement(&token, entitlement).await?, + None => state.inner.verify_token(&token).await?, + }; - request.extensions_mut().insert(claims); + request + .extensions_mut() + .insert(AuthContext { token, claims }); Ok(next.run(request).await) } @@ -117,7 +131,14 @@ mod tests { #[test] fn test_auth_state_new() { - let state = AuthState::new("https://example.supabase.co", "hyprnote_pro"); - assert_eq!(state.required_entitlement, "hyprnote_pro"); + let state = AuthState::new("https://example.supabase.co"); + assert_eq!(state.required_entitlement, None); + } + + #[test] + fn test_auth_state_with_required_entitlement() { + let state = + AuthState::new("https://example.supabase.co").with_required_entitlement("hyprnote_pro"); + assert_eq!(state.required_entitlement, Some("hyprnote_pro".to_string())); } } diff --git a/crates/api-calendar/Cargo.toml b/crates/api-calendar/Cargo.toml index c95cd7d87f..9496a3c0b6 100644 --- a/crates/api-calendar/Cargo.toml +++ b/crates/api-calendar/Cargo.toml @@ -4,7 +4,13 @@ version = "0.1.0" edition = "2024" [dependencies] -hypr-supabase-auth = { workspace = true } +hypr-api-auth = { workspace = true } +hypr-api-env = { workspace = true } +hypr-google-calendar = { workspace = true } +hypr-http = { workspace = true } +hypr-nango = { workspace = true } + +chrono = { workspace = true, features = ["serde"] } utoipa = { workspace = true } diff --git a/crates/api-calendar/src/config.rs b/crates/api-calendar/src/config.rs index ce47780ae0..32b1d68b46 100644 --- a/crates/api-calendar/src/config.rs +++ b/crates/api-calendar/src/config.rs @@ -1,17 +1,16 @@ -use std::sync::Arc; +use hypr_api_env::{NangoEnv, SupabaseEnv}; #[derive(Clone)] pub struct CalendarConfig { - pub auth: Option>, + pub nango: NangoEnv, + pub supabase: SupabaseEnv, } impl CalendarConfig { - pub fn new() -> Self { - Self { auth: None } - } - - pub fn with_auth(mut self, auth: Arc) -> Self { - self.auth = Some(auth); - self + pub fn new(nango: &NangoEnv, supabase: &SupabaseEnv) -> Self { + Self { + nango: nango.clone(), + supabase: supabase.clone(), + } } } diff --git a/crates/api-calendar/src/error.rs b/crates/api-calendar/src/error.rs index bcc5751d0e..56b8064200 100644 --- a/crates/api-calendar/src/error.rs +++ b/crates/api-calendar/src/error.rs @@ -25,12 +25,6 @@ pub enum CalendarError { Internal(String), } -impl From for CalendarError { - fn from(err: hypr_supabase_auth::Error) -> Self { - Self::Auth(err.to_string()) - } -} - impl IntoResponse for CalendarError { fn into_response(self) -> Response { let (status, error_code) = match &self { diff --git a/crates/api-calendar/src/lib.rs b/crates/api-calendar/src/lib.rs index 6c55c08326..66dcbf4171 100644 --- a/crates/api-calendar/src/lib.rs +++ b/crates/api-calendar/src/lib.rs @@ -1,9 +1,11 @@ mod config; mod error; +mod nango_http; mod routes; mod state; pub use config::CalendarConfig; pub use error::{CalendarError, Result}; -pub use routes::{openapi, router}; +pub use hypr_api_env::{NangoEnv, SupabaseEnv}; +pub use routes::{ListEventsResponse, openapi, router}; pub use state::AppState; diff --git a/crates/api-nango/src/nango_http.rs b/crates/api-calendar/src/nango_http.rs similarity index 53% rename from crates/api-nango/src/nango_http.rs rename to crates/api-calendar/src/nango_http.rs index d41aec754e..9cf66dfc13 100644 --- a/crates/api-nango/src/nango_http.rs +++ b/crates/api-calendar/src/nango_http.rs @@ -1,27 +1,18 @@ -use hypr_nango::{NangoClient, NangoIntegration}; +use hypr_nango::NangoProxy; pub struct NangoHttpClient<'a> { - nango: &'a NangoClient, - connection_id: String, + proxy: NangoProxy<'a>, } impl<'a> NangoHttpClient<'a> { - pub fn new(nango: &'a NangoClient, connection_id: impl Into) -> Self { - Self { - nango, - connection_id: connection_id.into(), - } + pub fn new(proxy: NangoProxy<'a>) -> Self { + Self { proxy } } } impl<'a> hypr_http::HttpClient for NangoHttpClient<'a> { async fn get(&self, path: &str) -> Result, Box> { - let response = self - .nango - .for_connection(NangoIntegration::GoogleCalendar, &self.connection_id) - .get(path)? - .send() - .await?; + let response = self.proxy.get(path)?.send().await?; let bytes = response.error_for_status()?.bytes().await?; Ok(bytes.to_vec()) } @@ -32,12 +23,7 @@ impl<'a> hypr_http::HttpClient for NangoHttpClient<'a> { body: Vec, ) -> Result, Box> { let json_value: serde_json::Value = serde_json::from_slice(&body)?; - let response = self - .nango - .for_connection(NangoIntegration::GoogleCalendar, &self.connection_id) - .post(path, &json_value)? - .send() - .await?; + let response = self.proxy.post(path, &json_value)?.send().await?; let bytes = response.error_for_status()?.bytes().await?; Ok(bytes.to_vec()) } @@ -48,12 +34,7 @@ impl<'a> hypr_http::HttpClient for NangoHttpClient<'a> { body: Vec, ) -> Result, Box> { let json_value: serde_json::Value = serde_json::from_slice(&body)?; - let response = self - .nango - .for_connection(NangoIntegration::GoogleCalendar, &self.connection_id) - .put(path, &json_value)? - .send() - .await?; + let response = self.proxy.put(path, &json_value)?.send().await?; let bytes = response.error_for_status()?.bytes().await?; Ok(bytes.to_vec()) } @@ -64,12 +45,7 @@ impl<'a> hypr_http::HttpClient for NangoHttpClient<'a> { body: Vec, ) -> Result, Box> { let json_value: serde_json::Value = serde_json::from_slice(&body)?; - let response = self - .nango - .for_connection(NangoIntegration::GoogleCalendar, &self.connection_id) - .patch(path, &json_value)? - .send() - .await?; + let response = self.proxy.patch(path, &json_value)?.send().await?; let bytes = response.error_for_status()?.bytes().await?; Ok(bytes.to_vec()) } @@ -78,12 +54,7 @@ impl<'a> hypr_http::HttpClient for NangoHttpClient<'a> { &self, path: &str, ) -> Result, Box> { - let response = self - .nango - .for_connection(NangoIntegration::GoogleCalendar, &self.connection_id) - .delete(path)? - .send() - .await?; + let response = self.proxy.delete(path)?.send().await?; let bytes = response.error_for_status()?.bytes().await?; Ok(bytes.to_vec()) } diff --git a/crates/api-calendar/src/routes/calendar.rs b/crates/api-calendar/src/routes/calendar.rs new file mode 100644 index 0000000000..e218bc1da2 --- /dev/null +++ b/crates/api-calendar/src/routes/calendar.rs @@ -0,0 +1,258 @@ +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::error::{CalendarError, Result}; +use crate::state::AppState; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ListCalendarsRequest { + pub connection_id: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ListCalendarsResponse { + pub calendars: Vec, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ListEventsRequest { + pub connection_id: String, + pub calendar_id: String, + #[serde(default)] + pub time_min: Option, + #[serde(default)] + pub time_max: Option, + #[serde(default)] + pub max_results: Option, + #[serde(default)] + pub page_token: Option, + #[serde(default)] + pub single_events: Option, + #[serde(default)] + pub order_by: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ListEventsResponse { + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateEventRequest { + pub connection_id: String, + pub calendar_id: String, + pub summary: String, + pub start: EventDateTime, + pub end: EventDateTime, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub location: Option, + #[serde(default)] + pub attendees: Option>, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct EventDateTime { + #[serde(default)] + pub date: Option, + #[serde(default, rename = "dateTime")] + pub date_time: Option, + #[serde(default, rename = "timeZone")] + pub time_zone: Option, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct EventAttendee { + pub email: String, + #[serde(default, rename = "displayName")] + pub display_name: Option, + #[serde(default)] + pub optional: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct CreateEventResponse { + pub event: serde_json::Value, +} + +#[utoipa::path( + post, + path = "/calendar/calendars", + request_body = ListCalendarsRequest, + responses( + (status = 200, description = "Calendars fetched", body = ListCalendarsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + tag = "calendar", + security( + ("bearer_auth" = []) + ) +)] +pub async fn list_calendars( + State(state): State, + Json(payload): Json, +) -> Result> { + let proxy = state + .nango + .integration("google-calendar") + .connection(&payload.connection_id); + let http = crate::nango_http::NangoHttpClient::new(proxy); + let client = hypr_google_calendar::GoogleCalendarClient::new(http); + + let response = client + .list_calendars() + .await + .map_err(|e| CalendarError::Internal(e.to_string()))?; + + let calendars: Vec = response + .items + .iter() + .map(|c| serde_json::to_value(c).unwrap_or_default()) + .collect(); + + Ok(Json(ListCalendarsResponse { calendars })) +} + +#[utoipa::path( + post, + path = "/calendar/events", + request_body = ListEventsRequest, + responses( + (status = 200, description = "Events fetched", body = ListEventsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + tag = "calendar", + security( + ("bearer_auth" = []) + ) +)] +pub async fn list_events( + State(state): State, + Json(payload): Json, +) -> Result> { + let proxy = state + .nango + .integration("google-calendar") + .connection(&payload.connection_id); + let http = crate::nango_http::NangoHttpClient::new(proxy); + let client = hypr_google_calendar::GoogleCalendarClient::new(http); + + let time_min = payload + .time_min + .as_deref() + .map(|s| { + chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .map_err(|e| CalendarError::BadRequest(format!("Invalid time_min: {e}"))) + }) + .transpose()?; + + let time_max = payload + .time_max + .as_deref() + .map(|s| { + chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .map_err(|e| CalendarError::BadRequest(format!("Invalid time_max: {e}"))) + }) + .transpose()?; + + let req = hypr_google_calendar::ListEventsRequest { + calendar_id: payload.calendar_id, + time_min, + time_max, + max_results: payload.max_results, + page_token: payload.page_token, + single_events: payload.single_events, + order_by: payload.order_by, + }; + + let response = client + .list_events(req) + .await + .map_err(|e| CalendarError::Internal(e.to_string()))?; + + let events: Vec = response + .items + .iter() + .map(|e| serde_json::to_value(e).unwrap_or_default()) + .collect(); + + Ok(Json(ListEventsResponse { + events, + next_page_token: response.next_page_token, + })) +} + +#[utoipa::path( + post, + path = "/calendar/events/create", + request_body = CreateEventRequest, + responses( + (status = 200, description = "Event created", body = CreateEventResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + tag = "calendar", + security( + ("bearer_auth" = []) + ) +)] +pub async fn create_event( + State(state): State, + Json(payload): Json, +) -> Result> { + let proxy = state + .nango + .integration("google-calendar") + .connection(&payload.connection_id); + let http = crate::nango_http::NangoHttpClient::new(proxy); + let client = hypr_google_calendar::GoogleCalendarClient::new(http); + + let req = hypr_google_calendar::CreateEventRequest { + calendar_id: payload.calendar_id, + event: hypr_google_calendar::CreateEventBody { + summary: payload.summary, + start: hypr_google_calendar::GoogleEventDateTime { + date: payload.start.date, + date_time: payload.start.date_time, + time_zone: payload.start.time_zone, + }, + end: hypr_google_calendar::GoogleEventDateTime { + date: payload.end.date, + date_time: payload.end.date_time, + time_zone: payload.end.time_zone, + }, + description: payload.description, + location: payload.location, + attendees: payload.attendees.map(|attendees| { + attendees + .into_iter() + .map(|a| hypr_google_calendar::GoogleEventAttendee { + email: Some(a.email), + display_name: a.display_name, + response_status: None, + is_self: None, + organizer: None, + optional: a.optional, + }) + .collect() + }), + }, + }; + + let event = client + .create_event(req) + .await + .map_err(|e| CalendarError::Internal(e.to_string()))?; + + let event = serde_json::to_value(event).unwrap_or_default(); + + Ok(Json(CreateEventResponse { event })) +} diff --git a/crates/api-calendar/src/routes/mod.rs b/crates/api-calendar/src/routes/mod.rs index 996ca7a52a..6649b6d548 100644 --- a/crates/api-calendar/src/routes/mod.rs +++ b/crates/api-calendar/src/routes/mod.rs @@ -1,12 +1,31 @@ -use axum::Router; +mod calendar; + +use axum::{Router, middleware, routing::post}; use utoipa::OpenApi; use crate::state::AppState; +pub use calendar::ListEventsResponse; + #[derive(OpenApi)] #[openapi( - paths(), - components(schemas()), + paths( + calendar::list_calendars, + calendar::list_events, + calendar::create_event, + ), + components( + schemas( + calendar::ListCalendarsRequest, + calendar::ListCalendarsResponse, + calendar::ListEventsRequest, + ListEventsResponse, + calendar::CreateEventRequest, + calendar::CreateEventResponse, + calendar::EventDateTime, + calendar::EventAttendee, + ) + ), tags( (name = "calendar", description = "Calendar management") ) @@ -18,5 +37,15 @@ pub fn openapi() -> utoipa::openapi::OpenApi { } pub fn router(state: AppState) -> Router { - Router::new().with_state(state) + let auth_state = state.auth.clone(); + + Router::new() + .route("/calendar/calendars", post(calendar::list_calendars)) + .route("/calendar/events", post(calendar::list_events)) + .route("/calendar/events/create", post(calendar::create_event)) + .route_layer(middleware::from_fn_with_state( + auth_state, + hypr_api_auth::require_auth, + )) + .with_state(state) } diff --git a/crates/api-calendar/src/state.rs b/crates/api-calendar/src/state.rs index 3e03e97f70..a29a24a5ef 100644 --- a/crates/api-calendar/src/state.rs +++ b/crates/api-calendar/src/state.rs @@ -1,12 +1,25 @@ +use hypr_api_auth::AuthState; +use hypr_nango::NangoClient; + use crate::config::CalendarConfig; +use crate::error::CalendarError; #[derive(Clone)] pub struct AppState { - pub config: CalendarConfig, + pub nango: NangoClient, + pub auth: AuthState, } impl AppState { - pub fn new(config: CalendarConfig) -> Self { - Self { config } + pub fn new(config: CalendarConfig) -> Result { + let nango = hypr_nango::NangoClient::builder() + .api_base(&config.nango.nango_api_base) + .api_key(&config.nango.nango_api_key) + .build() + .map_err(|e| CalendarError::Internal(e.to_string()))?; + + let auth = AuthState::new(&config.supabase.supabase_url); + + Ok(Self { nango, auth }) } } diff --git a/crates/api-env/Cargo.toml b/crates/api-env/Cargo.toml new file mode 100644 index 0000000000..8f7d17e88b --- /dev/null +++ b/crates/api-env/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "api-env" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { workspace = true, features = ["derive"] } diff --git a/crates/api-env/src/lib.rs b/crates/api-env/src/lib.rs new file mode 100644 index 0000000000..bf6eed573b --- /dev/null +++ b/crates/api-env/src/lib.rs @@ -0,0 +1,13 @@ +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +pub struct SupabaseEnv { + pub supabase_url: String, + pub supabase_anon_key: String, +} + +#[derive(Clone, Deserialize)] +pub struct NangoEnv { + pub nango_api_base: String, + pub nango_api_key: String, +} diff --git a/crates/api-nango/Cargo.toml b/crates/api-nango/Cargo.toml index 474acf9ec2..2dd987887e 100644 --- a/crates/api-nango/Cargo.toml +++ b/crates/api-nango/Cargo.toml @@ -4,12 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -hypr-google-calendar = { workspace = true } -hypr-http = { workspace = true } +hypr-api-auth = { workspace = true } +hypr-api-env = { workspace = true } hypr-nango = { workspace = true } -hypr-supabase-auth = { workspace = true } - -chrono = { workspace = true, features = ["serde"] } utoipa = { workspace = true } diff --git a/crates/api-nango/src/config.rs b/crates/api-nango/src/config.rs index aff54a3817..157a6c46be 100644 --- a/crates/api-nango/src/config.rs +++ b/crates/api-nango/src/config.rs @@ -1,29 +1,19 @@ -use std::sync::Arc; +use crate::NangoWebhookEnv; +use hypr_api_env::NangoEnv; #[derive(Clone)] pub struct IntegrationConfig { pub nango_api_base: String, pub nango_api_key: String, pub nango_webhook_secret: String, - pub auth: Option>, } impl IntegrationConfig { - pub fn new( - nango_api_base: impl Into, - nango_api_key: impl Into, - nango_webhook_secret: impl Into, - ) -> Self { + pub fn new(nango: &NangoEnv, webhook: &NangoWebhookEnv) -> Self { Self { - nango_api_base: nango_api_base.into(), - nango_api_key: nango_api_key.into(), - nango_webhook_secret: nango_webhook_secret.into(), - auth: None, + nango_api_base: nango.nango_api_base.clone(), + nango_api_key: nango.nango_api_key.clone(), + nango_webhook_secret: webhook.nango_webhook_secret.clone(), } } - - pub fn with_auth(mut self, auth: Arc) -> Self { - self.auth = Some(auth); - self - } } diff --git a/crates/api-nango/src/env.rs b/crates/api-nango/src/env.rs index 8e2f30e7ca..17a524ad83 100644 --- a/crates/api-nango/src/env.rs +++ b/crates/api-nango/src/env.rs @@ -1,8 +1,6 @@ use serde::Deserialize; #[derive(Deserialize)] -pub struct Env { - pub nango_api_base: String, - pub nango_api_key: String, +pub struct NangoWebhookEnv { pub nango_webhook_secret: String, } diff --git a/crates/api-nango/src/error.rs b/crates/api-nango/src/error.rs index 6a171f18fd..04e72d80ad 100644 --- a/crates/api-nango/src/error.rs +++ b/crates/api-nango/src/error.rs @@ -28,12 +28,6 @@ pub enum IntegrationError { Internal(String), } -impl From for IntegrationError { - fn from(err: hypr_supabase_auth::Error) -> Self { - Self::Auth(err.to_string()) - } -} - impl From for IntegrationError { fn from(err: hypr_nango::Error) -> Self { Self::Nango(err.to_string()) diff --git a/crates/api-nango/src/lib.rs b/crates/api-nango/src/lib.rs index 7d770c7645..a4868a7bbb 100644 --- a/crates/api-nango/src/lib.rs +++ b/crates/api-nango/src/lib.rs @@ -1,12 +1,12 @@ mod config; mod env; mod error; -pub mod nango_http; mod routes; mod state; pub use config::IntegrationConfig; -pub use env::Env; +pub use env::NangoWebhookEnv; pub use error::{IntegrationError, Result}; -pub use routes::{ListEventsResponse, WebhookResponse, openapi, router}; +pub use hypr_api_env::NangoEnv; +pub use routes::{WebhookResponse, openapi, router}; pub use state::AppState; diff --git a/crates/api-nango/src/routes/calendar.rs b/crates/api-nango/src/routes/calendar.rs deleted file mode 100644 index 6c7481c3a1..0000000000 --- a/crates/api-nango/src/routes/calendar.rs +++ /dev/null @@ -1,122 +0,0 @@ -use axum::{Json, extract::State, http::HeaderMap}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -use crate::error::{IntegrationError, Result}; -use crate::state::AppState; - -#[derive(Debug, Deserialize, ToSchema)] -pub struct ListEventsRequest { - pub connection_id: String, - pub calendar_id: String, - #[serde(default)] - pub time_min: Option, - #[serde(default)] - pub time_max: Option, - #[serde(default)] - pub max_results: Option, - #[serde(default)] - pub page_token: Option, - #[serde(default)] - pub single_events: Option, - #[serde(default)] - pub order_by: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ListEventsResponse { - pub events: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_page_token: Option, -} - -#[utoipa::path( - post, - path = "/calendar/events", - request_body = ListEventsRequest, - responses( - (status = 200, description = "Events fetched", body = ListEventsResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), - ), - tag = "integration", - security( - ("bearer_auth" = []) - ) -)] -pub async fn list_events( - State(state): State, - headers: HeaderMap, - Json(payload): Json, -) -> Result> { - let auth_token = extract_token(&headers)?; - - let auth = state - .config - .auth - .as_ref() - .ok_or_else(|| IntegrationError::Auth("Auth not configured".to_string()))?; - - auth.verify_token(auth_token) - .await - .map_err(|e| IntegrationError::Auth(e.to_string()))?; - - let http = crate::nango_http::NangoHttpClient::new(&state.nango, &payload.connection_id); - let client = hypr_google_calendar::GoogleCalendarClient::new(http); - - let time_min = payload - .time_min - .as_deref() - .map(|s| { - chrono::DateTime::parse_from_rfc3339(s) - .map(|dt| dt.with_timezone(&chrono::Utc)) - .map_err(|e| IntegrationError::BadRequest(format!("Invalid time_min: {e}"))) - }) - .transpose()?; - - let time_max = payload - .time_max - .as_deref() - .map(|s| { - chrono::DateTime::parse_from_rfc3339(s) - .map(|dt| dt.with_timezone(&chrono::Utc)) - .map_err(|e| IntegrationError::BadRequest(format!("Invalid time_max: {e}"))) - }) - .transpose()?; - - let req = hypr_google_calendar::ListEventsRequest { - calendar_id: payload.calendar_id, - time_min, - time_max, - max_results: payload.max_results, - page_token: payload.page_token, - single_events: payload.single_events, - order_by: payload.order_by, - }; - - let response = client - .list_events(req) - .await - .map_err(|e| IntegrationError::Nango(e.to_string()))?; - - let events: Vec = response - .items - .iter() - .map(|e| serde_json::to_value(e).unwrap_or_default()) - .collect(); - - Ok(Json(ListEventsResponse { - events, - next_page_token: response.next_page_token, - })) -} - -fn extract_token(headers: &HeaderMap) -> Result<&str> { - let auth_header = headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .ok_or_else(|| IntegrationError::Auth("Missing Authorization header".to_string()))?; - - hypr_supabase_auth::SupabaseAuth::extract_token(auth_header) - .ok_or_else(|| IntegrationError::Auth("Invalid Authorization header".to_string())) -} diff --git a/crates/api-nango/src/routes/connect.rs b/crates/api-nango/src/routes/connect.rs index 6db9a755c5..d06c53bcb5 100644 --- a/crates/api-nango/src/routes/connect.rs +++ b/crates/api-nango/src/routes/connect.rs @@ -1,8 +1,9 @@ -use axum::{Json, extract::State, http::HeaderMap}; +use axum::{Extension, Json, extract::State}; +use hypr_api_auth::AuthContext; use serde::Serialize; use utoipa::ToSchema; -use crate::error::{IntegrationError, Result}; +use crate::error::Result; use crate::state::AppState; #[derive(Debug, Serialize, ToSchema)] @@ -26,21 +27,9 @@ pub struct ConnectSessionResponse { )] pub async fn create_connect_session( State(state): State, - headers: HeaderMap, + Extension(auth): Extension, ) -> Result> { - let auth_token = extract_token(&headers)?; - - let auth = state - .config - .auth - .as_ref() - .ok_or_else(|| IntegrationError::Auth("Auth not configured".to_string()))?; - - let claims = auth - .verify_token(auth_token) - .await - .map_err(|e| IntegrationError::Auth(e.to_string()))?; - let user_id = claims.sub; + let user_id = auth.claims.sub; let req = hypr_nango::CreateConnectSessionRequest { end_user: hypr_nango::EndUser { @@ -61,13 +50,3 @@ pub async fn create_connect_session( expires_at: session.expires_at, })) } - -fn extract_token(headers: &HeaderMap) -> Result<&str> { - let auth_header = headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .ok_or_else(|| IntegrationError::Auth("Missing Authorization header".to_string()))?; - - hypr_supabase_auth::SupabaseAuth::extract_token(auth_header) - .ok_or_else(|| IntegrationError::Auth("Invalid Authorization header".to_string())) -} diff --git a/crates/api-nango/src/routes/mod.rs b/crates/api-nango/src/routes/mod.rs index 97dfd5e717..bae6e45e62 100644 --- a/crates/api-nango/src/routes/mod.rs +++ b/crates/api-nango/src/routes/mod.rs @@ -1,4 +1,3 @@ -mod calendar; mod connect; mod webhook; @@ -7,21 +6,17 @@ use utoipa::OpenApi; use crate::state::AppState; -pub use calendar::ListEventsResponse; pub use connect::ConnectSessionResponse; pub use webhook::WebhookResponse; #[derive(OpenApi)] #[openapi( paths( - calendar::list_events, connect::create_connect_session, webhook::nango_webhook, ), components( schemas( - calendar::ListEventsRequest, - ListEventsResponse, ConnectSessionResponse, WebhookResponse, ) @@ -38,7 +33,6 @@ pub fn openapi() -> utoipa::openapi::OpenApi { pub fn router(state: AppState) -> Router { Router::new() - .route("/calendar/events", post(calendar::list_events)) .route("/connect-session", post(connect::create_connect_session)) .route("/webhook", post(webhook::nango_webhook)) .with_state(state) diff --git a/crates/api-subscription/Cargo.toml b/crates/api-subscription/Cargo.toml index 51771b66a2..6016cda918 100644 --- a/crates/api-subscription/Cargo.toml +++ b/crates/api-subscription/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -hypr-supabase-auth = { workspace = true } +hypr-api-auth = { workspace = true } +hypr-api-env = { workspace = true } utoipa = { workspace = true } diff --git a/crates/api-subscription/src/config.rs b/crates/api-subscription/src/config.rs index 6bec5d39c4..dca9f19c3c 100644 --- a/crates/api-subscription/src/config.rs +++ b/crates/api-subscription/src/config.rs @@ -1,4 +1,5 @@ -use std::sync::Arc; +use crate::StripeEnv; +use hypr_api_env::SupabaseEnv; #[derive(Clone)] pub struct SubscriptionConfig { @@ -7,37 +8,16 @@ pub struct SubscriptionConfig { pub stripe_api_key: String, pub stripe_monthly_price_id: String, pub stripe_yearly_price_id: String, - pub auth: Option>, } impl SubscriptionConfig { - pub fn new( - supabase_url: impl Into, - supabase_anon_key: impl Into, - stripe_api_key: impl Into, - ) -> Self { + pub fn new(supabase: &SupabaseEnv, stripe: &StripeEnv) -> Self { Self { - supabase_url: supabase_url.into(), - supabase_anon_key: supabase_anon_key.into(), - stripe_api_key: stripe_api_key.into(), - stripe_monthly_price_id: String::new(), - stripe_yearly_price_id: String::new(), - auth: None, + supabase_url: supabase.supabase_url.clone(), + supabase_anon_key: supabase.supabase_anon_key.clone(), + stripe_api_key: stripe.stripe_api_key.clone(), + stripe_monthly_price_id: stripe.stripe_monthly_price_id.clone(), + stripe_yearly_price_id: stripe.stripe_yearly_price_id.clone(), } } - - pub fn with_stripe_monthly_price(mut self, price_id: impl Into) -> Self { - self.stripe_monthly_price_id = price_id.into(); - self - } - - pub fn with_stripe_yearly_price(mut self, price_id: impl Into) -> Self { - self.stripe_yearly_price_id = price_id.into(); - self - } - - pub fn with_auth(mut self, auth: Arc) -> Self { - self.auth = Some(auth); - self - } } diff --git a/crates/api-subscription/src/env.rs b/crates/api-subscription/src/env.rs index 9943042bab..accdf66410 100644 --- a/crates/api-subscription/src/env.rs +++ b/crates/api-subscription/src/env.rs @@ -1,9 +1,7 @@ use serde::Deserialize; #[derive(Deserialize)] -pub struct Env { - pub supabase_url: String, - pub supabase_anon_key: String, +pub struct StripeEnv { pub stripe_api_key: String, pub stripe_monthly_price_id: String, pub stripe_yearly_price_id: String, diff --git a/crates/api-subscription/src/error.rs b/crates/api-subscription/src/error.rs index 2dc7c0c550..d5cd3f83c5 100644 --- a/crates/api-subscription/src/error.rs +++ b/crates/api-subscription/src/error.rs @@ -31,12 +31,6 @@ pub enum SubscriptionError { Internal(String), } -impl From for SubscriptionError { - fn from(err: hypr_supabase_auth::Error) -> Self { - Self::Auth(err.to_string()) - } -} - impl From for SubscriptionError { fn from(err: stripe::StripeError) -> Self { Self::Stripe(err.to_string()) diff --git a/crates/api-subscription/src/lib.rs b/crates/api-subscription/src/lib.rs index e9f82745a3..412b2788cc 100644 --- a/crates/api-subscription/src/lib.rs +++ b/crates/api-subscription/src/lib.rs @@ -6,7 +6,8 @@ mod state; mod supabase; pub use config::SubscriptionConfig; -pub use env::Env; +pub use env::StripeEnv; pub use error::{Result, SubscriptionError}; -pub use routes::{openapi, router}; +pub use hypr_api_env::SupabaseEnv; +pub use routes::{AuthContext, openapi, router}; pub use state::AppState; diff --git a/crates/api-subscription/src/routes/billing.rs b/crates/api-subscription/src/routes/billing.rs index 0b1c66c38e..c249f80ec3 100644 --- a/crates/api-subscription/src/routes/billing.rs +++ b/crates/api-subscription/src/routes/billing.rs @@ -1,7 +1,6 @@ use axum::{ - Json, + Extension, Json, extract::{Query, State}, - http::HeaderMap, }; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -15,8 +14,9 @@ use stripe_billing::subscription::{ use stripe_core::customer::CreateCustomer; use utoipa::{IntoParams, ToSchema}; +use hypr_api_auth::AuthContext; + use crate::error::{Result, SubscriptionError}; -use crate::routes::rpc::extract_token; use crate::state::AppState; #[derive(Debug, Deserialize, IntoParams)] @@ -50,7 +50,7 @@ struct Profile { #[utoipa::path( post, - path = "/start-trial", + path = "/subscription/start-trial", params(StartTrialQuery), responses( (status = 200, description = "Trial started successfully", body = StartTrialResponse), @@ -65,32 +65,20 @@ struct Profile { pub async fn start_trial( State(state): State, Query(query): Query, - headers: HeaderMap, + Extension(auth): Extension, ) -> Result> { - let auth_token = extract_token(&headers)?; - - let auth = state - .config - .auth - .as_ref() - .ok_or_else(|| SubscriptionError::Auth("Auth not configured".to_string()))?; - - let claims = auth - .verify_token(auth_token) - .await - .map_err(|e| SubscriptionError::Auth(e.to_string()))?; - let user_id = claims.sub; + let user_id = &auth.claims.sub; let can_start: bool = state .supabase - .rpc("can_start_trial", auth_token, None) + .rpc("can_start_trial", &auth.token, None) .await?; if !can_start { return Ok(Json(StartTrialResponse { started: false })); } - let customer_id = get_or_create_customer(&state, auth_token, &user_id).await?; + let customer_id = get_or_create_customer(&state, &auth.token, user_id).await?; let customer_id = customer_id .ok_or_else(|| SubscriptionError::Internal("stripe_customer_id_missing".to_string()))?; @@ -100,7 +88,7 @@ pub async fn start_trial( Interval::Yearly => &state.config.stripe_yearly_price_id, }; - create_trial_subscription(&state.stripe, &customer_id, price_id, &user_id).await?; + create_trial_subscription(&state.stripe, &customer_id, price_id, user_id).await?; Ok(Json(StartTrialResponse { started: true })) } diff --git a/crates/api-subscription/src/routes/mod.rs b/crates/api-subscription/src/routes/mod.rs index 3b08b41245..0a2b90d337 100644 --- a/crates/api-subscription/src/routes/mod.rs +++ b/crates/api-subscription/src/routes/mod.rs @@ -10,7 +10,7 @@ use utoipa::OpenApi; use crate::state::AppState; pub use billing::{Interval, StartTrialResponse}; -pub use rpc::CanStartTrialResponse; +pub use rpc::{AuthContext, CanStartTrialResponse}; #[derive(OpenApi)] #[openapi( diff --git a/crates/api-subscription/src/routes/rpc.rs b/crates/api-subscription/src/routes/rpc.rs index 2b94527fbe..8d6eb89b30 100644 --- a/crates/api-subscription/src/routes/rpc.rs +++ b/crates/api-subscription/src/routes/rpc.rs @@ -1,10 +1,12 @@ -use axum::{Json, extract::State, http::HeaderMap}; +use axum::{Extension, Json, extract::State}; use serde::Serialize; use utoipa::ToSchema; -use crate::error::{Result, SubscriptionError}; +use crate::error::Result; use crate::state::AppState; +pub use hypr_api_auth::AuthContext; + #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CanStartTrialResponse { @@ -14,7 +16,7 @@ pub struct CanStartTrialResponse { #[utoipa::path( get, - path = "/can-start-trial", + path = "/subscription/can-start-trial", responses( (status = 200, description = "Check successful", body = CanStartTrialResponse), (status = 401, description = "Unauthorized"), @@ -27,23 +29,11 @@ pub struct CanStartTrialResponse { )] pub async fn can_start_trial( State(state): State, - headers: HeaderMap, + Extension(auth): Extension, ) -> Result> { - let auth_token = extract_token(&headers)?; - - let auth = state - .config - .auth - .as_ref() - .ok_or_else(|| SubscriptionError::Auth("Auth not configured".to_string()))?; - - auth.verify_token(auth_token) - .await - .map_err(|e| SubscriptionError::Auth(e.to_string()))?; - let can_start: bool = state .supabase - .rpc("can_start_trial", auth_token, None) + .rpc("can_start_trial", &auth.token, None) .await .unwrap_or(false); @@ -51,13 +41,3 @@ pub async fn can_start_trial( can_start_trial: can_start, })) } - -pub fn extract_token(headers: &HeaderMap) -> Result<&str> { - let auth_header = headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .ok_or_else(|| SubscriptionError::Auth("Missing Authorization header".to_string()))?; - - hypr_supabase_auth::SupabaseAuth::extract_token(auth_header) - .ok_or_else(|| SubscriptionError::Auth("Invalid Authorization header".to_string())) -} diff --git a/crates/google-calendar/src/client.rs b/crates/google-calendar/src/client.rs index 189cd1f601..f2bff329cb 100644 --- a/crates/google-calendar/src/client.rs +++ b/crates/google-calendar/src/client.rs @@ -1,7 +1,9 @@ use hypr_http::HttpClient; use crate::error::Error; -use crate::types::{ListEventsRequest, ListEventsResponse}; +use crate::types::{ + CreateEventRequest, GoogleEvent, ListCalendarsResponse, ListEventsRequest, ListEventsResponse, +}; pub struct GoogleCalendarClient { http: C, @@ -12,6 +14,16 @@ impl GoogleCalendarClient { Self { http } } + pub async fn list_calendars(&self) -> Result { + let bytes = self + .http + .get("/calendar/v3/users/me/calendarList") + .await + .map_err(Error::Http)?; + let response: ListCalendarsResponse = serde_json::from_slice(&bytes)?; + Ok(response) + } + pub async fn list_events(&self, req: ListEventsRequest) -> Result { let calendar_id = &req.calendar_id; let path = format!("/calendar/v3/calendars/{calendar_id}/events"); @@ -53,4 +65,14 @@ impl GoogleCalendarClient { let response: ListEventsResponse = serde_json::from_slice(&bytes)?; Ok(response) } + + pub async fn create_event(&self, req: CreateEventRequest) -> Result { + let calendar_id = &req.calendar_id; + let path = format!("/calendar/v3/calendars/{calendar_id}/events"); + + let body = serde_json::to_vec(&req.event)?; + let bytes = self.http.post(&path, body).await.map_err(Error::Http)?; + let event: GoogleEvent = serde_json::from_slice(&bytes)?; + Ok(event) + } } diff --git a/crates/google-calendar/src/error.rs b/crates/google-calendar/src/error.rs index 2aa149f8d3..7ba5d35df2 100644 --- a/crates/google-calendar/src/error.rs +++ b/crates/google-calendar/src/error.rs @@ -5,6 +5,6 @@ pub enum Error { #[error("HTTP client error: {0}")] Http(Box), - #[error("Deserialization error: {0}")] - Deserialization(#[from] serde_json::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), } diff --git a/crates/google-calendar/src/types.rs b/crates/google-calendar/src/types.rs index e8e9c9a477..86520d7ae6 100644 --- a/crates/google-calendar/src/types.rs +++ b/crates/google-calendar/src/types.rs @@ -1,6 +1,59 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GoogleCalendar { + pub id: String, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub time_zone: Option, + #[serde(default)] + pub color_id: Option, + #[serde(default)] + pub background_color: Option, + #[serde(default)] + pub foreground_color: Option, + #[serde(default)] + pub selected: Option, + #[serde(default)] + pub primary: Option, + #[serde(default)] + pub access_role: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListCalendarsResponse { + pub kind: String, + pub etag: String, + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateEventRequest { + pub calendar_id: String, + pub event: CreateEventBody, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateEventBody { + pub summary: String, + pub start: GoogleEventDateTime, + pub end: GoogleEventDateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attendees: Option>, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListEventsRequest { pub calendar_id: String, diff --git a/crates/nango/src/client.rs b/crates/nango/src/client.rs index 4ccea60a68..0f8d41f145 100644 --- a/crates/nango/src/client.rs +++ b/crates/nango/src/client.rs @@ -1,7 +1,6 @@ use serde::de::DeserializeOwned; -use crate::proxy::NangoProxyBuilder; -use crate::types::NangoIntegration; +use crate::proxy::NangoProxy; pub(crate) fn append_query(url: &mut url::Url, key: &str, value: &str) { url.query_pairs_mut().append_pair(key, value); @@ -19,6 +18,12 @@ pub struct NangoClient { pub(crate) api_base: url::Url, } +impl NangoClient { + pub fn builder() -> NangoClientBuilder { + NangoClientBuilder::default() + } +} + impl NangoClientBuilder { pub fn api_base(mut self, api_base: impl Into) -> Self { self.api_base = Some(api_base.into()); @@ -74,14 +79,28 @@ pub(crate) async fn parse_response( Ok(response.json().await?) } +pub struct NangoIntegration<'a> { + client: &'a NangoClient, + integration_id: String, +} + impl NangoClient { - pub fn for_connection( - &self, - integration: NangoIntegration, - connection_id: impl Into, - ) -> NangoProxyBuilder<'_> { - NangoProxyBuilder::new(self, integration.into(), connection_id.into()) - .retries(3) - .retry_on(vec![429, 500, 502, 503, 504]) + pub fn integration(&self, integration_id: impl Into) -> NangoIntegration<'_> { + NangoIntegration { + client: self, + integration_id: integration_id.into(), + } + } +} + +impl<'a> NangoIntegration<'a> { + pub fn connection(&self, connection_id: impl Into) -> NangoProxy<'a> { + NangoProxy::new( + self.client, + self.integration_id.clone(), + connection_id.into(), + ) + .retries(3) + .retry_on(vec![429, 500, 502, 503, 504]) } } diff --git a/crates/nango/src/error.rs b/crates/nango/src/error.rs index a230c5d328..00ee4f3692 100644 --- a/crates/nango/src/error.rs +++ b/crates/nango/src/error.rs @@ -6,8 +6,6 @@ pub enum Error { Api(u16, String), #[error(transparent)] Request(#[from] reqwest::Error), - #[error("unknown integration")] - UnknownIntegration, #[error("missing api key")] MissingApiKey, #[error("missing api base")] diff --git a/crates/nango/src/lib.rs b/crates/nango/src/lib.rs index 8640a8d9bd..b8b2c782bd 100644 --- a/crates/nango/src/lib.rs +++ b/crates/nango/src/lib.rs @@ -6,18 +6,17 @@ mod integration; pub mod proxy; mod sync; mod trigger; -mod types; pub mod webhook; +pub use client::NangoIntegration; pub use client::*; pub use connect_session::*; pub use connection::*; pub use error::*; pub use integration::*; -pub use proxy::NangoProxyBuilder; +pub use proxy::NangoProxy; pub use sync::*; pub use trigger::*; -pub use types::*; pub use webhook::*; macro_rules! common_derives { @@ -76,7 +75,8 @@ mod tests { .unwrap(); let _ = nango_client - .for_connection(NangoIntegration::GoogleCalendar, "connection") + .integration("google-calendar") + .connection("connection") .get("/users") .unwrap(); } diff --git a/crates/nango/src/proxy.rs b/crates/nango/src/proxy.rs index 0fc6fdf4ab..f1c8bd90f1 100644 --- a/crates/nango/src/proxy.rs +++ b/crates/nango/src/proxy.rs @@ -1,7 +1,7 @@ use crate::client::NangoClient; #[derive(Clone)] -pub struct NangoProxyBuilder<'a> { +pub struct NangoProxy<'a> { nango: &'a NangoClient, integration_id: String, connection_id: String, @@ -11,7 +11,7 @@ pub struct NangoProxyBuilder<'a> { decompress: Option, } -impl<'a> NangoProxyBuilder<'a> { +impl<'a> NangoProxy<'a> { pub(crate) fn new( nango: &'a NangoClient, integration_id: String, diff --git a/crates/nango/src/types.rs b/crates/nango/src/types.rs deleted file mode 100644 index 13acce446d..0000000000 --- a/crates/nango/src/types.rs +++ /dev/null @@ -1,34 +0,0 @@ -use crate::common_derives; - -common_derives! { - #[derive(strum::AsRefStr, std::hash::Hash)] - pub enum NangoIntegration { - #[serde(rename = "google-calendar")] - #[strum(serialize = "google-calendar")] - GoogleCalendar, - #[serde(rename = "outlook-calendar")] - #[strum(serialize = "outlook-calendar")] - OutlookCalendar, - } -} - -impl TryFrom for NangoIntegration { - type Error = crate::Error; - - fn try_from(value: String) -> Result { - match value.as_str() { - "google-calendar" => Ok(NangoIntegration::GoogleCalendar), - "outlook-calendar" => Ok(NangoIntegration::OutlookCalendar), - _ => Err(crate::Error::UnknownIntegration), - } - } -} - -impl From for String { - fn from(integration: NangoIntegration) -> Self { - match integration { - NangoIntegration::GoogleCalendar => "google-calendar".to_string(), - NangoIntegration::OutlookCalendar => "outlook-calendar".to_string(), - } - } -} From 5af69cbc34b4846a7fe1d180396da713c46cf804 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:44:23 +0000 Subject: [PATCH 2/3] Add missing auth middleware to api-nango and api-subscription routers Co-Authored-By: yujonglee --- Cargo.lock | 12 ++++++++++++ crates/api-nango/src/config.rs | 6 ++++-- crates/api-nango/src/routes/mod.rs | 8 +++++++- crates/api-nango/src/state.rs | 10 +++++++++- crates/api-subscription/src/routes/mod.rs | 8 +++++++- crates/api-subscription/src/state.rs | 4 ++++ 6 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cc0ad2dd7..79aa22972d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17612,6 +17612,10 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-cli2" +version = "0.1.0" + [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.2" @@ -17627,6 +17631,10 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-db" +version = "0.1.0" + [[package]] name = "tauri-plugin-db2" version = "0.1.0" @@ -17729,6 +17737,10 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-export" +version = "0.1.0" + [[package]] name = "tauri-plugin-extensions" version = "0.1.0" diff --git a/crates/api-nango/src/config.rs b/crates/api-nango/src/config.rs index 157a6c46be..719c3c86b4 100644 --- a/crates/api-nango/src/config.rs +++ b/crates/api-nango/src/config.rs @@ -1,19 +1,21 @@ use crate::NangoWebhookEnv; -use hypr_api_env::NangoEnv; +use hypr_api_env::{NangoEnv, SupabaseEnv}; #[derive(Clone)] pub struct IntegrationConfig { pub nango_api_base: String, pub nango_api_key: String, pub nango_webhook_secret: String, + pub supabase_url: String, } impl IntegrationConfig { - pub fn new(nango: &NangoEnv, webhook: &NangoWebhookEnv) -> Self { + pub fn new(nango: &NangoEnv, webhook: &NangoWebhookEnv, supabase: &SupabaseEnv) -> Self { Self { nango_api_base: nango.nango_api_base.clone(), nango_api_key: nango.nango_api_key.clone(), nango_webhook_secret: webhook.nango_webhook_secret.clone(), + supabase_url: supabase.supabase_url.clone(), } } } diff --git a/crates/api-nango/src/routes/mod.rs b/crates/api-nango/src/routes/mod.rs index bae6e45e62..6725d49a34 100644 --- a/crates/api-nango/src/routes/mod.rs +++ b/crates/api-nango/src/routes/mod.rs @@ -1,7 +1,7 @@ mod connect; mod webhook; -use axum::{Router, routing::post}; +use axum::{Router, middleware, routing::post}; use utoipa::OpenApi; use crate::state::AppState; @@ -32,8 +32,14 @@ pub fn openapi() -> utoipa::openapi::OpenApi { } pub fn router(state: AppState) -> Router { + let auth_state = state.auth.clone(); + Router::new() .route("/connect-session", post(connect::create_connect_session)) + .route_layer(middleware::from_fn_with_state( + auth_state, + hypr_api_auth::require_auth, + )) .route("/webhook", post(webhook::nango_webhook)) .with_state(state) } diff --git a/crates/api-nango/src/state.rs b/crates/api-nango/src/state.rs index bed142e02d..222e41e17d 100644 --- a/crates/api-nango/src/state.rs +++ b/crates/api-nango/src/state.rs @@ -1,3 +1,4 @@ +use hypr_api_auth::AuthState; use hypr_nango::NangoClient; use crate::config::IntegrationConfig; @@ -7,6 +8,7 @@ use crate::error::IntegrationError; pub struct AppState { pub config: IntegrationConfig, pub nango: NangoClient, + pub auth: AuthState, } impl AppState { @@ -17,6 +19,12 @@ impl AppState { .build() .map_err(|e| IntegrationError::Nango(e.to_string()))?; - Ok(Self { config, nango }) + let auth = AuthState::new(&config.supabase_url); + + Ok(Self { + config, + nango, + auth, + }) } } diff --git a/crates/api-subscription/src/routes/mod.rs b/crates/api-subscription/src/routes/mod.rs index 0a2b90d337..43ae6e73ad 100644 --- a/crates/api-subscription/src/routes/mod.rs +++ b/crates/api-subscription/src/routes/mod.rs @@ -2,7 +2,7 @@ mod billing; mod rpc; use axum::{ - Router, + Router, middleware, routing::{get, post}, }; use utoipa::OpenApi; @@ -36,8 +36,14 @@ pub fn openapi() -> utoipa::openapi::OpenApi { } pub fn router(state: AppState) -> Router { + let auth_state = state.auth.clone(); + Router::new() .route("/can-start-trial", get(rpc::can_start_trial)) .route("/start-trial", post(billing::start_trial)) + .route_layer(middleware::from_fn_with_state( + auth_state, + hypr_api_auth::require_auth, + )) .with_state(state) } diff --git a/crates/api-subscription/src/state.rs b/crates/api-subscription/src/state.rs index 1a2a479f68..ced3a4c7e1 100644 --- a/crates/api-subscription/src/state.rs +++ b/crates/api-subscription/src/state.rs @@ -1,3 +1,4 @@ +use hypr_api_auth::AuthState; use stripe::Client as StripeClient; use crate::config::SubscriptionConfig; @@ -8,6 +9,7 @@ pub struct AppState { pub config: SubscriptionConfig, pub supabase: SupabaseClient, pub stripe: StripeClient, + pub auth: AuthState, } impl AppState { @@ -18,11 +20,13 @@ impl AppState { ); let stripe = StripeClient::new(&config.stripe_api_key); + let auth = AuthState::new(&config.supabase_url); Self { config, supabase, stripe, + auth, } } } From 961e1717dbddf5f97e399cb558b2feb5de40a7d2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:45:27 +0000 Subject: [PATCH 3/3] Revert Cargo.lock stub artifacts Co-Authored-By: yujonglee --- Cargo.lock | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 79aa22972d..0cc0ad2dd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17612,10 +17612,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tauri-plugin-cli2" -version = "0.1.0" - [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.2" @@ -17631,10 +17627,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "tauri-plugin-db" -version = "0.1.0" - [[package]] name = "tauri-plugin-db2" version = "0.1.0" @@ -17737,10 +17729,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-export" -version = "0.1.0" - [[package]] name = "tauri-plugin-extensions" version = "0.1.0"