Custom Providers
FORGE's provider system is designed to be extended. You can create custom drivers for any provider category (SMS, Email, Storage, Payments) by implementing the corresponding trait, registering the driver in the factory, and adding configuration settings.
Overview
Creating a custom provider involves four steps:
┌──────────────────────────────────────────────────────────────┐
│ Creating a Custom Provider │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. Implement the trait Define your driver struct and │
│ implement all required methods │
│ │
│ 2. Register in factory Add a match arm so the factory │
│ can instantiate your driver │
│ │
│ 3. Add configuration Insert settings into the database │
│ for credentials and options │
│ │
│ 4. Test the driver Write unit and integration tests │
│ to verify correct behavior │
│ │
└──────────────────────────────────────────────────────────────┘Step 1: Implement the Provider Trait
Choose the trait that matches your provider category. This example walks through creating a custom SMS provider, but the same pattern applies to all categories.
Available Traits
| Category | Trait | Methods |
|---|---|---|
| SMS | SmsProvider | send, send_otp, verify_otp |
EmailProvider | send, send_template, validate_email | |
| Storage | StorageProvider | put, put_stream, get, url, temporary_url, delete, exists |
| Payments | PaymentProvider | create_checkout, get_status, refund, list_transactions |
Example: Custom SMS Provider
Create a new file for your driver implementation:
// src/services/sms/my_gateway.rs
use async_trait::async_trait;
use reqwest::Client;
use crate::services::sms::{SmsProvider, SmsError};
use crate::models::settings::Settings;
pub struct MyGatewayProvider {
api_key: String,
sender_id: String,
base_url: String,
client: Client,
}
impl MyGatewayProvider {
/// Create a new instance from application settings
pub fn new(settings: &Settings) -> Result<Self, SmsError> {
let api_key = settings
.get_encrypted("sms", "my_gateway_api_key")
.map_err(|_| SmsError::ProviderError(
"Missing my_gateway_api_key setting".to_string()
))?;
let sender_id = settings
.get("sms", "my_gateway_sender_id")
.unwrap_or_else(|| "FORGE".to_string());
Ok(Self {
api_key,
sender_id,
base_url: "https://api.mygateway.com/v1".to_string(),
client: Client::new(),
})
}
}
#[async_trait]
impl SmsProvider for MyGatewayProvider {
async fn send(&self, to: &str, message: &str) -> Result<(), SmsError> {
let response = self.client
.post(format!("{}/messages", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&serde_json::json!({
"to": to,
"from": self.sender_id,
"body": message,
}))
.send()
.await
.map_err(|e| SmsError::ProviderError(e.to_string()))?;
if !response.status().is_success() {
let error_body = response.text().await.unwrap_or_default();
return Err(SmsError::ProviderError(
format!("MyGateway API error: {}", error_body)
));
}
Ok(())
}
async fn send_otp(&self, to: &str, code: &str) -> Result<(), SmsError> {
// Use the provider's OTP service, or fall back to a plain SMS
let message = format!("Your verification code is: {}. Valid for 5 minutes.", code);
self.send(to, &message).await
}
async fn verify_otp(&self, to: &str, code: &str) -> Result<bool, SmsError> {
// If your provider has a built-in OTP verification API, call it here.
// Otherwise, FORGE handles OTP verification internally when using
// the default OTP flow, so this can delegate to the built-in check.
let response = self.client
.post(format!("{}/verify", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&serde_json::json!({
"phone": to,
"code": code,
}))
.send()
.await
.map_err(|e| SmsError::ProviderError(e.to_string()))?;
let result: serde_json::Value = response
.json()
.await
.map_err(|e| SmsError::ProviderError(e.to_string()))?;
Ok(result["verified"].as_bool().unwrap_or(false))
}
}TIP
Use settings.get_encrypted() for sensitive values like API keys and tokens. This reads from the encrypted settings column and decrypts automatically. Use settings.get() for non-sensitive values like sender IDs and region codes.
Step 2: Register in the Factory
Add your driver to the factory's match statement so it can be instantiated at runtime:
// src/services/sms/mod.rs
pub mod twilio;
pub mod unifonic;
pub mod vonage;
pub mod my_gateway; // Add your module
use crate::models::settings::Settings;
pub async fn create_sms_provider(
settings: &Settings,
) -> Result<Box<dyn SmsProvider>, SmsError> {
let driver = settings
.get("sms", "driver")
.unwrap_or_else(|| "twilio".to_string());
match driver.as_str() {
"twilio" => Ok(Box::new(twilio::TwilioProvider::new(settings)?)),
"unifonic" => Ok(Box::new(unifonic::UnifonicProvider::new(settings)?)),
"vonage" => Ok(Box::new(vonage::VonageProvider::new(settings)?)),
"my_gateway" => Ok(Box::new(my_gateway::MyGatewayProvider::new(settings)?)),
_ => Err(SmsError::ProviderError(
format!("Unknown SMS driver: {}", driver)
)),
}
}WARNING
The factory match string must exactly match the value stored in the driver setting. Use lowercase with underscores for consistency (e.g., "my_gateway", not "MyGateway" or "my-gateway").
Step 3: Add Configuration Settings
Insert the required settings into the settings table so they appear in the admin panel and are available at runtime.
Migration
Create a migration to seed the default settings:
-- migrations/XXXXX_add_my_gateway_settings.sql
INSERT INTO settings (group_name, key, value, value_type, is_encrypted, label, sort_order)
VALUES
('sms', 'my_gateway_api_key', '', 'string', true, 'MyGateway API Key', 10),
('sms', 'my_gateway_sender_id', 'FORGE', 'string', false, 'MyGateway Sender ID', 11)
ON CONFLICT (group_name, key) DO NOTHING;Settings Table Schema
| Column | Description |
|---|---|
group_name | Category group (e.g., sms, email, storage, payments) |
key | Setting identifier (unique within group) |
value | Setting value (encrypted if is_encrypted = true) |
value_type | Data type: string, number, boolean, json |
is_encrypted | Whether the value is stored encrypted (AES-256-GCM) |
label | Human-readable label for the admin UI |
sort_order | Display order in the admin settings page |
TIP
Use the is_encrypted flag for any credential or secret. Encrypted settings are stored using AES-256-GCM, masked in the admin UI, and never exposed through API responses.
Step 4: Testing
Unit Tests
Test your provider with mocked HTTP responses:
#[cfg(test)]
mod tests {
use super::*;
use mockito::Server;
#[tokio::test]
async fn test_send_sms() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/v1/messages")
.with_status(200)
.with_body(r#"{"id": "msg-123", "status": "sent"}"#)
.create_async()
.await;
let provider = MyGatewayProvider {
api_key: "test-key".to_string(),
sender_id: "TEST".to_string(),
base_url: server.url() + "/v1",
client: Client::new(),
};
let result = provider.send("+1234567890", "Test message").await;
assert!(result.is_ok());
mock.assert_async().await;
}
#[tokio::test]
async fn test_send_sms_api_error() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/v1/messages")
.with_status(401)
.with_body(r#"{"error": "Invalid API key"}"#)
.create_async()
.await;
let provider = MyGatewayProvider {
api_key: "bad-key".to_string(),
sender_id: "TEST".to_string(),
base_url: server.url() + "/v1",
client: Client::new(),
};
let result = provider.send("+1234567890", "Test message").await;
assert!(result.is_err());
match result.unwrap_err() {
SmsError::ProviderError(msg) => {
assert!(msg.contains("Invalid API key"));
}
other => panic!("Expected ProviderError, got: {:?}", other),
}
mock.assert_async().await;
}
}Integration Tests
Test against the real provider API in a controlled environment:
#[cfg(test)]
mod integration_tests {
use super::*;
/// Run with: cargo test --features integration -- --ignored
#[tokio::test]
#[ignore]
async fn test_real_sms_delivery() {
// Load test credentials from environment
let settings = Settings::from_env();
let provider = MyGatewayProvider::new(&settings)
.expect("Failed to create provider");
let result = provider
.send("+1234567890", "Integration test message")
.await;
assert!(result.is_ok(), "SMS delivery failed: {:?}", result.err());
}
}WARNING
Integration tests send real messages and may incur costs. Guard them with #[ignore] and only run them explicitly with cargo test -- --ignored. Never use production credentials in tests.
Full Example: Custom Email Provider
The same pattern works for any provider category. Here is a condensed example for a custom email provider:
// src/services/email/custom_mailer.rs
use async_trait::async_trait;
use crate::services::email::{EmailProvider, EmailError, EmailMessage, EmailResult, EmailStatus};
pub struct CustomMailer {
api_key: String,
client: reqwest::Client,
}
impl CustomMailer {
pub fn new(settings: &Settings) -> Result<Self, EmailError> {
let api_key = settings
.get_encrypted("email", "custom_mailer_api_key")
.map_err(|_| EmailError::ProviderError(
"Missing custom_mailer_api_key".to_string()
))?;
Ok(Self {
api_key,
client: reqwest::Client::new(),
})
}
}
#[async_trait]
impl EmailProvider for CustomMailer {
async fn send(&self, message: EmailMessage) -> Result<EmailResult, EmailError> {
let response = self.client
.post("https://api.custommailer.com/send")
.bearer_auth(&self.api_key)
.json(&serde_json::json!({
"to": message.to,
"cc": message.cc,
"bcc": message.bcc,
"subject": message.subject,
"text": message.body_text,
"html": message.body_html,
}))
.send()
.await
.map_err(|e| EmailError::ProviderError(e.to_string()))?;
let body: serde_json::Value = response
.json()
.await
.map_err(|e| EmailError::ProviderError(e.to_string()))?;
Ok(EmailResult {
message_id: body["id"].as_str().unwrap_or("").to_string(),
status: EmailStatus::Queued,
})
}
async fn send_template(
&self,
to: &str,
template: &str,
data: &serde_json::Value,
) -> Result<EmailResult, EmailError> {
// Implement template sending logic
todo!("Implement template sending for CustomMailer")
}
async fn validate_email(&self, email: &str) -> Result<bool, EmailError> {
// Basic format validation
Ok(email.contains('@') && email.contains('.'))
}
}Then register it in the email factory:
// src/services/email/mod.rs
"custom_mailer" => Ok(Box::new(custom_mailer::CustomMailer::new(settings)?)),Publishing as a Package
If your custom provider could be useful to other FORGE users, you can publish it as a standalone Rust crate.
Package Structure
forge-provider-mygateway/
├── Cargo.toml
├── src/
│ ├── lib.rs # Re-exports and provider registration
│ └── provider.rs # The SmsProvider implementation
├── tests/
│ ├── unit.rs
│ └── integration.rs
└── README.mdCargo.toml
[package]
name = "forge-provider-mygateway"
version = "0.1.0"
edition = "2021"
description = "MyGateway SMS provider for FORGE"
license = "MIT"
[dependencies]
forge-core = "0.1" # FORGE trait definitions
async-trait = "0.1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
mockito = "1"lib.rs
// src/lib.rs
mod provider;
pub use provider::MyGatewayProvider;
use forge_core::sms::{SmsProvider, SmsError};
use forge_core::settings::Settings;
/// Factory function for FORGE to call during driver registration
pub fn create(settings: &Settings) -> Result<Box<dyn SmsProvider>, SmsError> {
Ok(Box::new(MyGatewayProvider::new(settings)?))
}Using the Package
Other FORGE users can install your provider by adding it to their Cargo.toml:
[dependencies]
forge-provider-mygateway = "0.1"Then register it in their factory:
"my_gateway" => forge_provider_mygateway::create(settings)?,TIP
Follow the naming convention forge-provider-{category}-{driver} for published packages. This makes them discoverable and consistent with the FORGE ecosystem. Examples: forge-provider-sms-mygateway, forge-provider-email-postmark, forge-provider-storage-gcs.
Checklist
Before deploying your custom provider, verify the following:
- [ ] All trait methods are implemented and handle errors properly
- [ ] Driver is registered in the factory with the correct match string
- [ ] Settings are seeded in the database with appropriate
is_encryptedflags - [ ] Unit tests cover success and error paths
- [ ] Integration tests confirm real API communication (run manually)
- [ ] Credentials are stored encrypted and never logged or exposed
- [ ] Error messages are descriptive but do not leak sensitive information