Skip to content

المزودون المخصصون

نظام مزودي FORGE مُصمم ليكون قابلاً للتوسيع. يمكنك إنشاء drivers مخصصة لأي فئة مزود (SMS، البريد الإلكتروني، التخزين، المدفوعات) بتنفيذ السمة المقابلة، وتسجيل الـ driver في المصنع، وإضافة إعدادات التهيئة.

نظرة عامة

إنشاء مزود مخصص يتضمن أربع خطوات:

┌──────────────────────────────────────────────────────────────┐
│                إنشاء مزود مخصص                                │
├──────────────────────────────────────────────────────────────┤
│                                                               │
│  1. تنفيذ السمة        عرّف struct الـ driver الخاص بك        │
│                        ونفّذ جميع الدوال المطلوبة              │
│                                                               │
│  2. التسجيل في المصنع   أضف match arm حتى يستطيع المصنع       │
│                        إنشاء الـ driver الخاص بك               │
│                                                               │
│  3. إضافة الإعدادات     أدخل الإعدادات في قاعدة البيانات       │
│                        لبيانات الاعتماد والخيارات              │
│                                                               │
│  4. اختبار الـ driver   اكتب اختبارات وحدة وتكامل             │
│                        للتحقق من السلوك الصحيح                 │
│                                                               │
└──────────────────────────────────────────────────────────────┘

الخطوة 1: تنفيذ سمة المزود

اختر السمة التي تُطابق فئة مزودك. هذا المثال يشرح إنشاء مزود SMS مخصص، لكن نفس النمط ينطبق على جميع الفئات.

السمات المتاحة

الفئةالسمةالدوال
SMSSmsProvidersend، send_otp، verify_otp
البريدEmailProvidersend، send_template، validate_email
التخزينStorageProviderput، put_stream، get، url، temporary_url، delete، exists
المدفوعاتPaymentProvidercreate_checkout، get_status، refund، list_transactions

مثال: مزود SMS مخصص

أنشئ ملفاً جديداً لتنفيذ الـ driver:

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_body)
            ));
        }

        Ok(())
    }

    async fn send_otp(&self, to: &str, code: &str) -> Result<(), SmsError> {
        // Use provider's OTP service, or fall back to 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 you 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))
    }
}

نصيحة

استخدم settings.get_encrypted() للقيم الحساسة مثل مفاتيح API والرموز. هذا يقرأ من عمود الإعدادات المُشفّرة ويفك التشفير تلقائياً. استخدم settings.get() للقيم غير الحساسة مثل معرّفات المُرسل ورموز المناطق.

الخطوة 2: التسجيل في المصنع

أضف الـ driver إلى عبارة match في المصنع حتى يمكن إنشاؤه أثناء التشغيل:

rust
// src/services/sms/mod.rs

pub mod twilio;
pub mod unifonic;
pub mod vonage;
pub mod my_gateway;  // أضف الوحدة الخاصة بك

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!("driver SMS غير معروف: {}", driver)
        )),
    }
}

تحذير

سلسلة match في المصنع يجب أن تُطابق بالضبط القيمة المُخزّنة في إعداد driver. استخدم الأحرف الصغيرة مع الشرطات السفلية للاتساق (مثل "my_gateway"، وليس "MyGateway" أو "my-gateway").

الخطوة 3: إضافة إعدادات التهيئة

أدخل الإعدادات المطلوبة في جدول 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;

مخطط جدول الإعدادات

العمودالوصف
group_nameمجموعة الفئة (مثل sms، email، storage، payments)
keyمعرّف الإعداد (فريد ضمن المجموعة)
valueقيمة الإعداد (مُشفّرة إذا كان is_encrypted = true)
value_typeنوع البيانات: string، number، boolean، json
is_encryptedما إذا كانت القيمة مُخزّنة مُشفّرة (AES-256-GCM)
labelتسمية قابلة للقراءة لواجهة الإدارة
sort_orderترتيب العرض في صفحة إعدادات الإدارة

نصيحة

استخدم علم is_encrypted لأي بيانات اعتماد أو سر. تُخزّن الإعدادات المُشفّرة باستخدام AES-256-GCM، وتُخفى في واجهة الإدارة، ولا تُكشف أبداً من خلال استجابات API.

الخطوة 4: الاختبار

اختبارات الوحدة

اختبر مزودك مع استجابات HTTP مُحاكاة:

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", "رسالة اختبار").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": "مفتاح API غير صالح"}"#)
            .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", "رسالة اختبار").await;
        assert!(result.is_err());

        match result.unwrap_err() {
            SmsError::ProviderError(msg) => {
                assert!(msg.contains("مفتاح API غير صالح"));
            }
            other => panic!("توقعت ProviderError، حصلت على: {:?}", other),
        }

        mock.assert_async().await;
    }
}

اختبارات التكامل

اختبر ضد API المزود الحقيقي في بيئة مُتحكم فيها:

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("فشل في إنشاء المزود");

        let result = provider
            .send("+1234567890", "رسالة اختبار تكامل")
            .await;

        assert!(result.is_ok(), "فشل تسليم SMS: {:?}", result.err());
    }
}

تحذير

اختبارات التكامل ترسل رسائل حقيقية وقد تتكبد تكاليف. احمِها بـ #[ignore] وشغّلها صراحة فقط بـ cargo test -- --ignored. لا تستخدم أبداً بيانات اعتماد الإنتاج في الاختبارات.

مثال كامل: مزود بريد إلكتروني مخصص

نفس النمط يعمل لأي فئة مزود. هنا مثال مُختصر لمزود بريد إلكتروني مخصص:

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(
                "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('.'))
    }
}

ثم سجّله في مصنع البريد الإلكتروني:

rust
// src/services/email/mod.rs

"custom_mailer" => Ok(Box::new(custom_mailer::CustomMailer::new(settings)?)),

النشر كحزمة

إذا كان مزودك المخصص قد يكون مفيداً لمستخدمي FORGE الآخرين، يمكنك نشره كـ crate Rust مستقل.

بنية الحزمة

forge-provider-mygateway/
├── Cargo.toml
├── src/
│   ├── lib.rs          # إعادة التصدير وتسجيل المزود
│   └── provider.rs     # تنفيذ SmsProvider
├── tests/
│   ├── unit.rs
│   └── integration.rs
└── README.md

Cargo.toml

toml
[package]
name = "forge-provider-mygateway"
version = "0.1.0"
edition = "2021"
description = "مزود SMS MyGateway لـ FORGE"
license = "MIT"

[dependencies]
forge-core = "0.1"       # تعريفات سمات FORGE
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)?))
}

استخدام الحزمة

مستخدمو FORGE الآخرون يمكنهم تثبيت مزودك بإضافته إلى Cargo.toml:

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

ثم تسجيله في مصنعهم:

rust
"my_gateway" => forge_provider_mygateway::create(settings)?,

نصيحة

اتبع اصطلاح التسمية forge-provider-{category}-{driver} للحزم المنشورة. هذا يجعلها قابلة للاكتشاف ومتسقة مع منظومة FORGE. أمثلة: forge-provider-sms-mygateway، forge-provider-email-postmark، forge-provider-storage-gcs.

قائمة التحقق

قبل نشر مزودك المخصص، تحقق من التالي:

  • [ ] جميع دوال السمة مُنفّذة وتتعامل مع الأخطاء بشكل صحيح
  • [ ] الـ Driver مُسجّل في المصنع بسلسلة match الصحيحة
  • [ ] الإعدادات مبذورة في قاعدة البيانات بأعلام is_encrypted المناسبة
  • [ ] اختبارات الوحدة تغطي مسارات النجاح والخطأ
  • [ ] اختبارات التكامل تؤكد التواصل الحقيقي مع API (تُشغّل يدوياً)
  • [ ] بيانات الاعتماد مُخزّنة مُشفّرة ولا تُسجّل أو تُكشف أبداً
  • [ ] رسائل الخطأ وصفية لكنها لا تُسرّب معلومات حساسة

Released under the MIT License.