Chapter 17.5: Pluggable Provider Architecture
This chapter explores pierre’s pluggable provider architecture that enables runtime registration of 1 to x fitness providers simultaneously. You’ll learn about provider factories, dynamic discovery, environment-based configuration, and how to add new providers without modifying existing code.
Pluggable Architecture Overview
Pierre implements a fully pluggable provider system where fitness providers are registered at runtime through a factory pattern. The system supports 1 to x providers simultaneously, meaning you can use just Strava, or Strava + Garmin + Fitbit + custom providers all at once.
┌────────────────────────────────────────────────────────────────────────────────────┐
│ ProviderRegistry (runtime) │
│ Manages 1 to x providers with dynamic discovery │
└───────────┬────────────────────────────────────────────────────────────────────────┘
│
┌────────┴────────┬───────────┬────────────┬──────────┬────────┬─────────┬─────────────┐
│ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────┐ ┌───────┐ ┌─────────┐ ┌─────────┐
│ Strava │ │ Garmin │ │ Terra │ │ Fitbit │ │ WHOOP │ │ COROS │ │Synthetic│ │ Custom │
│ Factory │ │ Factory │ │ Factory │ │ Factory │ │Factory│ │Factory│ │ Factory │ │ Factory │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └───┬───┘ └───┬───┘ └────┬────┘ └────┬────┘
│ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────┐ ┌───────┐ ┌─────────┐ ┌─────────┐
│ Strava │ │ Garmin │ │ Terra │ │ Fitbit │ │ WHOOP │ │ COROS │ │Synthetic│ │ Custom │
│Provider │ │Provider │ │Provider │ │Provider │ │Provdr │ │Provdr │ │Provider │ │Provider │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └───────┘ └───────┘ └─────────┘ └─────────┘
│ │ │ │ │ │ │ │
└─────────────┴───────────┴────────────┴───────────┴─────────┴──────────┴────────────┘
│
▼
┌──────────────────────────┐
│ FitnessProvider Trait │
│ (shared interface) │
└──────────────────────────┘
Key benefit: Add, remove, or swap providers without modifying tool code, connection handlers, or application logic.
Feature Flags (compile-Time Selection)
Pierre uses Cargo feature flags for compile-time provider selection. This allows minimal binaries with only the providers you need:
Source: Cargo.toml
# Provider feature flags - enable/disable individual fitness data providers
provider-strava = []
provider-garmin = []
provider-terra = []
provider-fitbit = []
provider-whoop = []
provider-coros = []
provider-synthetic = []
all-providers = ["provider-strava", "provider-garmin", "provider-terra", "provider-fitbit", "provider-whoop", "provider-coros", "provider-synthetic"]
Build with specific providers:
# All providers (default)
cargo build --release
# Only Strava
cargo build --release --no-default-features --features "sqlite,provider-strava"
# Strava + Garmin (no synthetic)
cargo build --release --no-default-features --features "sqlite,provider-strava,provider-garmin"
Conditional compilation in code:
#![allow(unused)]
fn main() {
// Provider modules conditionally compiled
#[cfg(feature = "provider-strava")]
pub mod strava_provider;
#[cfg(feature = "provider-garmin")]
pub mod garmin_provider;
#[cfg(feature = "provider-whoop")]
pub mod whoop_provider;
#[cfg(feature = "provider-coros")]
pub mod coros_provider;
#[cfg(feature = "provider-synthetic")]
pub mod synthetic_provider;
}
Note: COROS API access requires applying to their developer program at https://support.coros.com/hc/en-us/articles/17085887816340. Documentation is provided after approval.
Service Provider Interface (SPI)
The SPI defines the contract for pluggable providers, enabling external crates to register providers without modifying core code.
Providerdescriptor Trait
Source: src/providers/spi.rs:129-177
#![allow(unused)]
fn main() {
/// Service Provider Interface (SPI) for pluggable fitness providers
///
/// External provider crates implement this trait to describe their capabilities.
pub trait ProviderDescriptor: Send + Sync {
/// Unique provider identifier (e.g., "strava", "garmin", "whoop")
fn name(&self) -> &'static str;
/// Human-readable display name (e.g., "Strava", "Garmin Connect")
fn display_name(&self) -> &'static str;
/// Provider capabilities using bitflags
fn capabilities(&self) -> ProviderCapabilities;
/// OAuth endpoints (None for non-OAuth providers like synthetic)
fn oauth_endpoints(&self) -> Option<OAuthEndpoints>;
/// OAuth parameters (scope separator, PKCE, etc.)
fn oauth_params(&self) -> Option<OAuthParams>;
/// Base URL for API requests
fn api_base_url(&self) -> &'static str;
/// Default OAuth scopes for this provider
fn default_scopes(&self) -> &'static [&'static str];
}
}
Providercapabilities (Bitflags)
Provider capabilities use bitflags for efficient storage and combinators:
Source: src/providers/spi.rs:95-126
#![allow(unused)]
fn main() {
bitflags::bitflags! {
/// Provider capability flags using bitflags for efficient storage
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderCapabilities: u8 {
/// Provider supports OAuth 2.0 authentication
const OAUTH = 0b0000_0001;
/// Provider supports activity data (workouts, runs, rides)
const ACTIVITIES = 0b0000_0010;
/// Provider supports sleep tracking data
const SLEEP_TRACKING = 0b0000_0100;
/// Provider supports recovery metrics (HRV, strain)
const RECOVERY_METRICS = 0b0000_1000;
/// Provider supports health metrics (weight, body composition)
const HEALTH_METRICS = 0b0001_0000;
}
}
impl ProviderCapabilities {
/// Activity-only provider (OAuth + activities)
pub const fn activity_only() -> Self {
Self::OAUTH.union(Self::ACTIVITIES)
}
/// Full health provider (all capabilities)
pub const fn full_health() -> Self {
Self::OAUTH
.union(Self::ACTIVITIES)
.union(Self::SLEEP_TRACKING)
.union(Self::RECOVERY_METRICS)
.union(Self::HEALTH_METRICS)
}
}
}
Using capabilities:
#![allow(unused)]
fn main() {
// Check specific capability
if provider.capabilities().contains(ProviderCapabilities::SLEEP_TRACKING) {
// Provider supports sleep data
}
// Combine capabilities
let caps = ProviderCapabilities::OAUTH | ProviderCapabilities::ACTIVITIES;
// Use convenience constructors
let full_health = ProviderCapabilities::full_health();
}
Oauthparams
OAuth configuration varies by provider (scope separators, PKCE support):
Source: src/providers/spi.rs:85-93
#![allow(unused)]
fn main() {
/// OAuth parameters for provider-specific configuration
#[derive(Debug, Clone)]
pub struct OAuthParams {
/// Scope separator character (space for Fitbit, comma for Strava)
pub scope_separator: &'static str,
/// Whether to use PKCE (recommended for public clients)
pub use_pkce: bool,
/// Additional query parameters for authorization URL
pub additional_auth_params: &'static [(&'static str, &'static str)],
}
}
Provider Registry
The ProviderRegistry is the central hub for managing all fitness providers:
Source: src/providers/registry.rs:13-60
#![allow(unused)]
fn main() {
/// Central registry for all fitness providers with factory pattern
pub struct ProviderRegistry {
/// Map of provider names to their factories
factories: HashMap<String, Box<dyn ProviderFactory>>,
/// Default configurations for each provider (loaded from environment)
default_configs: HashMap<String, ProviderConfig>,
}
impl ProviderRegistry {
/// Create registry and auto-register all known providers
#[must_use]
pub fn new() -> Self {
let mut registry = Self {
factories: HashMap::new(),
default_configs: HashMap::new(),
};
// Register Strava provider with environment-based config
registry.register_factory(
oauth_providers::STRAVA,
Box::new(StravaProviderFactory),
);
let config = load_provider_env_config(
oauth_providers::STRAVA,
"https://www.strava.com/oauth/authorize",
"https://www.strava.com/oauth/token",
"https://www.strava.com/api/v3",
Some("https://www.strava.com/oauth/deauthorize"),
&[oauth_providers::STRAVA_DEFAULT_SCOPES.to_owned()],
);
registry.set_default_config(oauth_providers::STRAVA, /* config */);
// Register Garmin provider
registry.register_factory(
oauth_providers::GARMIN,
Box::new(GarminProviderFactory),
);
// ... Garmin config
// Register Synthetic provider (no OAuth needed!)
registry.register_factory(
oauth_providers::SYNTHETIC,
Box::new(SyntheticProviderFactory),
);
// ... Synthetic config
registry
}
/// Register a provider factory for runtime creation
pub fn register_factory(&mut self, name: &str, factory: Box<dyn ProviderFactory>) {
self.factories.insert(name.to_owned(), factory);
}
/// Check if provider is supported (dynamic discovery)
#[must_use]
pub fn is_supported(&self, provider: &str) -> bool {
self.factories.contains_key(provider)
}
/// Get all supported provider names (1 to x providers)
#[must_use]
pub fn supported_providers(&self) -> Vec<String> {
self.factories.keys().map(ToString::to_string).collect()
}
/// Create provider instance from factory
pub fn create_provider(&self, name: &str) -> Option<Box<dyn FitnessProvider>> {
let factory = self.factories.get(name)?;
let config = self.default_configs.get(name)?.clone();
Some(factory.create(config))
}
}
}
Registry responsibilities:
- Factory storage: Maps provider names to factory implementations
- Dynamic discovery:
is_supported()andsupported_providers()enable runtime introspection - Configuration management: Stores default configs loaded from environment
- Provider creation:
create_provider()instantiates providers on-demand
Provider Factory Pattern
Each provider implements a ProviderFactory trait for creation:
Source: src/providers/core.rs:173-180
#![allow(unused)]
fn main() {
/// Provider factory for creating instances
pub trait ProviderFactory: Send + Sync {
/// Create a new provider instance with the given configuration
fn create(&self, config: ProviderConfig) -> Box<dyn FitnessProvider>;
/// Get supported provider names (for multi-provider factories)
fn supported_providers(&self) -> &'static [&'static str];
}
}
Example: Strava factory:
Source: src/providers/registry.rs:20-28
#![allow(unused)]
fn main() {
/// Factory for creating Strava provider instances
struct StravaProviderFactory;
impl ProviderFactory for StravaProviderFactory {
fn create(&self, config: ProviderConfig) -> Box<dyn FitnessProvider> {
Box::new(StravaProvider::new(config))
}
fn supported_providers(&self) -> &'static [&'static str] {
&["strava"]
}
}
}
Example: Synthetic factory (Phase 1):
Source: src/providers/registry.rs:30-38
#![allow(unused)]
fn main() {
/// Factory for creating Synthetic provider instances
struct SyntheticProviderFactory;
impl ProviderFactory for SyntheticProviderFactory {
fn create(&self, _config: ProviderConfig) -> Box<dyn FitnessProvider> {
Box::new(SyntheticProvider::default())
}
fn supported_providers(&self) -> &'static [&'static str] {
&["synthetic"]
}
}
}
Factory pattern benefits:
- Lazy instantiation: Providers created only when needed
- Configuration injection: Factory receives config at creation time
- Type erasure: Returns
Box<dyn FitnessProvider>for uniform handling
Environment-Based Configuration
Pierre loads provider configuration from environment variables for cloud-native deployment (GCP, AWS, etc.):
Configuration schema:
# Default provider (1 required, used when no provider specified)
export PIERRE_DEFAULT_PROVIDER=strava # or garmin, synthetic, custom
# Per-provider configuration (repeat for each provider 1 to x)
export PIERRE_STRAVA_CLIENT_ID=your-client-id
export PIERRE_STRAVA_CLIENT_SECRET=your-secret
export PIERRE_STRAVA_AUTH_URL=https://www.strava.com/oauth/authorize
export PIERRE_STRAVA_TOKEN_URL=https://www.strava.com/oauth/token
export PIERRE_STRAVA_API_BASE_URL=https://www.strava.com/api/v3
export PIERRE_STRAVA_REVOKE_URL=https://www.strava.com/oauth/deauthorize
export PIERRE_STRAVA_SCOPES="activity:read_all,profile:read_all"
# Garmin provider
export PIERRE_GARMIN_CLIENT_ID=your-consumer-key
export PIERRE_GARMIN_CLIENT_SECRET=your-consumer-secret
# ... Garmin URLs and scopes
# Synthetic provider (no OAuth needed - perfect for dev/testing!)
# No env vars required - automatically available
Loading configuration:
Source: src/config/environment.rs:2093-2174
#![allow(unused)]
fn main() {
/// Load provider-specific configuration from environment variables
///
/// Falls back to provided defaults if environment variables are not set.
/// Supports legacy env vars (STRAVA_CLIENT_ID) for backward compatibility.
#[must_use]
pub fn load_provider_env_config(
provider: &str,
default_auth_url: &str,
default_token_url: &str,
default_api_base_url: &str,
default_revoke_url: Option<&str>,
default_scopes: &[String],
) -> ProviderEnvConfig {
let provider_upper = provider.to_uppercase();
// Load client credentials with fallback to legacy env vars
let client_id = env::var(format!("PIERRE_{provider_upper}_CLIENT_ID"))
.or_else(|_| env::var(format!("{provider_upper}_CLIENT_ID")))
.ok();
let client_secret = env::var(format!("PIERRE_{provider_upper}_CLIENT_SECRET"))
.or_else(|_| env::var(format!("{provider_upper}_CLIENT_SECRET")))
.ok();
// Load URLs with defaults
let auth_url = env::var(format!("PIERRE_{provider_upper}_AUTH_URL"))
.unwrap_or_else(|_| default_auth_url.to_owned());
// ... load other fields
(client_id, client_secret, auth_url, token_url, api_base_url, revoke_url, scopes)
}
}
Backward compatibility:
- New format:
PIERRE_STRAVA_CLIENT_ID(preferred) - Legacy format:
STRAVA_CLIENT_ID(still supported) - Graceful fallback: Tries new format first, then legacy
Dynamic Provider Discovery
Connection tools automatically discover available providers at runtime:
Source: src/protocols/universal/handlers/connections.rs:84-88
#![allow(unused)]
fn main() {
// Multi-provider mode - check all supported providers from registry
let providers_to_check = executor.resources.provider_registry.supported_providers();
let mut providers_status = serde_json::Map::new();
for provider in providers_to_check {
let is_connected = matches!(
executor
.auth_service
.get_valid_token(user_uuid, provider, request.tenant_id.as_deref())
.await,
Ok(Some(_))
);
providers_status.insert(
provider.to_owned(),
serde_json::json!({
"connected": is_connected,
"status": if is_connected { "connected" } else { "disconnected" }
}),
);
}
}
Dynamic provider validation:
Source: src/protocols/universal/handlers/connections.rs:224-228
#![allow(unused)]
fn main() {
/// Validate that provider is supported using provider registry
fn is_provider_supported(
provider: &str,
provider_registry: &crate::providers::ProviderRegistry,
) -> bool {
provider_registry.is_supported(provider)
}
}
Dynamic error messages:
Source: src/protocols/universal/handlers/connections.rs:333-340
#![allow(unused)]
fn main() {
if !is_provider_supported(provider, &executor.resources.provider_registry) {
let supported_providers = executor
.resources
.provider_registry
.supported_providers()
.join(", ");
return Ok(connection_error(format!(
"Provider '{provider}' is not supported. Supported providers: {supported_providers}"
)));
}
}
Result: Error messages automatically update when you add/remove providers. No hardcoded lists!
Synthetic Provider (phase 1)
Pierre includes a synthetic provider for development and testing without OAuth:
Source: src/providers/synthetic_provider.rs:30-79
#![allow(unused)]
fn main() {
/// Synthetic fitness provider for development and testing (no OAuth required!)
///
/// This provider generates realistic fitness data without connecting to external APIs.
/// Perfect for:
/// - Development without OAuth credentials
/// - Integration tests
/// - Demo environments
/// - CI/CD pipelines
pub struct SyntheticProvider {
activities: Arc<RwLock<Vec<Activity>>>,
activity_index: Arc<RwLock<HashMap<String, Activity>>>,
config: ProviderConfig,
}
impl SyntheticProvider {
/// Create provider with pre-populated synthetic activities
#[must_use]
pub fn with_activities(activities: Vec<Activity>) -> Self {
let mut index = HashMap::new();
for activity in &activities {
index.insert(activity.id.clone(), activity.clone());
}
Self {
activities: Arc::new(RwLock::new(activities)),
activity_index: Arc::new(RwLock::new(index)),
config: ProviderConfig {
name: oauth_providers::SYNTHETIC.to_owned(),
auth_url: "http://localhost:8081/synthetic/auth".to_owned(),
token_url: "http://localhost:8081/synthetic/token".to_owned(),
api_base_url: "http://localhost:8081/synthetic/api".to_owned(),
revoke_url: None,
default_scopes: vec!["read:all".to_owned()],
},
}
}
}
}
Synthetic provider benefits:
- No OAuth dance: Skip authorization flows during development
- Deterministic data: Same activities every time for testing
- Fast iteration: No network calls, instant responses
- CI/CD friendly: No API keys or secrets needed
- Always available: Listed in
supported_providers()
Default provider selection:
Source: src/config/environment.rs:2060-2078
#![allow(unused)]
fn main() {
/// Get default provider from PIERRE_DEFAULT_PROVIDER or fallback to "synthetic"
#[must_use]
pub fn default_provider() -> String {
use crate::constants::oauth_providers;
env::var("PIERRE_DEFAULT_PROVIDER")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| oauth_providers::SYNTHETIC.to_owned())
}
}
Fallback hierarchy:
PIERRE_DEFAULT_PROVIDER=strava→ use StravaPIERRE_DEFAULT_PROVIDER=garmin→ use Garmin- Not set or empty → use Synthetic (OAuth-free development)
Adding a Custom Provider SPI Approach)
Here’s how to add a new provider using the SPI architecture:
Step 1: Add Feature Flag
Source: Cargo.toml
[features]
provider-whoop = []
all-providers = ["provider-strava", "provider-garmin", "provider-terra", "provider-fitbit", "provider-whoop", "provider-coros", "provider-synthetic"]
Step 2: Implement Providerdescriptor (SPI)
Source: src/providers/spi.rs
#![allow(unused)]
fn main() {
use pierre_mcp_server::providers::spi::{
ProviderDescriptor, OAuthEndpoints, OAuthParams, ProviderCapabilities
};
/// WHOOP provider descriptor for SPI registration
#[cfg(feature = "provider-whoop")]
pub struct WhoopDescriptor;
#[cfg(feature = "provider-whoop")]
impl ProviderDescriptor for WhoopDescriptor {
fn name(&self) -> &'static str {
"whoop"
}
fn display_name(&self) -> &'static str {
"WHOOP"
}
fn capabilities(&self) -> ProviderCapabilities {
// WHOOP supports all health features - use bitflags combinator
ProviderCapabilities::full_health()
}
fn oauth_endpoints(&self) -> Option<OAuthEndpoints> {
Some(OAuthEndpoints {
auth_url: "https://api.prod.whoop.com/oauth/oauth2/auth",
token_url: "https://api.prod.whoop.com/oauth/oauth2/token",
revoke_url: Some("https://api.prod.whoop.com/oauth/oauth2/revoke"),
})
}
fn oauth_params(&self) -> Option<OAuthParams> {
Some(OAuthParams {
scope_separator: " ", // Space-separated scopes
use_pkce: true, // PKCE recommended
additional_auth_params: &[],
})
}
fn api_base_url(&self) -> &'static str {
"https://api.prod.whoop.com/developer/v1"
}
fn default_scopes(&self) -> &'static [&'static str] {
&["read:profile", "read:workout", "read:sleep", "read:recovery"]
}
}
}
Step 3: Implement Fitnessprovider Trait
Source: src/providers/whoop_provider.rs
#![allow(unused)]
fn main() {
use pierre_mcp_server::providers::core::{FitnessProvider, ProviderConfig, OAuth2Credentials};
use pierre_mcp_server::models::{Activity, Athlete, Stats};
use pierre_mcp_server::errors::AppResult;
use async_trait::async_trait;
use std::sync::{Arc, RwLock};
#[cfg(feature = "provider-whoop")]
pub struct WhoopProvider {
config: ProviderConfig,
credentials: Arc<RwLock<Option<OAuth2Credentials>>>,
http_client: reqwest::Client,
}
#[cfg(feature = "provider-whoop")]
#[async_trait]
impl FitnessProvider for WhoopProvider {
fn name(&self) -> &'static str {
"whoop"
}
fn config(&self) -> &ProviderConfig {
&self.config
}
async fn set_credentials(&self, credentials: OAuth2Credentials) -> AppResult<()> {
// Store credentials using RwLock for interior mutability
let mut creds = self.credentials.write()
.map_err(|_| pierre_mcp_server::providers::errors::ProviderError::ConfigurationError(
"Failed to acquire credentials lock".to_owned()
))?;
*creds = Some(credentials);
Ok(())
}
async fn get_athlete(&self) -> AppResult<Athlete> {
// Real implementation: fetch from WHOOP API and convert to unified model
Ok(Athlete {
id: "whoop-user-123".to_owned(),
username: "athlete".to_owned(),
firstname: Some("WHOOP".to_owned()),
lastname: Some("User".to_owned()),
profile_picture: None,
provider: "whoop".to_owned(),
})
}
async fn get_activities(
&self,
_limit: Option<usize>,
_offset: Option<usize>,
) -> AppResult<Vec<Activity>> {
// Real implementation: fetch workouts from WHOOP API
Ok(vec![])
}
// ... implement remaining trait methods
}
}
Step 4: Create Provider Factory and Register
Source: src/providers/registry.rs
#![allow(unused)]
fn main() {
#[cfg(feature = "provider-whoop")]
use super::whoop_provider::WhoopProvider;
#[cfg(feature = "provider-whoop")]
use super::spi::WhoopDescriptor;
/// Factory for creating WHOOP provider instances
#[cfg(feature = "provider-whoop")]
struct WhoopProviderFactory;
#[cfg(feature = "provider-whoop")]
impl ProviderFactory for WhoopProviderFactory {
fn create(&self, config: ProviderConfig) -> Box<dyn FitnessProvider> {
Box::new(WhoopProvider::new(config))
}
fn supported_providers(&self) -> &'static [&'static str] {
&["whoop"]
}
}
// In ProviderRegistry::new():
#[cfg(feature = "provider-whoop")]
{
let descriptor = WhoopDescriptor;
registry.register_factory("whoop", Box::new(WhoopProviderFactory));
// Config loaded from descriptor's oauth_endpoints() and default_scopes()
}
}
Step 5: Add to Constants and Module Exports
Source: src/constants/oauth/providers.rs
#![allow(unused)]
fn main() {
#[cfg(feature = "provider-whoop")]
pub const WHOOP: &str = "whoop";
#[cfg(feature = "provider-whoop")]
pub const WHOOP_DEFAULT_SCOPES: &str = "read:profile read:workout read:sleep read:recovery";
}
Source: src/providers/mod.rs
#![allow(unused)]
fn main() {
#[cfg(feature = "provider-whoop")]
pub mod whoop_provider;
#[cfg(feature = "provider-whoop")]
pub use spi::WhoopDescriptor;
}
Step 6: Configure Environment
Source: .envrc
# WHOOP provider configuration
export WHOOP_CLIENT_ID=your-whoop-client-id
export WHOOP_CLIENT_SECRET=your-whoop-secret
export WHOOP_REDIRECT_URI=http://localhost:8081/api/oauth/callback/whoop
That’s it! WHOOP is now:
- ✅ Conditionally compiled with
--features provider-whoop - ✅ Available in
supported_providers()when feature enabled - ✅ Discoverable via
is_supported("whoop") - ✅ Creatable via
create_provider("whoop") - ✅ Listed in connection status responses
- ✅ Supported in
connect_providertool - ✅ Capabilities queryable via bitflags
No changes needed:
- ❌ Connection handlers (dynamic discovery)
- ❌ Tool implementations (use FitnessProvider trait)
- ❌ MCP schema generation (automatic)
- ❌ Test fixtures (provider-agnostic)
Managing 1 to X Providers Simultaneously
Pierre’s architecture supports multiple active providers per tenant/user:
Multi-provider connection status:
{
"success": true,
"result": {
"providers": {
"strava": {
"connected": true,
"status": "connected"
},
"garmin": {
"connected": true,
"status": "connected"
},
"fitbit": {
"connected": false,
"status": "disconnected"
},
"coros": {
"connected": true,
"status": "connected"
},
"synthetic": {
"connected": true,
"status": "connected"
},
"whoop": {
"connected": true,
"status": "connected"
}
}
}
}
Data aggregation across providers:
#![allow(unused)]
fn main() {
// Pseudo-code for fetching activities from all connected providers
async fn get_all_activities(user_id: Uuid, tenant_id: Uuid) -> Vec<Activity> {
let mut all_activities = Vec::new();
for provider_name in registry.supported_providers() {
if let Ok(Some(provider)) = create_authenticated_provider(
user_id,
tenant_id,
provider_name,
).await {
if let Ok(activities) = provider.get_activities(Some(50), None).await {
all_activities.extend(activities);
}
}
}
// Deduplicate and merge activities from multiple providers
all_activities.sort_by(|a, b| b.start_date.cmp(&a.start_date));
all_activities
}
}
Provider switching:
#![allow(unused)]
fn main() {
// Tools accept optional provider parameter
let provider_name = request
.parameters
.get("provider")
.and_then(|v| v.as_str())
.unwrap_or(&default_provider());
let provider = registry.create_provider(provider_name)
.ok_or_else(|| ProtocolError::ProviderNotFound)?;
}
Shared request/response Traits
All providers implement the same FitnessProvider trait, ensuring uniform request/response patterns:
Request side (method parameters):
- IDs:
&strfor activity/athlete IDs - Pagination:
PaginationParamsstruct - Date ranges:
DateTime<Utc>for time-based queries - Options:
Option<T>for optional filters
Response side (domain models):
- Activity: Unified workout representation
- Athlete: User profile information
- Stats: Aggregate performance metrics
- PersonalRecord: Best achievements
- SleepSession, RecoveryMetrics, HealthMetrics: Health data
Shared error handling:
- AppResult
: All providers return the same result type - ProviderError: Structured error enum with retry information
- Consistent mapping: Provider-specific errors →
ProviderError
Benefits:
- Swappable: Change from Strava to Garmin without modifying tool code
- Testable: Mock any provider using
FitnessProvidertrait - Type-safe: Compiler enforces contract across all providers
- Extensible: New providers must implement complete interface
Rust Idioms: Trait Object Factory
Source: src/providers/registry.rs:43-46
#![allow(unused)]
fn main() {
pub fn register_factory(&mut self, name: &str, factory: Box<dyn ProviderFactory>) {
self.factories.insert(name.to_owned(), factory);
}
}
Trait objects:
Box<dyn ProviderFactory>: Heap-allocated trait object with dynamic dispatch- Dynamic dispatch: Method calls resolved at runtime (vtable lookup)
- Polymorphism: Registry stores different factory types (Strava, Garmin, etc.)
- Type erasure: Concrete factory type erased, only trait methods accessible
Alternative (static dispatch):
#![allow(unused)]
fn main() {
// Generic approach (static dispatch)
pub fn register_factory<F: ProviderFactory + 'static>(&mut self, name: &str, factory: F) {
// Can't store different F types in same HashMap!
}
}
Why trait objects: Registry needs to store heterogeneous factory types in single collection.
Rust Idioms: arc<rwlock> for Interior Mutability
Source: src/providers/synthetic_provider.rs:34-36
#![allow(unused)]
fn main() {
pub struct SyntheticProvider {
activities: Arc<RwLock<Vec<Activity>>>,
activity_index: Arc<RwLock<HashMap<String, Activity>>>,
config: ProviderConfig,
}
}
Pattern explanation:
- Arc: Atomic reference counting for shared ownership across threads
- RwLock: Reader-writer lock allowing multiple readers OR single writer
- Interior mutability: Mutate data inside
&self(FitnessProvider trait uses&self)
Why needed:
#![allow(unused)]
fn main() {
#[async_trait]
pub trait FitnessProvider: Send + Sync {
async fn get_activities(&self, ...) -> Result<Vec<Activity>>;
// ^^^^^ immutable reference
}
}
Without RwLock (doesn’t compile):
#![allow(unused)]
fn main() {
impl FitnessProvider for SyntheticProvider {
async fn get_activities(&self, ...) -> Result<Vec<Activity>> {
self.activities.push(...); // ❌ Can't mutate through &self
}
}
}
With RwLock (compiles):
#![allow(unused)]
fn main() {
impl FitnessProvider for SyntheticProvider {
async fn get_activities(&self, ...) -> Result<Vec<Activity>> {
let activities = self.activities.read().await; // ✅ Interior mutability
Ok(activities.clone())
}
}
}
Provider Resilience Patterns
Pierre implements multiple resilience patterns to handle provider failures gracefully.
Retry with Exponential Backoff
Source: src/providers/core.rs (conceptual)
#![allow(unused)]
fn main() {
/// Retry configuration for provider requests
pub struct RetryConfig {
/// Maximum number of retry attempts
pub max_retries: u32,
/// Base delay between retries (doubles each attempt)
pub base_delay_ms: u64,
/// Maximum delay cap
pub max_delay_ms: u64,
/// Jitter factor (0.0 to 1.0) to prevent thundering herd
pub jitter_factor: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
base_delay_ms: 100,
max_delay_ms: 5000,
jitter_factor: 0.1,
}
}
}
}
Retry logic:
#![allow(unused)]
fn main() {
async fn fetch_with_retry<T, F, Fut>(
operation: F,
config: &RetryConfig,
) -> Result<T, ProviderError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, ProviderError>>,
{
let mut attempt = 0;
loop {
match operation().await {
Ok(result) => return Ok(result),
Err(e) if e.is_retryable() && attempt < config.max_retries => {
attempt += 1;
let delay = calculate_backoff(attempt, config);
tokio::time::sleep(Duration::from_millis(delay)).await;
}
Err(e) => return Err(e),
}
}
}
fn calculate_backoff(attempt: u32, config: &RetryConfig) -> u64 {
let base = config.base_delay_ms * 2u64.pow(attempt - 1);
let jitter = (base as f64 * config.jitter_factor * rand::random::<f64>()) as u64;
(base + jitter).min(config.max_delay_ms)
}
}
Rate Limit Respect
Providers return Retry-After headers when rate limited:
#![allow(unused)]
fn main() {
match provider.get_activities().await {
Err(ProviderError::RateLimitExceeded { retry_after_secs, .. }) => {
tracing::warn!(
provider = %provider.name(),
retry_after = retry_after_secs,
"Provider rate limited, scheduling retry"
);
// Queue for later execution
scheduler.schedule_retry(request, retry_after_secs).await;
Ok(PendingResult::Scheduled)
}
result => result,
}
}
Token Auto-Refresh
OAuth tokens are automatically refreshed before expiration:
Source: src/oauth2_client/flow_manager.rs (conceptual)
#![allow(unused)]
fn main() {
/// Check if token needs refresh (5 minute buffer)
fn needs_refresh(token: &UserOAuthToken) -> bool {
if let Some(expires_at) = token.expires_at {
let refresh_buffer = Duration::from_secs(300); // 5 minutes
expires_at - refresh_buffer < Utc::now()
} else {
false
}
}
/// Transparently refresh token before provider call
async fn ensure_valid_token(
db: &Database,
user_id: Uuid,
tenant_id: &str,
provider: &str,
) -> Result<String, ProviderError> {
let token = db.oauth_tokens().get(user_id, tenant_id, provider).await?;
if needs_refresh(&token) {
let refreshed = refresh_token(&token).await?;
db.oauth_tokens().upsert(&refreshed).await?;
Ok(refreshed.access_token)
} else {
Ok(token.access_token)
}
}
}
Graceful Degradation
When a provider is unavailable, Pierre continues serving from cache:
#![allow(unused)]
fn main() {
/// Fetch activities with cache fallback
async fn get_activities_resilient(
provider: &dyn FitnessProvider,
cache: &Cache,
user_id: Uuid,
) -> Result<Vec<Activity>, ProviderError> {
let cache_key = format!("activities:{}:{}", provider.name(), user_id);
match provider.get_activities(user_id).await {
Ok(activities) => {
// Update cache on success
cache.set(&cache_key, &activities, Duration::from_secs(3600)).await;
Ok(activities)
}
Err(e) if e.is_transient() => {
// Try cache on transient errors
if let Some(cached) = cache.get::<Vec<Activity>>(&cache_key).await {
tracing::warn!(
provider = %provider.name(),
error = %e,
"Provider unavailable, serving from cache"
);
Ok(cached)
} else {
Err(e)
}
}
Err(e) => Err(e),
}
}
}
Provider Health Checks
Monitor provider availability proactively:
#![allow(unused)]
fn main() {
/// Provider health status
#[derive(Debug, Clone)]
pub struct ProviderHealth {
pub provider: String,
pub is_healthy: bool,
pub last_check: DateTime<Utc>,
pub consecutive_failures: u32,
pub average_latency_ms: f64,
}
/// Check provider health via lightweight endpoint
async fn check_provider_health(provider: &dyn FitnessProvider) -> ProviderHealth {
let start = Instant::now();
let result = provider.health_check().await;
let latency = start.elapsed().as_millis() as f64;
ProviderHealth {
provider: provider.name().to_string(),
is_healthy: result.is_ok(),
last_check: Utc::now(),
consecutive_failures: if result.is_ok() { 0 } else { 1 },
average_latency_ms: latency,
}
}
}
Multi-Provider Fallback
When primary provider fails, try alternatives:
#![allow(unused)]
fn main() {
/// Try multiple providers in order
async fn get_activities_multi_provider(
registry: &ProviderRegistry,
user_id: Uuid,
preferred_providers: &[&str],
) -> Result<Vec<Activity>, ProviderError> {
let mut last_error = None;
for provider_name in preferred_providers {
if let Some(provider) = registry.get(provider_name) {
match provider.get_activities(user_id).await {
Ok(activities) => return Ok(activities),
Err(e) => {
tracing::warn!(
provider = provider_name,
error = %e,
"Provider failed, trying next"
);
last_error = Some(e);
}
}
}
}
Err(last_error.unwrap_or_else(|| ProviderError::NoProvidersAvailable))
}
}
Resilience Configuration
Per-provider resilience settings:
# config/providers.toml (conceptual)
[strava]
max_retries = 3
base_delay_ms = 100
timeout_secs = 30
circuit_breaker_threshold = 5
circuit_breaker_reset_secs = 60
[garmin]
max_retries = 5 # Garmin is slower, more retries
base_delay_ms = 200
timeout_secs = 60
Caching Provider Decorator
Pierre provides a CachingFitnessProvider decorator that wraps any FitnessProvider with transparent caching using the cache-aside pattern. This significantly reduces API calls to external providers.
Cache-Aside Pattern
Source: src/providers/caching_provider.rs
#![allow(unused)]
fn main() {
/// Caching wrapper for any FitnessProvider implementation
pub struct CachingFitnessProvider<C: CacheProvider> {
/// The underlying provider being wrapped
inner: Box<dyn FitnessProvider>,
/// Cache backend (Redis or in-memory)
cache: Arc<C>,
/// Tenant ID for cache key isolation
tenant_id: Uuid,
/// User ID for cache key isolation
user_id: Uuid,
/// TTL configuration for different resource types
ttl_config: CacheTtlConfig,
}
}
How it works:
- Check cache for requested data
- If cache hit: return cached data immediately
- If cache miss: fetch from provider API, store in cache, return data
#![allow(unused)]
fn main() {
// Create a caching provider
let cached_provider = CachingFitnessProvider::new(
provider, // Any Box<dyn FitnessProvider>
cache, // InMemoryCache or RedisCache
tenant_id,
user_id,
);
// Use normally - caching is transparent
let activities = cached_provider.get_activities(Some(10), None).await?;
}
Cache Policy Control
The CachePolicy enum allows explicit control over caching behavior:
Source: src/providers/caching_provider.rs
#![allow(unused)]
fn main() {
/// Cache policy for controlling caching behavior per-request
pub enum CachePolicy {
/// Use cache if available, fetch and cache on miss (default)
UseCache,
/// Bypass cache entirely, always fetch fresh data
Bypass,
/// Invalidate existing cache entry, fetch fresh, update cache
Refresh,
}
}
Usage:
#![allow(unused)]
fn main() {
// Default behavior - use cache
let activities = cached_provider.get_activities(Some(10), None).await?;
// Force fresh data (user-triggered refresh)
let fresh = cached_provider
.get_activities_with_policy(Some(10), None, CachePolicy::Refresh)
.await?;
// Bypass cache entirely (debugging)
let uncached = cached_provider
.get_activities_with_policy(Some(10), None, CachePolicy::Bypass)
.await?;
}
TTL Configuration
Different resources have different cache durations based on data volatility:
| Resource | TTL | Rationale |
|---|---|---|
AthleteProfile | 24 hours | Profiles rarely change |
ActivityList | 15 minutes | Need fresh for new activities |
Activity | 1 hour | Activity details immutable after creation |
Stats | 6 hours | Aggregates don’t need real-time freshness |
Source: src/constants/cache.rs
#![allow(unused)]
fn main() {
pub const DEFAULT_PROFILE_TTL_SECS: u64 = 86_400; // 24 hours
pub const DEFAULT_ACTIVITY_LIST_TTL_SECS: u64 = 900; // 15 minutes
pub const DEFAULT_ACTIVITY_TTL_SECS: u64 = 3_600; // 1 hour
pub const DEFAULT_STATS_TTL_SECS: u64 = 21_600; // 6 hours
}
Cache Key Structure
Cache keys include tenant/user/provider isolation for multi-tenant safety:
tenant:{tenant_id}:user:{user_id}:provider:{provider}:{resource_type}
Examples:
tenant:abc123:user:def456:provider:strava:athlete_profile
tenant:abc123:user:def456:provider:strava:activity_list:page:1:per_page:50
tenant:abc123:user:def456:provider:strava:activity:12345678
Cache Invalidation
Automatic invalidation on disconnect:
#![allow(unused)]
fn main() {
// When user disconnects, cache is automatically cleared
impl<C: CacheProvider> FitnessProvider for CachingFitnessProvider<C> {
async fn disconnect(&self) -> AppResult<()> {
// Invalidate all user's cache entries
self.invalidate_user_cache().await?;
self.inner.disconnect().await
}
}
}
Manual invalidation (for webhooks):
#![allow(unused)]
fn main() {
// Invalidate when new activity detected via webhook
cached_provider.invalidate_activity_list_cache().await?;
// Invalidate all user cache
cached_provider.invalidate_user_cache().await?;
}
Factory Methods
Using the registry:
#![allow(unused)]
fn main() {
// Create a caching provider via registry
let cached_provider = registry
.create_caching_provider("strava", cache_config, tenant_id, user_id)
.await?;
// Or use the global convenience function
let cached_provider = create_caching_provider_global(
"strava",
cache_config,
tenant_id,
user_id,
).await?;
}
Cache Backend Selection
The caching provider supports both in-memory and Redis backends:
# Use Redis (production/multi-instance)
export REDIS_URL=redis://localhost:6379
# No REDIS_URL = use in-memory LRU cache (dev/single-instance)
Benefits of caching:
- Reduced API calls: Bounded by TTL, not request volume
- Faster responses: Sub-millisecond cache hits vs 100ms+ API calls
- Rate limit protection: Fewer calls = less risk of hitting limits
- Resilience: Cache can serve stale data during provider outages
Configuration Best Practices
Cloud deployment (.envrc for GCP/AWS):
# Production: Configure only active providers
export PIERRE_DEFAULT_PROVIDER=strava
export PIERRE_STRAVA_CLIENT_ID=${STRAVA_CLIENT_ID}
export PIERRE_STRAVA_CLIENT_SECRET=${STRAVA_CLIENT_SECRET}
# Multi-provider setup
export PIERRE_DEFAULT_PROVIDER=strava
export PIERRE_STRAVA_CLIENT_ID=${STRAVA_CLIENT_ID}
export PIERRE_STRAVA_CLIENT_SECRET=${STRAVA_CLIENT_SECRET}
export PIERRE_GARMIN_CLIENT_ID=${GARMIN_CONSUMER_KEY}
export PIERRE_GARMIN_CLIENT_SECRET=${GARMIN_CONSUMER_SECRET}
export PIERRE_FITBIT_CLIENT_ID=${FITBIT_CLIENT_ID}
export PIERRE_FITBIT_CLIENT_SECRET=${FITBIT_CLIENT_SECRET}
# Development: Use synthetic provider (no secrets!)
export PIERRE_DEFAULT_PROVIDER=synthetic
# No other vars needed - synthetic provider works out of the box
Testing environments:
# Integration tests: Use synthetic provider
export PIERRE_DEFAULT_PROVIDER=synthetic
# OAuth tests: Override to real provider
export PIERRE_DEFAULT_PROVIDER=strava
export PIERRE_STRAVA_CLIENT_ID=test-client-id
export PIERRE_STRAVA_CLIENT_SECRET=test-secret
Key Takeaways
-
Pluggable architecture: Providers registered at runtime through factory pattern, no compile-time coupling.
-
Feature flags: Compile-time provider selection via
provider-strava,provider-garmin,provider-syntheticfor minimal binaries. -
Service Provider Interface (SPI):
ProviderDescriptortrait enables external providers to register without core code changes. -
Bitflags capabilities:
ProviderCapabilitiesuses efficient bitflags with combinators likeactivity_only()andfull_health(). -
1 to x providers: System supports unlimited providers simultaneously - just Strava, or Strava + Garmin + custom providers.
-
Dynamic discovery:
supported_providers()andis_supported()enable runtime introspection and automatic tool adaptation. -
Environment-based config: Cloud-native deployment using
PIERRE_<PROVIDER>_*environment variables. -
Synthetic provider: OAuth-free development provider perfect for CI/CD, demos, and rapid iteration.
-
OAuth parameters:
OAuthParamsstruct captures provider-specific OAuth differences (scope separator, PKCE). -
Factory pattern:
ProviderFactorytrait enables lazy provider instantiation with configuration injection. -
Shared interface:
FitnessProvidertrait ensures uniform request/response patterns across all providers. -
Trait objects:
Box<dyn ProviderFactory>enables storing heterogeneous factory types in registry. -
Interior mutability:
Arc<RwLock<T>>pattern allows mutation through&selfin async trait methods. -
Zero code changes: Adding providers doesn’t require modifying connection handlers, tools, or application logic.
-
Type safety: Compiler enforces that all providers implement complete
FitnessProviderinterface. -
Caching decorator:
CachingFitnessProviderwraps any provider with transparent cache-aside caching to reduce API calls. -
Cache policy control:
CachePolicyenum (UseCache,Bypass,Refresh) enables per-request cache behavior control. -
Multi-tenant cache isolation: Cache keys include tenant/user/provider for safe multi-tenant deployments.
Next Chapter: Chapter 18: A2A Protocol - Agent-to-Agent Communication - Learn how Pierre implements the Agent-to-Agent (A2A) protocol for secure inter-agent communication with Ed25519 signatures.
Previous Chapter: Chapter 17: Provider Data Models & Rate Limiting - Explore trait-based provider abstraction, unified data models, and retry logic with exponential backoff.