public-ready-init
This commit is contained in:
commit
2603284460
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
18
.env.example
Normal file
18
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
/.env
|
||||||
|
/service_account_key.json
|
||||||
|
Cargo.lock
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
39
Cargo.toml
Normal file
39
Cargo.toml
Normal 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
54
Dockerfile
Normal 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
252
README.md
Normal 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
20
docker-compose.yml
Normal 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
25
src/config.rs
Normal 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
70
src/handlers/events.rs
Normal 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
11
src/handlers/health.rs
Normal 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
2
src/handlers/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod events;
|
||||||
|
pub mod health;
|
||||||
60
src/main.rs
Normal file
60
src/main.rs
Normal 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
81
src/middleware/auth.rs
Normal 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
1
src/middleware/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
||||||
51
src/models/error.rs
Normal file
51
src/models/error.rs
Normal 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
88
src/models/event.rs
Normal 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
2
src/models/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod error;
|
||||||
|
pub mod event;
|
||||||
499
src/services/google_calendar.rs
Normal file
499
src/services/google_calendar.rs
Normal 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(
|
||||||
|
¶ms
|
||||||
|
.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
1
src/services/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod google_calendar;
|
||||||
Loading…
x
Reference in New Issue
Block a user