Skip to content

نمط مزودي الخدمات

يستخدم FORGE نمط المزودين المبني على السمات (traits) للتكامل مع الخدمات الخارجية. بدلاً من ربط تطبيقك ببوابة SMS محددة أو خدمة بريد إلكتروني أو خلفية تخزين، يُولّد FORGE كوداً يبرمج ضد واجهات مجردة. يختار المصنع التنفيذ الفعلي في وقت التشغيل بناءً على الإعدادات.

هذا يعني أنه يمكنك التبديل من Twilio إلى Unifonic، أو من S3 إلى التخزين المحلي، أو من SendGrid إلى SMTP بتغيير قيمة واحدة في إعداداتك -- بدون تغييرات في الكود.

البنية المعمارية

         كود التطبيق


        ┌─────────────┐
        │    عقد      │     ← سمة مجردة (واجهة)
        │   المزود    │
        └──────┬──────┘

       ┌───────┼───────┐
       ▼       ▼       ▼
   ┌───────┐ ┌─────┐ ┌─────┐
   │Twilio │ │Uni- │ │ Log │  ← تنفيذات فعلية (drivers)
   │       │ │fonic│ │     │
   └───────┘ └─────┘ └─────┘

كود تطبيقك يستدعي sms_provider.send(to, message). سواء أرسل ذلك رسالة SMS حقيقية عبر Twilio، أو وجّهها عبر Unifonic، أو كتبها ببساطة في ملف سجل يعتمد كلياً على الـ driver المُعد.

فئات المزودين

يُولّد FORGE أربع فئات مزودين، كل منها بتنفيذات driver متعددة:

الفئةالسمةالـ Drivers المتاحة
SMSSmsProvidertwilio, unifonic, log
البريد الإلكترونيEmailProvidersmtp, sendgrid, log
التخزينStorageProviderlocal, s3
المدفوعاتPaymentProviderhyperpay, checkout, log

نصيحة

كل فئة تتضمن driver log الذي يكتب العمليات إلى سجل التطبيق بدلاً من استدعاء APIs خارجية. هذا هو الافتراضي أثناء التطوير، لتتمكن من البناء والاختبار بدون إعداد بيانات اعتماد طرف ثالث.

عقد مزود SMS

هذه سمة مزود SMS الكاملة التي يجب على كل driver SMS تنفيذها:

rust
#[async_trait]
pub trait SmsProvider: Send + Sync {
    /// Get the provider name (e.g., "twilio", "unifonic")
    fn name(&self) -> &'static str;

    /// Send a plain SMS message
    async fn send(&self, to: &str, message: &str) -> Result<SmsResult, SmsError>;

    /// Send an OTP code (may use provider's verify API)
    async fn send_otp(&self, to: &str, code: &str) -> Result<SmsResult, SmsError>;

    /// Verify an OTP code (if provider supports server-side verification)
    async fn verify_otp(&self, to: &str, code: &str) -> Result<bool, SmsError>;

    /// Validate phone number format
    fn validate_phone(&self, phone: &str) -> bool;

    /// Check delivery status of a sent message
    async fn get_status(&self, message_id: &str) -> Result<SmsStatus, SmsError>;
}

pub struct SmsResult {
    pub message_id: String,
    pub status: SmsStatus,
    pub cost: Option<f64>,
}

pub enum SmsStatus {
    Queued,
    Sent,
    Delivered,
    Failed(String),
}

كل driver -- Twilio أو Unifonic أو أي تنفيذ مخصص -- يجب أن يوفر جميع الدوال الخمس. كود التطبيق لا يحتاج أبداً لمعرفة أي driver نشط.

مصفوفة التنفيذ

الدالةTwilioUnifonicLog
send()Twilio Messages APIUnifonic Send APIيكتب في السجل
send_otp()Twilio Verify APIتنفيذ مخصصيكتب في السجل
verify_otp()Twilio Verify Checkتنفيذ مخصصيُرجع دائماً true
validate_phone()تحقق E.164تحقق E.164يُرجع دائماً true
get_status()Twilio Message StatusUnifonic Status APIيُرجع Delivered

عقد مزود البريد الإلكتروني

rust
#[async_trait]
pub trait EmailProvider: Send + Sync {
    fn name(&self) -> &'static str;

    /// Send an email message
    async fn send(&self, message: EmailMessage) -> Result<EmailResult, EmailError>;

    /// Send using a provider-hosted template
    async fn send_template(
        &self,
        template_id: &str,
        to: &str,
        data: HashMap<String, Value>,
    ) -> Result<EmailResult, EmailError>;

    /// Validate email address format
    fn validate_email(&self, email: &str) -> bool;
}

pub struct EmailMessage {
    pub to: Vec<String>,
    pub cc: Vec<String>,
    pub bcc: Vec<String>,
    pub subject: String,
    pub body_text: Option<String>,
    pub body_html: Option<String>,
    pub attachments: Vec<Attachment>,
    pub reply_to: Option<String>,
    pub headers: HashMap<String, String>,
}

عقد مزود التخزين

rust
#[async_trait]
pub trait StorageProvider: Send + Sync {
    fn name(&self) -> &'static str;

    /// Store a file from bytes
    async fn put(&self, path: &str, content: &[u8]) -> Result<StoredFile, StorageError>;

    /// Store from a stream (for large files)
    async fn put_stream(
        &self, path: &str, stream: impl AsyncRead
    ) -> Result<StoredFile, StorageError>;

    /// Retrieve file contents
    async fn get(&self, path: &str) -> Result<Vec<u8>, StorageError>;

    /// Get public URL for file
    fn url(&self, path: &str) -> String;

    /// Get temporary signed URL (for private files)
    fn temporary_url(
        &self, path: &str, expires: Duration
    ) -> Result<String, StorageError>;

    /// Delete a file
    async fn delete(&self, path: &str) -> Result<(), StorageError>;

    /// Check if file exists
    async fn exists(&self, path: &str) -> bool;

    /// Copy file to new path
    async fn copy(&self, from: &str, to: &str) -> Result<StoredFile, StorageError>;

    /// Move file to new path
    async fn mv(&self, from: &str, to: &str) -> Result<StoredFile, StorageError>;
}

عقد مزود المدفوعات

rust
#[async_trait]
pub trait PaymentProvider: Send + Sync {
    fn name(&self) -> &'static str;

    /// Create a checkout session (returns redirect URL)
    async fn create_checkout(
        &self, request: CheckoutRequest
    ) -> Result<CheckoutSession, PaymentError>;

    /// Get payment status by ID
    async fn get_payment(
        &self, payment_id: &str
    ) -> Result<PaymentResult, PaymentError>;

    /// Capture an authorized payment
    async fn capture(
        &self, payment_id: &str, amount: Option<Decimal>
    ) -> Result<PaymentResult, PaymentError>;

    /// Refund a payment (full or partial)
    async fn refund(
        &self, payment_id: &str, amount: Option<Decimal>
    ) -> Result<PaymentResult, PaymentError>;

    /// Verify webhook signature from provider
    fn verify_webhook(&self, payload: &[u8], signature: &str) -> bool;

    /// Parse webhook payload into structured event
    fn parse_webhook(
        &self, payload: &[u8]
    ) -> Result<WebhookEvent, PaymentError>;
}

نمط المصنع

يُولّد FORGE ProviderFactory الذي يقرأ إعدادات التطبيق ويُرجع الـ driver الصحيح:

rust
pub struct ProviderFactory;

impl ProviderFactory {
    pub fn sms(config: &Config) -> Box<dyn SmsProvider> {
        match config.sms_driver.as_str() {
            "twilio" => Box::new(TwilioProvider::new(config)),
            "unifonic" => Box::new(UnifonicProvider::new(config)),
            _ => Box::new(LogProvider::new("sms")),
        }
    }

    pub fn email(config: &Config) -> Box<dyn EmailProvider> {
        match config.email_driver.as_str() {
            "smtp" => Box::new(SmtpProvider::new(config)),
            "sendgrid" => Box::new(SendGridProvider::new(config)),
            _ => Box::new(LogProvider::new("email")),
        }
    }

    pub fn storage(config: &Config) -> Box<dyn StorageProvider> {
        match config.storage_driver.as_str() {
            "local" => Box::new(LocalStorageProvider::new(config)),
            "s3" => Box::new(S3StorageProvider::new(config)),
            _ => Box::new(LocalStorageProvider::new(config)),
        }
    }

    pub fn payment(config: &Config) -> Option<Box<dyn PaymentProvider>> {
        match config.payment_driver.as_deref() {
            Some("hyperpay") => Some(Box::new(HyperPayProvider::new(config))),
            Some("checkout") => Some(Box::new(CheckoutProvider::new(config))),
            _ => None,
        }
    }
}

تحذير

مزود المدفوعات يُرجع Option لأنه ليس كل تطبيق يحتاج معالجة مدفوعات. تحقق دائماً من None قبل محاولة شحن عميل.

الإعدادات

تُعد المزودات في forge.yaml في وقت التوليد وفي جدول settings في قاعدة البيانات في وقت التشغيل.

إعدادات وقت التوليد

عيّن الـ drivers الافتراضية في forge.yaml:

yaml
# forge.yaml
providers:
  sms: twilio
  email: smtp
  storage: local
  payment: hyperpay    # اختياري

هذا يُحدد أي تنفيذات driver تُضمّن في الكود المُولّد.

إعدادات وقت التشغيل

بيانات اعتماد وإعدادات المزودين تُخزّن في جدول settings وتُحمّل عند بدء التطبيق:

┌──────────────────────────┬──────────────────────────────────┐
│ المفتاح                   │ القيمة                           │
├──────────────────────────┼──────────────────────────────────┤
│ sms.driver               │ twilio                           │
│ sms.twilio.account_sid   │ AC...                            │
│ sms.twilio.auth_token    │ ••••••••                         │
│ sms.twilio.from_number   │ +1234567890                      │
├──────────────────────────┼──────────────────────────────────┤
│ email.driver             │ smtp                             │
│ email.smtp.host          │ smtp.gmail.com                   │
│ email.smtp.port          │ 587                              │
│ email.smtp.username      │ app@example.com                  │
│ email.smtp.password      │ ••••••••                         │
├──────────────────────────┼──────────────────────────────────┤
│ storage.driver           │ s3                               │
│ storage.s3.bucket        │ my-app-uploads                   │
│ storage.s3.region        │ us-east-1                        │
│ storage.s3.access_key    │ AKIA...                          │
│ storage.s3.secret_key    │ ••••••••                         │
└──────────────────────────┴──────────────────────────────────┘

نصيحة

يمكنك تغيير الـ driver النشط في وقت التشغيل من خلال لوحة إعدادات الإدارة. على سبيل المثال، التبديل من sms.driver من twilio إلى unifonic يسري فوراً بدون إعادة نشر.

استخدام المزودين في كود التطبيق

يحقن التطبيق المُولّد المزودين عبر آلية state في Axum:

rust
// In a handler
pub async fn send_verification(
    State(app_state): State<AppState>,
    Json(payload): Json<VerifyRequest>,
) -> Result<Json<MessageResponse>, ApiError> {
    let code = generate_otp_code();

    // Uses whichever SMS driver is configured
    app_state.sms_provider
        .send_otp(&payload.phone, &code)
        .await?;

    Ok(Json(MessageResponse {
        message: "Verification code sent".to_string(),
    }))
}

المعالج لا يعرف ولا يهتم إذا أُرسلت SMS عبر Twilio أو Unifonic أو سُجّلت ببساطة. المصنع حدد الـ driver الصحيح عند بدء التطبيق.

إضافة مزود مخصص

لإضافة driver جديد لفئة مزود موجودة:

  1. أنشئ ملفاً جديداً في مجلد المزود (مثل src/providers/sms/vonage.rs)
  2. نفّذ سمة المزود:
rust
pub struct VonageProvider {
    api_key: String,
    api_secret: String,
    from: String,
}

#[async_trait]
impl SmsProvider for VonageProvider {
    fn name(&self) -> &'static str { "vonage" }

    async fn send(&self, to: &str, message: &str) -> Result<SmsResult, SmsError> {
        // Vonage API implementation
    }

    async fn send_otp(&self, to: &str, code: &str) -> Result<SmsResult, SmsError> {
        self.send(to, &format!("Your code is: {}", code)).await
    }

    async fn verify_otp(&self, _to: &str, _code: &str) -> Result<bool, SmsError> {
        // Custom OTP verification logic
        Ok(false) // Should verify against stored code
    }

    fn validate_phone(&self, phone: &str) -> bool {
        phone.starts_with('+') && phone.len() >= 10
    }

    async fn get_status(&self, message_id: &str) -> Result<SmsStatus, SmsError> {
        // Vonage status check implementation
    }
}
  1. سجّل الـ driver الجديد في المصنع:
rust
pub fn sms(config: &Config) -> Box<dyn SmsProvider> {
    match config.sms_driver.as_str() {
        "twilio" => Box::new(TwilioProvider::new(config)),
        "unifonic" => Box::new(UnifonicProvider::new(config)),
        "vonage" => Box::new(VonageProvider::new(config)),  // New
        _ => Box::new(LogProvider::new("sms")),
    }
}

لأن الـ driver الجديد ينفذ نفس السمة، لا يحتاج أي كود آخر في التطبيق للتغيير.

الاختبار مع المزودين

driver الـ log يجعل الاختبار سهلاً. في إعدادات الاختبار، عيّن جميع المزودين إلى log:

yaml
# .env.test
SMS_DRIVER=log
EMAIL_DRIVER=log
STORAGE_DRIVER=local

هذا يضمن تشغيل الاختبارات بدون استدعاءات API خارجية مع الاستمرار في ممارسة خط أنابيب المزود الكامل. لاختبارات التكامل التي تحتاج للتحقق من سلوك المزود الفعلي، استخدم الـ drivers الحقيقية مع بيانات اعتماد اختبارية.

نصيحة

driver الـ log يُسجّل كل استدعاء مع معاملاته. في الاختبارات، يمكنك فحص مخرجات السجل للتحقق من أن دوال المزود الصحيحة استُدعيت بالوسائط المتوقعة.

Released under the MIT License.