public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 01:32:58 -05:00
commit 2603284460
19 changed files with 1290 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
target/
.git/
.gitignore
README.md
.env
.env.*
*.log
.DS_Store
Thumbs.db

18
.env.example Normal file
View File

@ -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

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/target
/.env
/service_account_key.json
Cargo.lock
*.log
.DS_Store
.idea

39
Cargo.toml Normal file
View File

@ -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"

54
Dockerfile Normal file
View File

@ -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"]

252
README.md Normal file
View File

@ -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 <repository>
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

20
docker-compose.yml Normal file
View File

@ -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

25
src/config.rs Normal file
View File

@ -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<Self, env::VarError> {
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")?,
})
}
}

70
src/handlers/events.rs Normal file
View File

@ -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<Config>,
payload: web::Json<CreateEventRequest>,
) -> Result<HttpResponse, ServiceError> {
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<Config>,
query: web::Query<ListEventsQuery>,
) -> Result<HttpResponse, ServiceError> {
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<Config>,
path: web::Path<String>,
) -> Result<HttpResponse, ServiceError> {
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<Config>,
path: web::Path<String>,
payload: web::Json<UpdateEventRequest>,
) -> Result<HttpResponse, ServiceError> {
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<Config>,
path: web::Path<String>,
) -> Result<HttpResponse, ServiceError> {
let event_id = path.into_inner();
let service = GoogleCalendarService::new(&config);
service.delete_event(&event_id).await?;
Ok(HttpResponse::NoContent().finish())
}

11
src/handlers/health.rs Normal file
View File

@ -0,0 +1,11 @@
use actix_web::{HttpResponse, Result, get};
use serde_json::json;
#[get("/health")]
pub async fn health_check() -> Result<HttpResponse> {
Ok(HttpResponse::Ok().json(json!({
"status": "ok",
"service": "calendar-microservice",
"timestamp": chrono::Utc::now()
})))
}

2
src/handlers/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod events;
pub mod health;

60
src/main.rs Normal file
View File

@ -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
}

81
src/middleware/auth.rs Normal file
View File

@ -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<S, B> Transform<S, ServiceRequest> for ApiKeyAuth
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Transform = ApiKeyAuthMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(ApiKeyAuthMiddleware {
service: Rc::new(service),
}))
}
}
pub struct ApiKeyAuthMiddleware<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for ApiKeyAuthMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
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::<actix_web::web::Data<Config>>() {
if provided_key == config.api_key {
return service.call(req).await;
}
}
}
// Return unauthorized error
Err(ServiceError::Unauthorized.into())
})
}
}

1
src/middleware/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod auth;

51
src/models/error.rs Normal file
View File

@ -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)
}
}
}
}

88
src/models/event.rs Normal file
View File

@ -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<Vec<EventReminder>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateEventRequest {
pub id: Option<String>,
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
pub start: EventDateTime,
pub end: EventDateTime,
pub attendees: Option<Vec<Attendee>>,
pub reminders: Option<EventReminders>,
#[serde(rename = "colorId")]
pub color_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateEventRequest {
pub summary: Option<String>,
pub description: Option<String>,
pub location: Option<String>,
pub start: Option<EventDateTime>,
pub end: Option<EventDateTime>,
pub attendees: Option<Vec<Attendee>>,
pub reminders: Option<EventReminders>,
#[serde(rename = "colorId")]
pub color_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EventDateTime {
#[serde(rename = "dateTime")]
pub date_time: Option<DateTime<Utc>>,
pub date: Option<String>, // For all-day events (YYYY-MM-DD)
#[serde(rename = "timeZone")]
pub time_zone: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Attendee {
pub email: String,
#[serde(rename = "displayName")]
pub display_name: Option<String>,
pub optional: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Event {
pub id: String,
pub summary: String,
pub description: Option<String>,
pub location: Option<String>,
pub start: EventDateTime,
pub end: EventDateTime,
pub attendees: Option<Vec<Attendee>>,
pub reminders: Option<EventReminders>,
#[serde(rename = "colorId")]
pub color_id: Option<String>,
#[serde(rename = "htmlLink")]
pub html_link: Option<String>,
pub created: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ListEventsQuery {
#[serde(rename = "timeMin")]
pub time_min: Option<DateTime<Utc>>,
#[serde(rename = "timeMax")]
pub time_max: Option<DateTime<Utc>>,
#[serde(rename = "maxResults")]
pub max_results: Option<u32>,
pub q: Option<String>, // Search query
}

2
src/models/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod error;
pub mod event;

View File

@ -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<Mutex<Option<CachedToken>>>,
}
#[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<Event, ServiceError> {
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<Event, ServiceError> {
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<Vec<Event>, 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<Event, ServiceError> {
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(
&params
.into_iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
.collect::<Vec<_>>()
.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<F>(
&self,
request_builder: F,
) -> Result<Response, ServiceError>
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<Event, ServiceError> {
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<Event, ServiceError> {
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<Vec<Event>, 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<String, ServiceError> {
// 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<ServiceAccountKey, ServiceError> {
// 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<String, ServiceError> {
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<TokenResponse, ServiceError> {
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
)))
}
}
}

1
src/services/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod google_calendar;