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