Belgelere geç
Ruby

Rails ve Rack

Bir başlatıcı (initializer), iki kez gönderemeyen bir iş, bir webhook denetleyicisi ve ağa hiç ulaşmayan testler.

Kurulum

Gem'in kendine ait bir Rails entegrasyonu yoktur: Railtie de, ActionMailer teslim yöntemi de yoktur. API'yi herhangi bir Ruby programında olduğu gibi bir işten ya da bir servis nesnesinden, paylaşılan istemci ya da sizin kurduğunuz bir istemci üzerinden çağırırsınız.

config/initializers/openemail.rb
api_key = Rails.application.credentials.dig(:openemail, :api_key) OpenEmail.init(api_key:, timeout: 15) if api_key

Anahtarı bin/rails credentials:edit ile openemail altında api_key olarak saklayın. OpenEmail.init sonrasında OpenEmail.emails, OpenEmail.webhooks ve diğer tüm ad alanları, sürecin her isteğinde, işinde ve konsol oturumunda o istemciyi kullanır.

Bu kimlik bilgisi yoksa paylaşılan istemci kendini bunun yerine ilk çağrısında OPENEMAIL_API_KEY değerinden kurar; bu yüzden anahtarı ortamda tutan bir dağıtımın hiç başlatıcıya ihtiyacı yoktur. OpenEmail.init hiçbir yerde kimlik bilgisi bulamadığında ArgumentError fırlatır ve bir başlatıcı, anahtarın ayarlanmamış olabileceği assets:precompile gibi komutlar için de çalışır. Yukarıdaki çağrının bir koşulla korunmasının nedeni budur.

Bir işten gönderme

Yavaş ya da başarısız bir gönderim hiçbir sayfayı bekletmesin diye istekten değil, bir işten gönderin. idempotency_key: değerini gönderimin ilgili olduğu kayıttan türetin. Kuyruk onu yeniden denediği için ya da API yanıt verdikten sonra bir worker çöktüğü için yeniden çalışan bir iş bu durumda aynı anahtarla gönderir ve API ikinci bir ileti göndermek yerine daha önce gönderdiği iletiyi yeniden oynatır.

app/jobs/send_invoice_job.rb
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])  endend

retry_on OpenEmail::NetworkError yanıt alamayan bir gönderimi kuyruğa geri verir ve ilk çalıştırma API'ye gerçekten ulaştıysa aynı anahtar bir sonraki çalıştırmayı yeniden oynatmaya dönüştürür. discard_on OpenEmail::ValidationError, API'nin yazıldığı hâliyle reddettiği bir iletiyi bırakır, çünkü onu yeniden göndermek başarılı olamaz. Bir NetworkError işe ulaştığında gem gönderimi aynı anahtarla zaten kendisi yeniden denemiştir, varsayılan olarak iki kez.

Anahtarı, invoice:42:email örneğinde olduğu gibi kayıt ve amaç için sabit tutun. Bir zaman damgasından ya da SecureRandom ile üretilen bir anahtar her çalıştırmada yenidir ve bu durumda yeniden denenen bir iş iki kez gönderir. Bir anahtarı farklı bir gövdeyle, örneğin iki çalıştırma arasında değişmiş bir faturayla yeniden kullanmak gönderimle sonuçlanmaz, 422 idempotency_key_reuse ile reddedilir.

invoice.pdf.download, Active Storage'daki baytları ikili bir String olarak döndürür ve bir ekin content alanı bunu olduğu gibi alır. scheduledAt: 1.day.from_now gibi Active Support'tan gelen bir zaman, her Time gibi bir UTC anı olarak gönderilir; Date.tomorrow ise bir Ruby Date'tir ve API'nin UTC gece yarısı olarak okuduğu çıplak bir tarih olarak gönderilir. Bu yüzden saat önemliyse bir zaman geçirin.

Sıradan bir Sidekiq işi de aynı şekilde çalışır. Anahtarı işin argümanlarından türetin ve gönderimi Sidekiq'in kendi yeniden denemelerinin yeniden oynatmasına bırakın.

app/sidekiq/welcome_email_job.rb
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

Bir servis nesnesinden

İstemcisini argüman olarak alan ve varsayılan olarak paylaşılan istemciyi kullanan bir servis nesnesi, gönderimi tek bir yerde tutar ve bir testin ona kendi istemcisini vermesine olanak tanır.

app/services/invoice_email.rb
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"    )  endend

InvoiceEmail.new.deliver(number: "INV-2026-0042", to: "[email protected]") paylaşılan istemci üzerinden, InvoiceEmail.new(client: test_client) ise başka herhangi bir istemci üzerinden gönderir.

Webhook'ları alma

Her teslimatı, ona göre işlem yapmadan önce doğrulayın. Rails bir JSON gövdesini params içine ayrıştırır, ancak imza ham baytları kapsar; bu yüzden doğrulayıcıya request.raw_post değerini, imza başlığını okuyacağı request.headers ile birlikte geçirin.

app/controllers/open_email_webhooks_controller.rb
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  endend
config/routes.rb
Rails.application.routes.draw do  post "/webhooks/openemail", to: "open_email_webhooks#create"end

Bir ActionController::API denetleyicisinde aşılması gereken bir sahtecilik koruması yoktur. ActionController::Base ya da onun üzerine kurulu bir ApplicationController altında skip_forgery_protection ekleyin; aksi hâlde Rails, eyleminiz çalışmadan önce POST isteğini reddeder, çünkü bir teslimat hiçbir doğruluk tokenı taşımaz.

app/controllers/open_email_webhooks_controller.rb
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

Olayı bir işe devredin ve hemen yanıt verin: 5 saniye içinde yanıt almayan bir teslimat başarısız sayılır ve daha sonra yeniden gönderilir. Olayın id değeri her yeniden denemede ve yeniden oynatmada aynıdır; bu yüzden işlediğiniz kimlikleri saklayın ve daha önce gördüğünüz birini atlayın.

head :bad_request sahte ya da eskimiş bir teslimata yanıt verir. Eksik bir gizli anahtar farklı bir hatadır: verify_webhook_signature bunun için denetleyicinin yakalamadığı bir ArgumentError fırlatır; böylece yanlış yapılandırılmış bir uygulama 500 ile yanıt verir ve her olayın sahte diye geri çevrilmesi yerine, siz sorunu düzelttiğinizde teslimat yeniden denenir.

Aynı denetim herhangi bir Rack uygulamasında, headers: olarak Rack ortamının (env) kendisi verilerek çalışır; orada imza başlığı HTTP_X_OPENEMAIL_SIGNATURE olarak gelir. Bunun gibi bir Rack uç noktası config.ru içinde run OpenEmailWebhook.new ile tek başına ya da rotalarda mount OpenEmailWebhook.new => "/webhooks/openemail" ile Rails içinde çalışır.

app/webhooks/open_email_webhook.rb
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

Parametreler ve yüklemeler

Bir istek gövdesi to_hash çağrısına yanıt veren herhangi bir nesne olabilir; bu yüzden izin verilmiş ActionController::Parameters doğrudan geçer. Bir formdan gelen dosya, yani bir ActionDispatch::Http::UploadedFile, doğrudan files.upload metoduna gider ve bu metot dosyanın adını ve türünü ondan alır.

app/controllers/uploads_controller.rb
class UploadsController < ApplicationController  def create    file = OpenEmail.files.upload(params.require(:file))     render json: {id: file[:id]}  endend

İş parçacıkları ve fork'lar

Tek bir istemci iş parçacıkları arasında güvenle paylaşılabilir; bu yüzden Puma'nın iş parçacıkları ve Sidekiq'in worker'ları aynı anda OpenEmail.emails üzerinden gönderebilir. İstemci kurulduktan sonra dondurulur, arkasındaki bağlantı havuzu bir kilit alır; OpenEmail.init ve OpenEmail.client de öyle.

preload_app! ile Puma'nın küme modunda, Unicorn'da ya da Resque'te olduğu gibi bir fork'tan sonra alt süreç devraldığı bağlantıları unutur ve kendi bağlantılarını açar; bu yüzden fork'tan önce bir başlatıcıda kurulan bir istemci her worker'da güvenlidir. on_worker_boot içinde yeniden bağlanacak hiçbir şey yoktur.

Her süreç konak başına en fazla 8 boşta bağlantıyı her biri 2 saniye boyunca tutar. Bunu değiştirmek için kendi OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) nesnenizi adapter: olarak geçirin.

Test etme

Testlerinizde istemciye bir adaptör verin; hiçbir şey makineden çıkmaz. setup içindeki OpenEmail.init tüm süreç için paylaşılan istemcinin yerini alır, teardown içindeki OpenEmail.reset_client ise onu bırakır; böylece bir sonraki çağrı yeniden ortamdan kurulur.

test/jobs/send_invoice_job_test.rb
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

Kaydedilen her istek method, url, headers, body ve timeout içeren bir OpenEmail::HttpRequest nesnesidir ve body değeri gönderilecek olan JSON'dur; bu yüzden JSON.parse(request.body) iletinin kendisini gösterir. Kodunuzun bir reddi nasıl ele aldığını test etmek için API'nin hata zarfıyla birlikte bir hata durumu döndürün.

Paylaşılan istemci tüm sürece aittir; bu yüzden aynı anda iş parçacıklarında çalışan testler, test edilen koda yukarıdaki servis nesnesinin kabul ettiği gibi kendine ait bir istemci vermelidir.