Rails و Rack
یک initializer، کاری پسزمینه که نمیتواند دو بار بفرستد، یک کنترلر وبهوک، و آزمونهایی که هرگز به شبکه نمیرسند.
راهاندازی
gem هیچ یکپارچگی اختصاصی با Rails ندارد: نه Railtie، و نه delivery method برای ActionMailer. API را از یک کار پسزمینه یا یک service object، از راه کلاینت مشترک یا کلاینتی که خودتان میسازید فراخوانی میکنید، درست مانند هر برنامهٔ Ruby دیگر.
api_key = Rails.application.credentials.dig(:openemail, :api_key) OpenEmail.init(api_key:, timeout: 15) if api_keyکلید را با bin/rails credentials:edit، زیر openemail و api_key ذخیره کنید. پس از OpenEmail.init، OpenEmail.emails، OpenEmail.webhooks و هر فضای نام دیگری در هر درخواست، کار پسزمینه و نشست کنسولِ آن فرایند از همان کلاینت استفاده میکنند.
بدون این اعتبارنامه، کلاینت مشترک بهجای آن در نخستین فراخوانیاش خودش را از OPENEMAIL_API_KEY میسازد، پس استقراری که کلید را در محیط نگه میدارد اصلاً به initializer نیاز ندارد. OpenEmail.init وقتی هیچ اعتبارنامهای در هیچ جا پیدا نکند ArgumentError را raise میکند، و initializer برای فرمانهایی مانند assets:precompile هم اجرا میشود که ممکن است کلید در آنها تنظیم نشده باشد، و به همین دلیل فراخوانی بالا محافظتشده است.
ارسال از یک کار پسزمینه
بهجای درون درخواست، از یک کار پسزمینه بفرستید، تا ارسالی کند یا ناموفق هرگز یک صفحه را معطل نکند. idempotency_key: را از رکوردی مشتق کنید که ارسال دربارهٔ آن است. کاری که دوباره اجرا شود، چون صف دوباره امتحانش کرده یا یک worker پس از پاسخ API از کار افتاده، آنگاه با همان کلید میفرستد، و API بهجای فرستادن پیام دوم، پیامی را که پیشتر فرستاده بازپخش میکند.
class SendInvoiceJob < ApplicationJob queue_as :default retry_on OpenEmail::NetworkError, wait: 30.seconds, attempts: 5 discard_on OpenEmail::ValidationError def perform(invoice) sent = OpenEmail.emails.send( from: "Acme Billing <[email protected]>", to: invoice.customer_email, subject: "Invoice #{invoice.number}", html: "<p>Your invoice #{invoice.number} is attached.</p>", attachments: [{filename: "#{invoice.number}.pdf", content: invoice.pdf.download, contentType: "application/pdf"}], idempotency_key: "invoice:#{invoice.id}:email" ) invoice.update!(email_id: sent[:id], email_status: sent[:status]) endendretry_on OpenEmail::NetworkError ارسالی را که پاسخی نگرفته به صف برمیگرداند، و اگر اجرای نخست واقعاً به API رسیده باشد، همان کلید اجرای بعدی را به یک بازپخش تبدیل میکند. discard_on OpenEmail::ValidationError پیامی را که API به همان شکل نوشتهشده رد کرده کنار میگذارد، چون فرستادن دوبارهاش نمیتواند موفق شود. تا وقتی یک NetworkError به کار پسزمینه برسد، gem خودش ارسال را با همان کلید دوباره امتحان کرده است، بهطور پیشفرض دو بار.
کلید را برای آن رکورد و آن هدف ثابت نگه دارید، همانطور که invoice:42:email ثابت است. کلیدی که از یک برچسب زمانی یا از SecureRandom ساخته شود در هر اجرا تازه است، و آنگاه کاری که دوباره تلاش شود دو بار میفرستد. استفادهٔ دوباره از یک کلید با بدنهای متفاوت، مثلاً فاکتوری که میان دو اجرا تغییر کرده، بهجای فرستاده شدن با یک 422 idempotency_key_reuse رد میشود.
invoice.pdf.download بایتها را از Active Storage بهصورت یک String دودویی برمیگرداند، که content یک پیوست آن را همانطور که هست میپذیرد. زمانی از Active Support، مانند scheduledAt: 1.day.from_now، مانند هر Time دیگری بهصورت یک لحظهٔ UTC فرستاده میشود، در حالی که Date.tomorrow یک Date در Ruby است و بهصورت تاریخی خالی فرستاده میشود که API آن را نیمهشب UTC میخواند، پس وقتی ساعت مهم است یک زمان بدهید.
یک کار پسزمینهٔ سادهٔ Sidekiq هم به همین شکل کار میکند. کلید را از آرگومانهای آن مشتق کنید و بگذارید تلاشهای دوبارهٔ خودِ Sidekiq ارسال را بازپخش کنند.
class WelcomeEmailJob include Sidekiq::Job def perform(user_id, email) OpenEmail.emails.send( from: "Acme <[email protected]>", to: email, subject: "Welcome to Acme", text: "Glad you are here.", idempotency_key: "welcome:#{user_id}" ) endendاز یک service object
یک service object که کلاینتش را بهعنوان آرگومان میگیرد، با کلاینت مشترک بهعنوان پیشفرض، ارسال را در یک جا نگه میدارد و به آزمون اجازه میدهد کلاینت خودش را به آن بدهد.
class InvoiceEmail def initialize(client: OpenEmail.client) @client = client end def deliver(number:, to:) @client.emails.send( from: "Acme Billing <[email protected]>", to:, subject: "Invoice #{number}", text: "Your invoice #{number} is attached.", idempotency_key: "invoice:#{number}:email" ) endendInvoiceEmail.new.deliver(number: "INV-2026-0042", to: "[email protected]") از راه کلاینت مشترک میفرستد، و InvoiceEmail.new(client: test_client) از راه هر کلاینت دیگری.
دریافت وبهوکها
هر تحویل را پیش از آنکه بر اساسش کاری کنید راستیآزمایی کنید. Rails بدنهٔ JSON را به params تجزیه میکند، اما امضا بایتهای خام را پوشش میدهد، پس request.raw_post را همراه با request.headers، که سرآیند امضا را از آن میخواند، به وارسیکننده بدهید.
class OpenEmailWebhooksController < ActionController::API def create event = OpenEmail.verify_webhook_signature( payload: request.raw_post, headers: request.headers, secret: Rails.application.credentials.dig(:openemail, :webhook_secret) ) HandleOpenEmailEventJob.perform_later(event) head :no_content rescue OpenEmail::WebhookSignatureError head :bad_request endendRails.application.routes.draw do post "/webhooks/openemail", to: "open_email_webhooks#create"endکنترلر ActionController::API هیچ محافظتی در برابر جعل (forgery protection) ندارد که لازم باشد از آن عبور کنید. زیر ActionController::Base، یا ApplicationControllerای که بر پایهٔ آن ساخته شده، skip_forgery_protection را اضافه کنید، وگرنه Rails درخواست POST را پیش از اجرای action شما رد میکند، چون یک تحویل هیچ authenticity token ندارد.
class OpenEmailWebhooksController < ApplicationController skip_forgery_protection def create event = OpenEmail.verify_webhook_signature( payload: request.raw_post, headers: request.headers, secret: Rails.application.credentials.dig(:openemail, :webhook_secret) ) HandleOpenEmailEventJob.perform_later(event) head :no_content rescue OpenEmail::WebhookSignatureError head :bad_request endendرویداد را به یک کار پسزمینه بسپارید و بیدرنگ پاسخ دهید: تحویلی که ظرف 5 ثانیه پاسخی نگیرد شکستخورده به حساب میآید و بعداً دوباره فرستاده میشود. id رویداد در هر تلاش دوباره و بازپخش آن یکسان است، پس شناسههایی را که رسیدگی کردهاید ذخیره کنید و از شناسهای که دیدهاید بگذرید.
head :bad_request به تحویل جعلی یا کهنه پاسخ میدهد. نبودن secret شکست دیگری است: verify_webhook_signature برای آن ArgumentError را raise میکند، که کنترلر آن را rescue نمیکند، پس برنامهای که بد پیکربندی شده 500 پاسخ میدهد و تحویل پس از آنکه مشکل را رفع کردید دوباره امتحان میشود، بهجای آنکه همهٔ رویدادها بهعنوان جعلی رد شوند.
همین بررسی در هر برنامهٔ Rack کار میکند، با خودِ env در Rack بهعنوان headers:، که سرآیند امضا در آن به شکل HTTP_X_OPENEMAIL_SIGNATURE میرسد. یک اندپوینت Rack مانند این، بهتنهایی با run OpenEmailWebhook.new در config.ru اجرا میشود، یا درون Rails با mount OpenEmailWebhook.new => "/webhooks/openemail" در routes.
class OpenEmailWebhook def call(env) request = Rack::Request.new(env) event = OpenEmail.verify_webhook_signature( payload: request.body.read, headers: env, secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET") ) HandleOpenEmailEventJob.perform_later(event) [204, {}, []] rescue OpenEmail::WebhookSignatureError [400, {"content-type" => "text/plain"}, ["bad signature"]] endendپارامترها و بارگذاریها
بدنهٔ درخواست میتواند هر شیئی باشد که به to_hash پاسخ دهد، پس ActionController::Parameters مجاز (permitted) مستقیماً عبور میکند. فایلی از یک فرم، یعنی یک ActionDispatch::Http::UploadedFile، مستقیماً به files.upload میرود، که نام و نوع فایل را از آن برمیدارد.
class UploadsController < ApplicationController def create file = OpenEmail.files.upload(params.require(:file)) render json: {id: file[:id]} endendتردها و forkها
یک کلاینت را میتوان بیخطر میان تردها به اشتراک گذاشت، پس تردهای Puma و workerهای Sidekiq همه میتوانند همزمان از راه OpenEmail.emails بفرستند. کلاینت پس از ساخته شدن منجمد (frozen) میشود، مخزن اتصال پشت آن قفل میگیرد، و OpenEmail.init و OpenEmail.client هم همینطور.
پس از fork، مانند حالت cluster در Puma با preload_app!، یا در Unicorn و Resque، فرایند فرزند اتصالهایی را که به ارث برده فراموش میکند و اتصالهای خودش را باز میکند، پس کلاینتی که پیش از fork در یک initializer ساخته شده در هر worker بیخطر است. چیزی نیست که لازم باشد در on_worker_boot دوباره وصل شود.
هر فرایند برای هر میزبان تا 8 اتصال بیکار را هرکدام به مدت 2 ثانیه نگه میدارد. برای تغییر این، OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) خودتان را بهعنوان adapter: بدهید.
آزمون
در آزمونهایتان به کلاینت یک آداپتور بدهید تا هیچ چیزی از دستگاه بیرون نرود. OpenEmail.init در setup کلاینت مشترک را برای کل فرایند جایگزین میکند، و OpenEmail.reset_client در teardown آن را کنار میگذارد، پس فراخوانی بعدی دوباره از محیط ساخته میشود.
require "test_helper" class SendInvoiceJobTest < ActiveJob::TestCase FakeOpenEmail = Struct.new(:requests) do def call(request) requests << request OpenEmail::HttpResponse.new( status: 200, headers: {"content-type" => "application/json"}, body: JSON.generate({id: "msg_test", status: "sent"}) ) end end setup do @api = FakeOpenEmail.new([]) OpenEmail.init(api_key: "oe_test_fake", adapter: @api, max_retries: 0, disable_update_notice: true) end teardown do OpenEmail.reset_client end test "a job that runs twice sends under one key" do invoice = invoices(:september) 2.times { SendInvoiceJob.perform_now(invoice) } keys = @api.requests.map { |request| request.headers["Idempotency-Key"] } assert_equal ["invoice:#{invoice.id}:email"] * 2, keys assert_equal "/emails", URI(@api.requests.first.url).path endendهر درخواست ثبتشده یک OpenEmail::HttpRequest با method، url، headers، body و timeout است، و body آن همان JSONی است که فرستاده میشد، پس JSON.parse(request.body) خودِ پیام را نشان میدهد. برای آزمودن اینکه کدتان با رد شدن چگونه رفتار میکند، یک وضعیت خطا با پاکت خطای API برگردانید.
کلاینت مشترک به کل فرایند تعلق دارد، پس آزمونهایی که همزمان در تردها اجرا میشوند باید به کدِ زیر آزمون کلاینت خودش را بدهند، همانطور که service object بالا میپذیرد.