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.
api_key = Rails.application.credentials.dig(:openemail, :api_key) OpenEmail.init(api_key:, timeout: 15) if api_keyAnahtarı 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.
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 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.
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}" ) endendBir 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.
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]") 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.
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"endBir 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.
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 endendOlayı 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.
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"]] endendParametreler 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.
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.
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 endendKaydedilen 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.