Skip to content

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

CategoryTraitMethods
SMSSmsProvidersend, send_otp, verify_otp
EmailEmailProvidersend, send_template, validate_email
StorageStorageProviderput, put_stream, get, url, temporary_url, delete, exists
PaymentsPaymentProvidercreate_checkout, get_status, refund, list_transactions

Example: Custom SMS Provider

Create a new file for your driver implementation:

rust
// 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:

rust
// 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:

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

ColumnDescription
group_nameCategory group (e.g., sms, email, storage, payments)
keySetting identifier (unique within group)
valueSetting value (encrypted if is_encrypted = true)
value_typeData type: string, number, boolean, json
is_encryptedWhether the value is stored encrypted (AES-256-GCM)
labelHuman-readable label for the admin UI
sort_orderDisplay 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:

rust
#[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:

rust
#[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:

rust
// 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:

rust
// 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.md

Cargo.toml

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

rust
// 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:

toml
[dependencies]
forge-provider-mygateway = "0.1"

Then register it in their factory:

rust
"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_encrypted flags
  • [ ] 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

Released under the MIT License.