mammoth 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "json"
4
4
  require "net/http"
5
+ require "openssl"
5
6
  require "socket"
7
+ require "time"
6
8
  require "uri"
7
9
 
8
10
  module Mammoth
@@ -11,27 +13,72 @@ module Mammoth
11
13
  # HTTP status range treated as successful webhook delivery.
12
14
  SUCCESS_RANGE = 200..299
13
15
 
14
- attr_reader :name, :url, :timeout_seconds
16
+ SIGNING_ALGORITHM = "hmac_sha256"
17
+ SIGNATURE_PREFIX = "sha256="
18
+
19
+ attr_reader :name, :url, :timeout_seconds, :headers, :signing
15
20
 
16
21
  # @param name [String] destination name
17
22
  # @param url [String] webhook endpoint URL
18
23
  # @param timeout_seconds [Integer] HTTP open/read timeout in seconds
19
- def initialize(name:, url:, timeout_seconds: 5)
24
+ # @param headers [Hash] static HTTP headers applied to every request
25
+ # @param signing [Hash, nil] HMAC signing configuration
26
+ def initialize(name:, url:, timeout_seconds: 5, headers: {}, signing: nil)
20
27
  @name = name
21
28
  @url = URI(url)
22
29
  @timeout_seconds = timeout_seconds
30
+ @headers = headers.transform_keys(&:to_s)
31
+ @signing = signing
23
32
  end
24
33
 
25
- # Build a sink from Mammoth configuration.
26
- #
27
- # @param config [Mammoth::Configuration] loaded configuration
28
- # @return [Mammoth::WebhookSink]
29
- def self.from_config(config)
30
- new(
31
- name: config.dig("webhook", "name"),
32
- url: config.dig("webhook", "url"),
33
- timeout_seconds: config.dig("webhook", "timeout_seconds")
34
- )
34
+ class << self
35
+ # Build a sink from Mammoth configuration.
36
+ #
37
+ # @param config [Mammoth::Configuration] loaded configuration
38
+ # @return [Mammoth::WebhookSink]
39
+ def from_config(config)
40
+ new(
41
+ name: config.dig("webhook", "name"),
42
+ url: config.dig("webhook", "url"),
43
+ timeout_seconds: config.dig("webhook", "timeout_seconds"),
44
+ headers: configured_headers(config),
45
+ signing: configured_signing(config)
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ def configured_headers(config)
52
+ static_headers = config.dig("webhook", "headers") || {}
53
+ env_headers = config.dig("webhook", "header_env") || {}
54
+
55
+ static_headers.merge(resolve_env_headers(env_headers))
56
+ end
57
+
58
+ def resolve_env_headers(env_headers)
59
+ env_headers.each_with_object(Hash.new) do |(header, env_name), resolved| # rubocop:disable Style/EmptyLiteral
60
+ resolved[header] = ENV.fetch(env_name) do
61
+ raise ConfigurationError, "webhook.header_env.#{header} references missing environment variable #{env_name}"
62
+ end
63
+ end
64
+ end
65
+
66
+ def configured_signing(config)
67
+ signing = config.dig("webhook", "signing")
68
+ return unless signing
69
+
70
+ algorithm = signing.fetch("algorithm", SIGNING_ALGORITHM)
71
+ raise ConfigurationError, "webhook.signing.algorithm must be #{SIGNING_ALGORITHM}" unless algorithm == SIGNING_ALGORITHM
72
+
73
+ secret_env = signing.fetch("secret_env")
74
+ {
75
+ secret: ENV.fetch(secret_env) do
76
+ raise ConfigurationError, "webhook.signing.secret_env references missing environment variable #{secret_env}"
77
+ end,
78
+ signature_header: signing.fetch("signature_header", "X-Mammoth-Signature"),
79
+ timestamp_header: signing.fetch("timestamp_header", "X-Mammoth-Timestamp")
80
+ }
81
+ end
35
82
  end
36
83
 
37
84
  # Deliver an event to the webhook endpoint.
@@ -40,7 +87,21 @@ module Mammoth
40
87
  # @return [Hash] delivery result
41
88
  # @raise [Mammoth::DeliveryError] when delivery fails
42
89
  def deliver(event)
43
- payload = EventSerializer.call(event)
90
+ deliver_payload(EventSerializer.call(event))
91
+ end
92
+
93
+ # Deliver a transaction envelope to the webhook endpoint.
94
+ #
95
+ # @param envelope [#events, #transaction_id] CDC transaction envelope
96
+ # @return [Hash] delivery result
97
+ # @raise [Mammoth::DeliveryError] when delivery fails
98
+ def deliver_transaction(envelope)
99
+ deliver_payload(TransactionEnvelopeSerializer.call(envelope))
100
+ end
101
+
102
+ private
103
+
104
+ def deliver_payload(payload)
44
105
  response = perform_request(payload)
45
106
  return delivery_result(payload, response) if SUCCESS_RANGE.cover?(response.code.to_i)
46
107
 
@@ -49,8 +110,6 @@ module Mammoth
49
110
  raise DeliveryError, "webhook #{name} delivery failed: #{e.message}"
50
111
  end
51
112
 
52
- private
53
-
54
113
  def perform_request(payload)
55
114
  Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https", open_timeout: timeout_seconds,
56
115
  read_timeout: timeout_seconds) do |http|
@@ -59,19 +118,36 @@ module Mammoth
59
118
  end
60
119
 
61
120
  def build_request(payload)
121
+ body = JSON.generate(payload)
62
122
  Net::HTTP::Post.new(url.request_uri).tap do |request|
63
123
  request["Content-Type"] = "application/json"
64
- request.body = JSON.generate(payload)
124
+ headers.each { |header, value| request[header] = value }
125
+ apply_signature_headers(request, body)
126
+ request.body = body
65
127
  end
66
128
  end
67
129
 
68
130
  def delivery_result(payload, response)
69
131
  {
70
132
  event_id: payload.fetch("event_id"),
133
+ payload_type: payload["type"] || "event",
71
134
  destination: name,
72
135
  status: "delivered",
73
136
  http_status: response.code.to_i
74
137
  }
75
138
  end
139
+
140
+ def apply_signature_headers(request, body)
141
+ return unless signing
142
+
143
+ timestamp = Time.now.utc.iso8601
144
+ request[signing.fetch(:timestamp_header)] = timestamp
145
+ request[signing.fetch(:signature_header)] = signed_body(timestamp, body)
146
+ end
147
+
148
+ def signed_body(timestamp, body)
149
+ digest = OpenSSL::HMAC.hexdigest("SHA256", signing.fetch(:secret), "#{timestamp}.#{body}")
150
+ "#{SIGNATURE_PREFIX}#{digest}"
151
+ end
76
152
  end
77
153
  end
data/lib/mammoth.rb CHANGED
@@ -7,9 +7,13 @@ require_relative "mammoth/status"
7
7
  require_relative "mammoth/sqlite_store"
8
8
  require_relative "mammoth/checkpoint_store"
9
9
  require_relative "mammoth/dead_letter_store"
10
+ require_relative "mammoth/delivered_envelope_store"
10
11
  require_relative "mammoth/event_serializer"
12
+ require_relative "mammoth/transaction_envelope_serializer"
11
13
  require_relative "mammoth/webhook_sink"
12
14
  require_relative "mammoth/delivery_worker"
15
+ require_relative "mammoth/delivery_processor"
16
+ require_relative "mammoth/concurrent_delivery_runtime"
13
17
  require_relative "mammoth/sources/postgres"
14
18
  require_relative "mammoth/cdc_source"
15
19
  require_relative "mammoth/replication_consumer"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mammoth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cdc-concurrent
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: cdc-core
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -134,8 +148,11 @@ files:
134
148
  - lib/mammoth/cdc_source.rb
135
149
  - lib/mammoth/checkpoint_store.rb
136
150
  - lib/mammoth/cli.rb
151
+ - lib/mammoth/concurrent_delivery_runtime.rb
137
152
  - lib/mammoth/configuration.rb
138
153
  - lib/mammoth/dead_letter_store.rb
154
+ - lib/mammoth/delivered_envelope_store.rb
155
+ - lib/mammoth/delivery_processor.rb
139
156
  - lib/mammoth/delivery_worker.rb
140
157
  - lib/mammoth/errors.rb
141
158
  - lib/mammoth/event_serializer.rb
@@ -144,6 +161,7 @@ files:
144
161
  - lib/mammoth/sql/__bootstrap__.sql
145
162
  - lib/mammoth/sqlite_store.rb
146
163
  - lib/mammoth/status.rb
164
+ - lib/mammoth/transaction_envelope_serializer.rb
147
165
  - lib/mammoth/version.rb
148
166
  - lib/mammoth/webhook_sink.rb
149
167
  homepage: https://kanutocd.github.io/mammoth/