نمط مزودي الخدمات
يستخدم 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 المتاحة |
|---|---|---|
| SMS | SmsProvider | twilio, unifonic, log |
| البريد الإلكتروني | EmailProvider | smtp, sendgrid, log |
| التخزين | StorageProvider | local, s3 |
| المدفوعات | PaymentProvider | hyperpay, checkout, log |
نصيحة
كل فئة تتضمن driver log الذي يكتب العمليات إلى سجل التطبيق بدلاً من استدعاء APIs خارجية. هذا هو الافتراضي أثناء التطوير، لتتمكن من البناء والاختبار بدون إعداد بيانات اعتماد طرف ثالث.
عقد مزود SMS
هذه سمة مزود SMS الكاملة التي يجب على كل driver SMS تنفيذها:
#[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 نشط.
مصفوفة التنفيذ
| الدالة | Twilio | Unifonic | Log |
|---|---|---|---|
send() | Twilio Messages API | Unifonic Send API | يكتب في السجل |
send_otp() | Twilio Verify API | تنفيذ مخصص | يكتب في السجل |
verify_otp() | Twilio Verify Check | تنفيذ مخصص | يُرجع دائماً true |
validate_phone() | تحقق E.164 | تحقق E.164 | يُرجع دائماً true |
get_status() | Twilio Message Status | Unifonic Status API | يُرجع Delivered |
عقد مزود البريد الإلكتروني
#[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>,
}عقد مزود التخزين
#[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>;
}عقد مزود المدفوعات
#[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 الصحيح:
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:
# 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:
// 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 جديد لفئة مزود موجودة:
- أنشئ ملفاً جديداً في مجلد المزود (مثل
src/providers/sms/vonage.rs) - نفّذ سمة المزود:
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
}
}- سجّل الـ driver الجديد في المصنع:
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:
# .env.test
SMS_DRIVER=log
EMAIL_DRIVER=log
STORAGE_DRIVER=localهذا يضمن تشغيل الاختبارات بدون استدعاءات API خارجية مع الاستمرار في ممارسة خط أنابيب المزود الكامل. لاختبارات التكامل التي تحتاج للتحقق من سلوك المزود الفعلي، استخدم الـ drivers الحقيقية مع بيانات اعتماد اختبارية.
نصيحة
driver الـ log يُسجّل كل استدعاء مع معاملاته. في الاختبارات، يمكنك فحص مخرجات السجل للتحقق من أن دوال المزود الصحيحة استُدعيت بالوسائط المتوقعة.