From 2603284460cb89dec5fdf7fcff9a1bbae6beb23d Mon Sep 17 00:00:00 2001 From: Damien Coles Date: Mon, 26 Jan 2026 01:32:58 -0500 Subject: [PATCH] public-ready-init --- .dockerignore | 9 + .env.example | 18 ++ .gitignore | 7 + Cargo.toml | 39 +++ Dockerfile | 54 ++++ README.md | 252 ++++++++++++++++ docker-compose.yml | 20 ++ src/config.rs | 25 ++ src/handlers/events.rs | 70 +++++ src/handlers/health.rs | 11 + src/handlers/mod.rs | 2 + src/main.rs | 60 ++++ src/middleware/auth.rs | 81 ++++++ src/middleware/mod.rs | 1 + src/models/error.rs | 51 ++++ src/models/event.rs | 88 ++++++ src/models/mod.rs | 2 + src/services/google_calendar.rs | 499 ++++++++++++++++++++++++++++++++ src/services/mod.rs | 1 + 19 files changed, 1290 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 src/config.rs create mode 100644 src/handlers/events.rs create mode 100644 src/handlers/health.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/main.rs create mode 100644 src/middleware/auth.rs create mode 100644 src/middleware/mod.rs create mode 100644 src/models/error.rs create mode 100644 src/models/event.rs create mode 100644 src/models/mod.rs create mode 100644 src/services/google_calendar.rs create mode 100644 src/services/mod.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ccaf86c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +target/ +.git/ +.gitignore +README.md +.env +.env.* +*.log +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bcb073a --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Server configuration +HOST=127.0.0.1 +PORT=4000 + +# API Key for authentication (generate a secure random string) +API_KEY=your-secret-api-key-here + +# Google Calendar ID to manage +# For a primary calendar: user@yourdomain.com +# For a shared calendar: abc123@group.calendar.google.com +GOOGLE_CALENDAR_ID=your-calendar-id@group.calendar.google.com + +# Google Service Account Key +# Can be provided as: +# 1. File path: /path/to/service_account_key.json +# 2. Base64 encoded JSON: cat service-account.json | base64 -w 0 +# 3. Raw JSON string (not recommended for production) +GOOGLE_SERVICE_ACCOUNT_KEY=/path/to/service_account_key.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15ab1b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/.env +/service_account_key.json +Cargo.lock +*.log +.DS_Store +.idea \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..15a3dd9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "calendar-microservice" +version = "0.1.0" +edition = "2021" +description = "A Rust microservice for Google Calendar API integration with domain-wide delegation support" +license = "MIT" + +[dependencies] +# Web framework +actix-web = "4.4" +actix-cors = "0.7.1" + +# HTTP client for Google Calendar API +reqwest = { version = "0.12.22", features = ["json"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Environment variables +dotenv = "0.15" + +# Logging +env_logger = "0.11.8" +log = "0.4" + +# Error handling +thiserror = "2.0.12" + +# Time handling +chrono = { version = "0.4", features = ["serde"] } + +# Base64 for service account credentials +base64 = "0.22.1" + +# JWT for Google service account auth +jsonwebtoken = "9.0" +futures-util = "0.3.31" +urlencoding = "2.1.3" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e572b4a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# Multi-stage build for optimized production image +FROM rust:1.83 as builder + +WORKDIR /app + +# Copy manifests for dependency caching +COPY Cargo.toml Cargo.lock* ./ + +# Create dummy source for dependency caching +RUN mkdir -p src && \ + echo "fn main() {}" > src/main.rs && \ + cargo build --release && \ + rm -rf src + +# Copy the actual source code +COPY src/ src/ + +# Force rebuild with actual source +RUN touch src/main.rs && rm -f target/release/calendar-microservice && cargo build --release + +# Runtime stage +FROM debian:bookworm-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates libssl3 curl && \ + rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -r -s /bin/false appuser + +# Copy the built binary +COPY --from=builder /app/target/release/calendar-microservice /app/calendar-microservice + +# Set ownership +RUN chown appuser:appuser /app/calendar-microservice + +# Switch to non-root user +USER appuser + +# Environment defaults +ENV HOST=0.0.0.0 +ENV PORT=4000 +ENV RUST_LOG=info + +EXPOSE 4000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:4000/api/v1/health || exit 1 + +CMD ["/app/calendar-microservice"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2feb884 --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# Calendar Microservice + +A Rust-based REST API microservice for Google Calendar integration using service account authentication with domain-wide delegation support. + +## Features + +- **Complete Event Management**: Create, read, update, delete, and list calendar events +- **Google Calendar API Integration**: Full integration with Google Calendar v3 API +- **Domain-Wide Delegation**: Impersonate users within your Google Workspace domain +- **API Key Authentication**: Secure access via `X-API-Key` header +- **Token Caching**: Efficient OAuth token management to minimize authentication requests +- **Docker Support**: Ready for containerized deployment + +## Prerequisites + +- Rust 1.75+ (for development) +- Docker & Docker Compose (for deployment) +- A Google Cloud project with Calendar API enabled +- A Google service account with domain-wide delegation configured + +## Quick Start + +### 1. Environment Setup + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +Configure your environment variables: + +```env +HOST=127.0.0.1 +PORT=4000 +API_KEY=your-secret-api-key-here +GOOGLE_CALENDAR_ID=your-calendar-id@group.calendar.google.com +GOOGLE_SERVICE_ACCOUNT_KEY=/path/to/service_account_key.json +``` + +### 2. Google Cloud Setup + +1. Create a service account in Google Cloud Console +2. Enable the Google Calendar API for your project +3. Download the service account key JSON file +4. Configure domain-wide delegation in Google Workspace Admin Console with scope: + - `https://www.googleapis.com/auth/calendar` + +### 3. Running the Service + +#### Development + +```bash +cargo run +``` + +#### Production (Docker) + +```bash +docker-compose up -d +``` + +The service will be available at `http://localhost:4000`. + +## API Documentation + +### Authentication + +All API endpoints (except `/api/v1/health`) require authentication via the `X-API-Key` header: + +``` +X-API-Key: your-secret-api-key-here +``` + +### Endpoints + +#### Health Check + +``` +GET /api/v1/health +``` + +Returns service health status. No authentication required. + +#### Create Event + +``` +POST /api/v1/events +Content-Type: application/json + +{ + "id": "optional-custom-id", + "summary": "Meeting with Team", + "description": "Weekly sync meeting", + "location": "Conference Room A", + "start": { + "dateTime": "2024-06-15T10:00:00Z", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2024-06-15T11:00:00Z", + "timeZone": "UTC" + }, + "attendees": [ + { + "email": "attendee@example.com", + "displayName": "John Doe" + } + ] +} +``` + +#### Get Event + +``` +GET /api/v1/events/{event_id} +``` + +#### List Events + +``` +GET /api/v1/events?timeMin=2024-06-01T00:00:00Z&timeMax=2024-06-30T23:59:59Z&maxResults=10&q=meeting +``` + +Query parameters: +- `timeMin`: Start of time range (RFC3339) +- `timeMax`: End of time range (RFC3339) +- `maxResults`: Maximum number of events to return +- `q`: Search term + +#### Update Event + +``` +PUT /api/v1/events/{event_id} +Content-Type: application/json + +{ + "summary": "Updated Meeting Title", + "description": "Updated description" +} +``` + +#### Delete Event + +``` +DELETE /api/v1/events/{event_id} +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `HOST` | Server bind address | `127.0.0.1` | +| `PORT` | Server port | `4000` | +| `API_KEY` | Secret key for API authentication | Required | +| `GOOGLE_CALENDAR_ID` | ID of the Google Calendar to manage | Required | +| `GOOGLE_SERVICE_ACCOUNT_KEY` | Path to service account key file, base64 encoded JSON, or raw JSON | Required | + +### Service Account Key Formats + +The `GOOGLE_SERVICE_ACCOUNT_KEY` can be provided in three formats: + +1. **File path**: `/path/to/service_account_key.json` +2. **Base64 encoded**: `cat service-account.json | base64 -w 0` +3. **Raw JSON**: `{"type": "service_account", ...}` (not recommended for production) + +### CORS Configuration + +CORS is configured in `src/main.rs`. Modify the allowed origins for your deployment: + +```rust +let cors = Cors::default() + .allowed_origin("https://your-app.com") + // ... +``` + +## Project Structure + +``` +├── Cargo.toml +├── Dockerfile +├── docker-compose.yml +├── .env.example +└── src/ + ├── main.rs # Application entry point + ├── config.rs # Configuration management + ├── handlers/ # HTTP request handlers + │ ├── events.rs # Event CRUD operations + │ └── health.rs # Health check endpoint + ├── middleware/ # HTTP middleware + │ └── auth.rs # API key authentication + ├── models/ # Data models + │ ├── error.rs # Error handling + │ └── event.rs # Event structures + └── services/ # Business logic + └── google_calendar.rs # Google Calendar API client +``` + +## Error Handling + +The service returns standard HTTP status codes: + +| Status | Description | +|--------|-------------| +| `200 OK` | Successful operation | +| `201 Created` | Event created successfully | +| `204 No Content` | Event deleted successfully | +| `400 Bad Request` | Invalid request data | +| `401 Unauthorized` | Invalid or missing API key | +| `404 Not Found` | Event not found | +| `502 Bad Gateway` | Google API error | +| `500 Internal Server Error` | Server error | + +Error responses include a JSON body: + +```json +{ + "error": "Error type", + "message": "Detailed error message" +} +``` + +## Development + +### Building from Source + +```bash +git clone +cd calendar-microservice +cargo build --release +``` + +### Running Tests + +```bash +cargo test +``` + +### Logging + +Set the `RUST_LOG` environment variable to control log levels: + +```bash +RUST_LOG=info cargo run +RUST_LOG=debug cargo run # More verbose +``` + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6b2ed92 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + calendar: + build: . + ports: + - "4000:4000" + env_file: + - .env + environment: + - HOST=0.0.0.0 + - RUST_LOG=info + volumes: + # Mount service account key if using file path + - ./service_account_key.json:/app/secrets/service_account_key.json:ro + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:4000/api/v1/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..30c4d45 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,25 @@ +use std::env; + +#[derive(Debug, Clone)] +pub struct Config { + pub host: String, + pub port: u16, + pub api_key: String, + pub google_calendar_id: String, + pub google_service_account_key: String, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Config { + host: env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()), + port: env::var("PORT") + .unwrap_or_else(|_| "4000".to_string()) + .parse() + .expect("PORT must be a valid number"), + api_key: env::var("API_KEY")?, + google_calendar_id: env::var("GOOGLE_CALENDAR_ID")?, + google_service_account_key: env::var("GOOGLE_SERVICE_ACCOUNT_KEY")?, + }) + } +} diff --git a/src/handlers/events.rs b/src/handlers/events.rs new file mode 100644 index 0000000..e03b290 --- /dev/null +++ b/src/handlers/events.rs @@ -0,0 +1,70 @@ +use crate::{ + config::Config, + models::{ + error::ServiceError, + event::{CreateEventRequest, ListEventsQuery, UpdateEventRequest}, + }, + services::google_calendar::GoogleCalendarService, +}; +use actix_web::{HttpResponse, Result, delete, get, post, put, web}; + +#[post("")] +pub async fn create_event( + config: web::Data, + payload: web::Json, +) -> Result { + let service = GoogleCalendarService::new(&config); + let event = service.create_event(payload.into_inner()).await?; + + Ok(HttpResponse::Created().json(event)) +} + +#[get("")] +pub async fn list_events( + config: web::Data, + query: web::Query, +) -> Result { + let service = GoogleCalendarService::new(&config); + let events = service.list_events(query.into_inner()).await?; + + Ok(HttpResponse::Ok().json(events)) +} + +#[get("/{event_id}")] +pub async fn get_event( + config: web::Data, + path: web::Path, +) -> Result { + let event_id = path.into_inner(); + let service = GoogleCalendarService::new(&config); + let event = service.get_event(&event_id).await?; + + Ok(HttpResponse::Ok().json(event)) +} + +#[put("/{event_id}")] +pub async fn update_event( + config: web::Data, + path: web::Path, + payload: web::Json, +) -> Result { + let event_id = path.into_inner(); + let service = GoogleCalendarService::new(&config); + let event = service + .update_event(&event_id, payload.into_inner()) + .await?; + + Ok(HttpResponse::Ok().json(event)) +} + +#[delete("/{event_id}")] +pub async fn delete_event( + config: web::Data, + path: web::Path, +) -> Result { + let event_id = path.into_inner(); + let service = GoogleCalendarService::new(&config); + service.delete_event(&event_id).await?; + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/src/handlers/health.rs b/src/handlers/health.rs new file mode 100644 index 0000000..5136d56 --- /dev/null +++ b/src/handlers/health.rs @@ -0,0 +1,11 @@ +use actix_web::{HttpResponse, Result, get}; +use serde_json::json; + +#[get("/health")] +pub async fn health_check() -> Result { + Ok(HttpResponse::Ok().json(json!({ + "status": "ok", + "service": "calendar-microservice", + "timestamp": chrono::Utc::now() + }))) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..688a185 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod events; +pub mod health; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f604e1d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,60 @@ +use actix_cors::Cors; +use actix_web::{App, HttpServer, middleware::Logger, web}; +use config::Config; +use dotenv::dotenv; + +mod config; +mod handlers; +mod middleware; +mod models; +mod services; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + // Load environment variables + dotenv().ok(); + + // Initialize logger + env_logger::init(); + + // Load configuration + let config = Config::from_env().expect("Failed to load configuration"); + let bind_address = format!("{}:{}", config.host, config.port); + + log::info!("Starting Calendar Service on {}", bind_address); + + HttpServer::new(move || { + // Configure CORS - modify these origins for your deployment + let cors = Cors::default() + .allowed_origin("http://localhost:5173") + .allowed_origin("http://127.0.0.1:5173") + .allowed_origin("http://localhost:3000") + .allowed_origin("http://127.0.0.1:3000") + .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) + .allowed_headers(vec!["X-API-Key", "Content-Type"]) + .supports_credentials() + .max_age(3600); + + App::new() + .app_data(web::Data::new(config.clone())) + .wrap(Logger::default()) + .service( + web::scope("/api/v1") + .wrap(cors) + // Place auth AFTER CORS so preflight isn't blocked + .wrap(middleware::auth::ApiKeyAuth) + .service(handlers::health::health_check) + .service( + web::scope("/events") + .service(handlers::events::create_event) + .service(handlers::events::get_event) + .service(handlers::events::list_events) + .service(handlers::events::update_event) + .service(handlers::events::delete_event), + ), + ) + }) + .bind(&bind_address)? + .run() + .await +} diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs new file mode 100644 index 0000000..d539b54 --- /dev/null +++ b/src/middleware/auth.rs @@ -0,0 +1,81 @@ +use actix_web::{ + Error, + dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, + http::Method, +}; +use futures_util::future::LocalBoxFuture; +use std::{ + future::{Ready, ready}, + rc::Rc, +}; + +use crate::{config::Config, models::error::ServiceError}; + +pub struct ApiKeyAuth; + +impl Transform for ApiKeyAuth +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Transform = ApiKeyAuthMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(ApiKeyAuthMiddleware { + service: Rc::new(service), + })) + } +} + +pub struct ApiKeyAuthMiddleware { + service: Rc, +} + +impl Service for ApiKeyAuthMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let service = Rc::clone(&self.service); + + Box::pin(async move { + // Allow CORS preflight requests unconditionally + if req.method() == Method::OPTIONS { + return service.call(req).await; + } + + // Skip auth for health check + if req.path() == "/api/v1/health" { + return service.call(req).await; + } + + // Check for the API key in the header + let api_key = req.headers().get("X-API-Key").and_then(|h| h.to_str().ok()); + + if let Some(provided_key) = api_key { + // Get the expected API key from app data + if let Some(config) = req.app_data::>() { + if provided_key == config.api_key { + return service.call(req).await; + } + } + } + + // Return unauthorized error + Err(ServiceError::Unauthorized.into()) + }) + } +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/src/models/error.rs b/src/models/error.rs new file mode 100644 index 0000000..7c612af --- /dev/null +++ b/src/models/error.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, ResponseError}; +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error("Event not found")] + NotFound, + + #[error("Unauthorized")] + Unauthorized, + + #[error("Google API error: {0}")] + GoogleApiError(String), + + #[error("Internal server error: {0}")] + InternalError(String), + + #[error("Invalid event ID format: {0}")] + InvalidEventId(String), +} + +#[derive(Serialize)] +struct ErrorResponse { + error: String, + message: String, +} + +impl ResponseError for ServiceError { + fn error_response(&self) -> HttpResponse { + let error_response = ErrorResponse { + error: self.to_string(), + message: match self { + ServiceError::NotFound => "Event not found".to_string(), + ServiceError::Unauthorized => "Invalid API key".to_string(), + ServiceError::GoogleApiError(msg) => format!("Google Calendar API error: {}", msg), + ServiceError::InternalError(msg) => format!("Internal error: {}", msg), + ServiceError::InvalidEventId(msg) => format!("Invalid event ID: {}", msg), + }, + }; + + match self { + ServiceError::InvalidEventId(_) => HttpResponse::BadRequest().json(error_response), + ServiceError::NotFound => HttpResponse::NotFound().json(error_response), + ServiceError::Unauthorized => HttpResponse::Unauthorized().json(error_response), + ServiceError::GoogleApiError(_) => HttpResponse::BadGateway().json(error_response), + ServiceError::InternalError(_) => { + HttpResponse::InternalServerError().json(error_response) + } + } + } +} diff --git a/src/models/event.rs b/src/models/event.rs new file mode 100644 index 0000000..64eed1a --- /dev/null +++ b/src/models/event.rs @@ -0,0 +1,88 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventReminder { + pub method: String, // "email" or "popup" + pub minutes: i32, // Minutes before the event +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventReminders { + #[serde(rename = "useDefault")] + pub use_default: bool, + pub overrides: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateEventRequest { + pub id: Option, + pub summary: String, + pub description: Option, + pub location: Option, + pub start: EventDateTime, + pub end: EventDateTime, + pub attendees: Option>, + pub reminders: Option, + #[serde(rename = "colorId")] + pub color_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateEventRequest { + pub summary: Option, + pub description: Option, + pub location: Option, + pub start: Option, + pub end: Option, + pub attendees: Option>, + pub reminders: Option, + #[serde(rename = "colorId")] + pub color_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct EventDateTime { + #[serde(rename = "dateTime")] + pub date_time: Option>, + pub date: Option, // For all-day events (YYYY-MM-DD) + #[serde(rename = "timeZone")] + pub time_zone: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Attendee { + pub email: String, + #[serde(rename = "displayName")] + pub display_name: Option, + pub optional: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Event { + pub id: String, + pub summary: String, + pub description: Option, + pub location: Option, + pub start: EventDateTime, + pub end: EventDateTime, + pub attendees: Option>, + pub reminders: Option, + #[serde(rename = "colorId")] + pub color_id: Option, + #[serde(rename = "htmlLink")] + pub html_link: Option, + pub created: Option>, + pub updated: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ListEventsQuery { + #[serde(rename = "timeMin")] + pub time_min: Option>, + #[serde(rename = "timeMax")] + pub time_max: Option>, + #[serde(rename = "maxResults")] + pub max_results: Option, + pub q: Option, // Search query +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..1610d23 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod event; diff --git a/src/services/google_calendar.rs b/src/services/google_calendar.rs new file mode 100644 index 0000000..2888b00 --- /dev/null +++ b/src/services/google_calendar.rs @@ -0,0 +1,499 @@ +use crate::{ + config::Config, + models::{ + error::ServiceError, + event::{CreateEventRequest, Event, ListEventsQuery, UpdateEventRequest}, + }, +}; +use base64::{Engine as _, engine::general_purpose}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use reqwest::{Client, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const GOOGLE_CALENDAR_SCOPE: &str = "https://www.googleapis.com/auth/calendar"; + +#[derive(Debug, Serialize, Deserialize)] +struct ServiceAccountKey { + #[serde(rename = "type")] + key_type: String, + project_id: String, + private_key_id: String, + private_key: String, + client_email: String, + client_id: String, + auth_uri: String, + token_uri: String, + auth_provider_x509_cert_url: String, + client_x509_cert_url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct JwtClaims { + iss: String, + scope: String, + aud: String, + exp: usize, + iat: usize, + sub: String, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[allow(dead_code)] + token_type: String, + expires_in: u32, +} + +pub struct GoogleCalendarService { + client: Client, + config: Config, + cached_token: Arc>>, +} + +#[derive(Debug, Clone)] +struct CachedToken { + access_token: String, + expires_at: u64, +} + +impl GoogleCalendarService { + pub fn new(config: &Config) -> Self { + Self { + client: Client::new(), + config: config.clone(), + cached_token: Arc::new(Mutex::new(None)), + } + } + + pub async fn create_event(&self, request: CreateEventRequest) -> Result { + if let Some(ref id) = request.id { + self.validate_event_id(id)?; + } + + let url = self.build_events_url(); + let event_body = self.build_create_event_body(request); + + let response = self + .make_authenticated_request(|client, token| { + client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .json(&event_body) + }) + .await?; + + self.handle_event_response(response).await + } + + pub async fn get_event(&self, event_id: &str) -> Result { + let url = self.build_event_url(event_id); + + let response = self + .make_authenticated_request(|client, token| { + client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + }) + .await?; + + self.handle_event_response_with_404(response).await + } + + pub async fn list_events(&self, query: ListEventsQuery) -> Result, ServiceError> { + let url = self.build_list_events_url(query); + + let response = self + .make_authenticated_request(|client, token| { + client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + }) + .await?; + + self.handle_list_events_response(response).await + } + + pub async fn update_event( + &self, + event_id: &str, + request: UpdateEventRequest, + ) -> Result { + let existing_event = self.get_event(event_id).await?; + let url = self.build_event_url(event_id); + let event_body = self.build_update_event_body(request, existing_event); + + let response = self + .make_authenticated_request(|client, token| { + client + .put(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .json(&event_body) + }) + .await?; + + self.handle_event_response_with_404(response).await + } + + pub async fn delete_event(&self, event_id: &str) -> Result<(), ServiceError> { + let url = self.build_event_url(event_id); + + let response = self + .make_authenticated_request(|client, token| { + client + .delete(&url) + .header("Authorization", format!("Bearer {}", token)) + }) + .await?; + + self.handle_delete_response(response).await + } + + // Private helper methods + + fn build_events_url(&self) -> String { + format!( + "https://www.googleapis.com/calendar/v3/calendars/{}/events", + self.config.google_calendar_id + ) + } + + fn build_event_url(&self, event_id: &str) -> String { + format!( + "https://www.googleapis.com/calendar/v3/calendars/{}/events/{}", + self.config.google_calendar_id, + urlencoding::encode(event_id) + ) + } + + fn build_list_events_url(&self, query: ListEventsQuery) -> String { + let mut url = self.build_events_url(); + let mut params = Vec::new(); + + if let Some(time_min) = query.time_min { + params.push(("timeMin", time_min.to_rfc3339())); + } + if let Some(time_max) = query.time_max { + params.push(("timeMax", time_max.to_rfc3339())); + } + if let Some(max_results) = query.max_results { + params.push(("maxResults", max_results.to_string())); + } + if let Some(q) = query.q { + params.push(("q", q)); + } + + if !params.is_empty() { + url.push('?'); + url.push_str( + ¶ms + .into_iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v))) + .collect::>() + .join("&"), + ); + } + + url + } + + fn build_create_event_body(&self, request: CreateEventRequest) -> Value { + let mut event_body = json!({ + "summary": request.summary, + "start": request.start, + "end": request.end + }); + + if let Some(id) = request.id { + event_body["id"] = json!(id); + } + if let Some(description) = request.description { + event_body["description"] = json!(description); + } + if let Some(location) = request.location { + event_body["location"] = json!(location); + } + if let Some(attendees) = request.attendees { + event_body["attendees"] = json!(attendees); + } + if let Some(reminders) = request.reminders { + event_body["reminders"] = json!(reminders); + } + if let Some(color_id) = request.color_id { + event_body["colorId"] = json!(color_id); + } + + event_body + } + + fn build_update_event_body(&self, request: UpdateEventRequest, existing: Event) -> Value { + let mut event_body = json!({ + "summary": request.summary.unwrap_or(existing.summary), + "start": request.start.unwrap_or(existing.start), + "end": request.end.unwrap_or(existing.end) + }); + + if let Some(description) = request.description.or(existing.description) { + event_body["description"] = json!(description); + } + if let Some(location) = request.location.or(existing.location) { + event_body["location"] = json!(location); + } + if let Some(attendees) = request.attendees.or(existing.attendees) { + event_body["attendees"] = json!(attendees); + } + if let Some(reminders) = request.reminders.or(existing.reminders) { + event_body["reminders"] = json!(reminders); + } + if let Some(color_id) = request.color_id.or(existing.color_id) { + event_body["colorId"] = json!(color_id); + } + + event_body + } + + async fn make_authenticated_request( + &self, + request_builder: F, + ) -> Result + where + F: FnOnce(&Client, &str) -> reqwest::RequestBuilder, + { + let access_token = self.get_access_token().await?; + let response = request_builder(&self.client, &access_token) + .send() + .await + .map_err(|e| ServiceError::InternalError(format!("Request failed: {}", e)))?; + + Ok(response) + } + + async fn handle_event_response(&self, response: Response) -> Result { + if response.status().is_success() { + response.json().await.map_err(|e| { + ServiceError::InternalError(format!("Failed to parse response: {}", e)) + }) + } else { + Err(self.handle_error_response(response).await) + } + } + + async fn handle_event_response_with_404( + &self, + response: Response, + ) -> Result { + match response.status().as_u16() { + 200 => response.json().await.map_err(|e| { + ServiceError::InternalError(format!("Failed to parse response: {}", e)) + }), + 404 => Err(ServiceError::NotFound), + _ => Err(self.handle_error_response(response).await), + } + } + + async fn handle_list_events_response( + &self, + response: Response, + ) -> Result, ServiceError> { + if response.status().is_success() { + let json: Value = response.json().await.map_err(|e| { + ServiceError::InternalError(format!("Failed to parse response: {}", e)) + })?; + + let events = json["items"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|item| serde_json::from_value(item.clone()).ok()) + .collect(); + + Ok(events) + } else { + Err(self.handle_error_response(response).await) + } + } + + async fn handle_delete_response(&self, response: Response) -> Result<(), ServiceError> { + match response.status().as_u16() { + 204 => Ok(()), + 404 => Err(ServiceError::NotFound), + _ => Err(self.handle_error_response(response).await), + } + } + + async fn handle_error_response(&self, response: Response) -> ServiceError { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + match status.as_u16() { + 401 => ServiceError::Unauthorized, + _ => ServiceError::GoogleApiError(format!("HTTP {}: {}", status, error_text)), + } + } + + fn validate_event_id(&self, id: &str) -> Result<(), ServiceError> { + if id.len() < 5 || id.len() > 1024 { + return Err(ServiceError::InvalidEventId( + "ID must be between 5 and 1024 characters".to_string(), + )); + } + + for ch in id.chars() { + if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() { + return Err(ServiceError::InvalidEventId( + "ID can only contain lowercase letters a-v and digits 0-9".to_string(), + )); + } + if ch.is_ascii_lowercase() && ch > 'v' { + return Err(ServiceError::InvalidEventId( + "ID can only contain lowercase letters a-v and digits 0-9".to_string(), + )); + } + } + + Ok(()) + } + + async fn get_access_token(&self) -> Result { + // Check if we have a cached token that's still valid + { + let cached_token = self.cached_token.lock().map_err(|e| { + ServiceError::InternalError(format!("Failed to lock token cache: {}", e)) + })?; + + if let Some(cached) = cached_token.as_ref() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if cached.expires_at > now + 60 { + // 60-second buffer + return Ok(cached.access_token.clone()); + } + } + } // Lock is released here + + // Get a new token + let service_account = self.parse_service_account_key()?; + let jwt = self.create_jwt(&service_account)?; + let token_response = self.exchange_jwt_for_token(&jwt).await?; + + // Cache the new token + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + token_response.expires_in as u64; + + let new_token = CachedToken { + access_token: token_response.access_token.clone(), + expires_at, + }; + + // Update the cache + { + let mut cached_token = self.cached_token.lock().map_err(|e| { + ServiceError::InternalError(format!("Failed to lock token cache: {}", e)) + })?; + *cached_token = Some(new_token); + } + + Ok(token_response.access_token) + } + + fn parse_service_account_key(&self) -> Result { + // Read from the file if the value is a file path + let key_data = if self.config.google_service_account_key.starts_with('/') + || self.config.google_service_account_key.starts_with("./") + { + // Read from the file + std::fs::read_to_string(&self.config.google_service_account_key).map_err(|e| { + ServiceError::InternalError(format!( + "Failed to read service account key file: {}", + e + )) + })? + } else if self.config.google_service_account_key.starts_with('{') { + // Direct JSON + self.config.google_service_account_key.clone() + } else { + // Assume it's base64 encoded + let decoded = general_purpose::STANDARD + .decode(&self.config.google_service_account_key) + .map_err(|e| { + ServiceError::InternalError(format!( + "Invalid base64 service account key: {}", + e + )) + })?; + + String::from_utf8(decoded).map_err(|e| { + ServiceError::InternalError(format!("Invalid UTF-8 in service account key: {}", e)) + })? + }; + + serde_json::from_str(&key_data).map_err(|e| { + ServiceError::InternalError(format!("Invalid service account key format: {}", e)) + }) + } + + fn create_jwt(&self, service_account: &ServiceAccountKey) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as usize; + + let claims = JwtClaims { + iss: service_account.client_email.clone(), + scope: GOOGLE_CALENDAR_SCOPE.to_string(), + aud: GOOGLE_TOKEN_URL.to_string(), + exp: now + 3600, // 1 hour + iat: now, + sub: self.config.google_calendar_id.clone(), + }; + + let encoding_key = EncodingKey::from_rsa_pem(service_account.private_key.as_bytes()) + .map_err(|e| ServiceError::InternalError(format!("Invalid private key: {}", e)))?; + + encode(&Header::new(Algorithm::RS256), &claims, &encoding_key) + .map_err(|e| ServiceError::InternalError(format!("Failed to create JWT: {}", e))) + } + + async fn exchange_jwt_for_token(&self, jwt: &str) -> Result { + let response = self + .client + .post(GOOGLE_TOKEN_URL) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", jwt), + ]) + .send() + .await + .map_err(|e| ServiceError::InternalError(format!("Token exchange failed: {}", e)))?; + + if response.status().is_success() { + response.json().await.map_err(|e| { + ServiceError::InternalError(format!("Failed to parse token response: {}", e)) + }) + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Err(ServiceError::InternalError(format!( + "Token exchange failed: {}", + error_text + ))) + } + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..64a3f75 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1 @@ +pub mod google_calendar;