emailfuse 0.1.4 → 0.3.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.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module EmailFuse
7
+ # The Webhooks module provides methods for managing webhooks via the EmailFuse API.
8
+ # Webhooks allow you to receive real-time notifications about email events.
9
+ #
10
+ # Default tolerance for timestamp validation (5 minutes)
11
+ WEBHOOK_TOLERANCE_SECONDS = 300
12
+ #
13
+ # @example Create a webhook
14
+ # EmailFuse::Webhooks.create(
15
+ # endpoint: "https://webhook.example.com/handler",
16
+ # events: ["email.sent", "email.delivered", "email.bounced"]
17
+ # )
18
+ #
19
+ # @example List all webhooks
20
+ # EmailFuse::Webhooks.list
21
+ #
22
+ # @example Retrieve a specific webhook
23
+ # EmailFuse::Webhooks.get("4dd369bc-aa82-4ff3-97de-514ae3000ee0")
24
+ #
25
+ # @example Update a webhook
26
+ # EmailFuse::Webhooks.update(
27
+ # webhook_id: "430eed87-632a-4ea6-90db-0aace67ec228",
28
+ # endpoint: "https://new-webhook.example.com/handler",
29
+ # events: ["email.sent", "email.delivered"],
30
+ # status: "enabled"
31
+ # )
32
+ #
33
+ # @example Delete a webhook
34
+ # EmailFuse::Webhooks.remove("4dd369bc-aa82-4ff3-97de-514ae3000ee0")
35
+ module Webhooks
36
+ class << self
37
+ # Create a new webhook to receive real-time notifications about email events
38
+ #
39
+ # @param params [Hash] The webhook parameters
40
+ # @option params [String] :endpoint The URL where webhook events will be sent (required)
41
+ # @option params [Array<String>] :events Array of event types to subscribe to (required)
42
+ #
43
+ # @return [Hash] The webhook object containing id, object type, and signing_secret
44
+ #
45
+ # @example
46
+ # EmailFuse::Webhooks.create(
47
+ # endpoint: "https://webhook.example.com/handler",
48
+ # events: ["email.sent", "email.delivered", "email.bounced"]
49
+ # )
50
+ def create(params = {})
51
+ path = "webhooks"
52
+ EmailFuse::Request.new(path, params, "post").perform
53
+ end
54
+
55
+ # Retrieve a list of webhooks for the authenticated user
56
+ #
57
+ # @param params [Hash] The pagination parameters
58
+ # @option params [Integer] :limit Number of webhooks to retrieve (max: 100, min: 1)
59
+ # @option params [String] :after The ID after which to retrieve more webhooks (for pagination)
60
+ # @option params [String] :before The ID before which to retrieve more webhooks (for pagination)
61
+ #
62
+ # @return [Hash] A paginated list of webhook objects
63
+ #
64
+ # @example
65
+ # EmailFuse::Webhooks.list
66
+ #
67
+ # @example With pagination
68
+ # EmailFuse::Webhooks.list(limit: 20, after: "4dd369bc-aa82-4ff3-97de-514ae3000ee0")
69
+ def list(params = {})
70
+ path = EmailFuse::PaginationHelper.build_paginated_path("webhooks", params)
71
+ EmailFuse::Request.new(path, {}, "get").perform
72
+ end
73
+
74
+ # Retrieve a single webhook for the authenticated user
75
+ #
76
+ # @param webhook_id [String] The webhook ID (required)
77
+ #
78
+ # @return [Hash] The webhook object with full details
79
+ #
80
+ # @example
81
+ # EmailFuse::Webhooks.get("4dd369bc-aa82-4ff3-97de-514ae3000ee0")
82
+ def get(webhook_id)
83
+ raise ArgumentError, "webhook_id is required" if webhook_id.nil? || webhook_id.to_s.empty?
84
+
85
+ path = "webhooks/#{webhook_id}"
86
+ EmailFuse::Request.new(path, {}, "get").perform
87
+ end
88
+
89
+ # Update an existing webhook configuration
90
+ #
91
+ # @param params [Hash] The webhook update parameters
92
+ # @option params [String] :webhook_id The webhook ID (required)
93
+ # @option params [String] :endpoint The URL where webhook events will be sent
94
+ # @option params [Array<String>] :events Array of event types to subscribe to
95
+ # @option params [String] :status The webhook status ("enabled" or "disabled")
96
+ #
97
+ # @return [Hash] The updated webhook object
98
+ #
99
+ # @example
100
+ # EmailFuse::Webhooks.update(
101
+ # webhook_id: "430eed87-632a-4ea6-90db-0aace67ec228",
102
+ # endpoint: "https://new-webhook.example.com/handler",
103
+ # events: ["email.sent", "email.delivered"],
104
+ # status: "enabled"
105
+ # )
106
+ def update(params = {})
107
+ params = params.dup # Don't mutate caller's hash
108
+ webhook_id = params.delete(:webhook_id)
109
+ raise ArgumentError, ":webhook_id is required" if webhook_id.nil? || webhook_id.to_s.empty?
110
+
111
+ path = "webhooks/#{webhook_id}"
112
+ EmailFuse::Request.new(path, params, "patch").perform
113
+ end
114
+
115
+ # Remove an existing webhook
116
+ #
117
+ # @param webhook_id [String] The webhook ID (required)
118
+ #
119
+ # @return [Hash] Confirmation object with id, object type, and deleted status
120
+ #
121
+ # @example
122
+ # EmailFuse::Webhooks.remove("4dd369bc-aa82-4ff3-97de-514ae3000ee0")
123
+ def remove(webhook_id)
124
+ raise ArgumentError, "webhook_id is required" if webhook_id.nil? || webhook_id.to_s.empty?
125
+
126
+ path = "webhooks/#{webhook_id}"
127
+ EmailFuse::Request.new(path, {}, "delete").perform
128
+ end
129
+
130
+ # Verify a webhook payload using HMAC-SHA256 signature validation
131
+ # This validates that the webhook request came from EmailFuse and hasn't been tampered with
132
+ #
133
+ # @param params [Hash] The webhook verification parameters
134
+ # @option params [String] :payload The raw webhook payload body (required)
135
+ # @option params [Hash] :headers The webhook headers containing svix-id, svix-timestamp,
136
+ # and svix-signature (required)
137
+ # @option params [String] :webhook_secret The signing secret from webhook creation (required)
138
+ #
139
+ # @return [Boolean] true if verification succeeds
140
+ # @raise [StandardError] If verification fails or required parameters are missing
141
+ #
142
+ # @example
143
+ # EmailFuse::Webhooks.verify(
144
+ # payload: request.body.read,
145
+ # headers: {
146
+ # svix_id: "id_1234567890abcdefghijklmnopqrstuvwxyz",
147
+ # svix_timestamp: "1616161616",
148
+ # svix_signature: "v1,signature_here"
149
+ # },
150
+ # webhook_secret: "whsec_1234567890abcdez"
151
+ # )
152
+ def verify(params = {})
153
+ payload = params[:payload]
154
+ headers = params[:headers] || {}
155
+ webhook_secret = params[:webhook_secret]
156
+
157
+ validate_required_params(payload, headers, webhook_secret)
158
+ validate_timestamp(headers[:svix_timestamp])
159
+
160
+ signed_content = "#{headers[:svix_id]}.#{headers[:svix_timestamp]}.#{payload}"
161
+ decoded_secret = decode_secret(webhook_secret)
162
+ expected_signature = generate_signature(decoded_secret, signed_content)
163
+
164
+ verify_signature(headers[:svix_signature], expected_signature)
165
+ end
166
+
167
+ private
168
+
169
+ # Validate required parameters
170
+ def validate_required_params(payload, headers, webhook_secret)
171
+ validate_payload(payload)
172
+ validate_webhook_secret(webhook_secret)
173
+ validate_headers(headers)
174
+ end
175
+
176
+ # Validate payload is present
177
+ def validate_payload(payload)
178
+ raise "payload cannot be empty" if payload.nil? || payload.empty?
179
+ end
180
+
181
+ # Validate webhook secret is present
182
+ def validate_webhook_secret(webhook_secret)
183
+ raise "webhook_secret cannot be empty" if webhook_secret.nil? || webhook_secret.empty?
184
+ end
185
+
186
+ # Validate required headers are present
187
+ def validate_headers(headers)
188
+ raise "svix-id header is required" if headers[:svix_id].nil? || headers[:svix_id].empty?
189
+ raise "svix-timestamp header is required" if headers[:svix_timestamp].nil? || headers[:svix_timestamp].empty?
190
+ raise "svix-signature header is required" if headers[:svix_signature].nil? || headers[:svix_signature].empty?
191
+ end
192
+
193
+ # Validate timestamp to prevent replay attacks
194
+ def validate_timestamp(timestamp_header)
195
+ timestamp = timestamp_header.to_i
196
+ now = Time.now.to_i
197
+ diff = now - timestamp
198
+
199
+ return unless diff > WEBHOOK_TOLERANCE_SECONDS || diff < -WEBHOOK_TOLERANCE_SECONDS
200
+
201
+ raise "Timestamp outside tolerance window: difference of #{diff} seconds"
202
+ end
203
+
204
+ # Decode the signing secret (strip whsec_ prefix and base64 decode)
205
+ def decode_secret(webhook_secret)
206
+ secret = webhook_secret.sub(/^whsec_/, "")
207
+ Base64.strict_decode64(secret)
208
+ rescue ArgumentError => e
209
+ raise "Failed to decode webhook secret: #{e.message}"
210
+ end
211
+
212
+ # Verify signature using constant-time comparison
213
+ def verify_signature(signature_header, expected_signature)
214
+ signatures = signature_header.split(" ")
215
+ signatures.each do |sig|
216
+ parts = sig.split(",", 2)
217
+ next if parts.length != 2
218
+
219
+ received_signature = parts[1]
220
+ return true if secure_compare(expected_signature, received_signature)
221
+ end
222
+
223
+ raise "No matching signature found"
224
+ end
225
+
226
+ # Generate HMAC-SHA256 signature and return it as base64
227
+ def generate_signature(secret, content)
228
+ digest = OpenSSL::HMAC.digest("sha256", secret, content)
229
+ Base64.strict_encode64(digest)
230
+ end
231
+
232
+ # Constant-time string comparison to prevent timing attacks
233
+ #
234
+ # Note: We implement this manually for Ruby 2.7 compatibility.
235
+ # Ruby 3.0+ could use OpenSSL.fixed_length_secure_compare instead.
236
+ def secure_compare(str_a, str_b)
237
+ return false if str_a.nil? || str_b.nil? || str_a.bytesize != str_b.bytesize
238
+
239
+ bytes_a = str_a.unpack("C*")
240
+ result = 0
241
+ str_b.each_byte.with_index { |byte_b, i| result |= byte_b ^ bytes_a[i] }
242
+ result.zero?
243
+ end
244
+ end
245
+ end
246
+ end
data/lib/email_fuse.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Version
4
+ require "email_fuse/version"
5
+
6
+ # Utils
7
+ require "httparty"
8
+ require "json"
9
+ require "cgi"
10
+ require "email_fuse/errors"
11
+ require "email_fuse/response"
12
+ require "email_fuse/client"
13
+ require "email_fuse/request"
14
+ require "email_fuse/pagination_helper"
15
+
16
+ # API Operations
17
+ require "email_fuse/batch"
18
+ require "email_fuse/emails"
19
+ require "email_fuse/emails/receiving"
20
+ require "email_fuse/emails/attachments"
21
+ require "email_fuse/emails/receiving/attachments"
22
+ require "email_fuse/webhooks"
23
+
24
+ # Rails
25
+ require "email_fuse/railtie" if defined?(Rails) && defined?(ActionMailer)
26
+
27
+ # Main EmailFuse module
28
+ module EmailFuse
29
+ DEFAULT_TIMEOUT = 30
30
+
31
+ class << self
32
+ attr_accessor :api_key, :timeout
33
+ attr_writer :base_url
34
+
35
+ def configure
36
+ yield self if block_given?
37
+ true
38
+ end
39
+ alias config configure
40
+
41
+ def base_url
42
+ @base_url ||= "https://api.emailfuse.net"
43
+ end
44
+ end
45
+ end
data/lib/emailfuse.rb CHANGED
@@ -1,32 +1,3 @@
1
- require "action_mailer"
1
+ # frozen_string_literal: true
2
2
 
3
- require "faraday"
4
- require "faraday/multipart"
5
-
6
- require "emailfuse/version"
7
-
8
- module Emailfuse
9
- autoload :Configuration, "emailfuse/configuration"
10
- autoload :Client, "emailfuse/client"
11
- autoload :Collection, "emailfuse/collection"
12
- autoload :Error, "emailfuse/error"
13
- autoload :Object, "emailfuse/object"
14
-
15
- autoload :Deliverer, "emailfuse/deliverer"
16
-
17
- class << self
18
- attr_writer :config
19
- end
20
-
21
- def self.configure
22
- yield(config) if block_given?
23
- end
24
-
25
- def self.config
26
- @config ||= Configuration.new
27
- end
28
-
29
- autoload :Email, "emailfuse/models/email"
30
- end
31
-
32
- require "emailfuse/railtie" if defined?(Rails::Railtie)
3
+ require "email_fuse"
metadata CHANGED
@@ -1,81 +1,68 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: emailfuse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dean Perry
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-09-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: actionmailer
13
+ name: httparty
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '0'
18
+ version: 0.21.0
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '0'
25
+ version: 0.21.0
27
26
  - !ruby/object:Gem::Dependency
28
- name: faraday
27
+ name: rails
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '2.0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '2.0'
41
- - !ruby/object:Gem::Dependency
42
- name: faraday-multipart
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
30
+ - - ">="
46
31
  - !ruby/object:Gem::Version
47
- version: '1.0'
48
- type: :runtime
32
+ version: '7.2'
33
+ type: :development
49
34
  prerelease: false
50
35
  version_requirements: !ruby/object:Gem::Requirement
51
36
  requirements:
52
- - - "~>"
37
+ - - ">="
53
38
  - !ruby/object:Gem::Version
54
- version: '1.0'
55
- description:
56
- email:
57
- - dean@voupe.com
39
+ version: '7.2'
40
+ email: dean@voupe.com
58
41
  executables: []
59
42
  extensions: []
60
43
  extra_rdoc_files: []
61
44
  files:
45
+ - CHANGELOG.md
62
46
  - README.md
63
- - Rakefile
47
+ - lib/email_fuse.rb
48
+ - lib/email_fuse/batch.rb
49
+ - lib/email_fuse/client.rb
50
+ - lib/email_fuse/emails.rb
51
+ - lib/email_fuse/emails/attachments.rb
52
+ - lib/email_fuse/emails/receiving.rb
53
+ - lib/email_fuse/emails/receiving/attachments.rb
54
+ - lib/email_fuse/errors.rb
55
+ - lib/email_fuse/mailer.rb
56
+ - lib/email_fuse/pagination_helper.rb
57
+ - lib/email_fuse/railtie.rb
58
+ - lib/email_fuse/request.rb
59
+ - lib/email_fuse/response.rb
60
+ - lib/email_fuse/version.rb
61
+ - lib/email_fuse/webhooks.rb
64
62
  - lib/emailfuse.rb
65
- - lib/emailfuse/client.rb
66
- - lib/emailfuse/collection.rb
67
- - lib/emailfuse/configuration.rb
68
- - lib/emailfuse/deliverer.rb
69
- - lib/emailfuse/error.rb
70
- - lib/emailfuse/models/email.rb
71
- - lib/emailfuse/object.rb
72
- - lib/emailfuse/railtie.rb
73
- - lib/emailfuse/version.rb
74
- homepage:
75
63
  licenses:
76
64
  - MIT
77
65
  metadata: {}
78
- post_install_message:
79
66
  rdoc_options: []
80
67
  require_paths:
81
68
  - lib
@@ -83,15 +70,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
83
70
  requirements:
84
71
  - - ">="
85
72
  - !ruby/object:Gem::Version
86
- version: '0'
73
+ version: '3.3'
87
74
  required_rubygems_version: !ruby/object:Gem::Requirement
88
75
  requirements:
89
76
  - - ">="
90
77
  - !ruby/object:Gem::Version
91
78
  version: '0'
92
79
  requirements: []
93
- rubygems_version: 3.5.11
94
- signing_key:
80
+ rubygems_version: 4.0.3
95
81
  specification_version: 4
96
- summary: Ruby library for the Emailfuse API including an Rails Action Mailer adapter
82
+ summary: The Ruby and Rails SDK for EmailFuse
97
83
  test_files: []
data/Rakefile DELETED
@@ -1,10 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require "rake/testtask"
3
-
4
- Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
7
- t.test_files = FileList["test/**/*_test.rb"]
8
- end
9
-
10
- task default: :test
@@ -1,69 +0,0 @@
1
- module Emailfuse
2
- class Client
3
- class << self
4
- def url
5
- Emailfuse.config.host || "https://app.emailfuse.net"
6
- end
7
-
8
- def connection
9
- @connection ||= Faraday.new("#{url}/api/v1") do |conn|
10
- conn.request :authorization, :Bearer, Emailfuse.config.token
11
-
12
- conn.headers = {
13
- "User-Agent" => "emailfuse/v#{VERSION} (github.com/voupe/emailfuse-gem)"
14
- }
15
-
16
- conn.request :multipart
17
- conn.request :json
18
-
19
- conn.response :json
20
- end
21
- end
22
-
23
- def get_request(url, params: {}, headers: {})
24
- handle_response connection.get(url, params, headers)
25
- end
26
-
27
- def post_request(url, body: {}, headers: {})
28
- handle_response connection.post(url, body, headers)
29
- end
30
-
31
- def patch_request(url, body:, headers: {})
32
- handle_response connection.patch(url, body, headers)
33
- end
34
-
35
- def delete_request(url, headers: {})
36
- handle_response connection.delete(url, headers)
37
- end
38
-
39
- def handle_response(response)
40
- case response.status
41
- when 400
42
- raise Error, "Error 400: Your request was malformed."
43
- when 401
44
- raise Error, "Error 401: You did not supply valid authentication credentials."
45
- when 403
46
- raise Error, "Error 403: You are not allowed to perform that action."
47
- when 404
48
- raise Error, "Error 404: No results were found for your request."
49
- when 409
50
- raise Error, "Error 409: Your request was a conflict."
51
- when 429
52
- raise Error, "Error 429: Your request exceeded the API rate limit."
53
- when 422
54
- raise Error, "Error 422: Unprocessable Entity."
55
- when 500
56
- raise Error, "Error 500: We were unable to perform the request due to server-side problems."
57
- when 503
58
- raise Error, "Error 503: You have been rate limited for sending more than 20 requests per second."
59
- when 501
60
- raise Error, "Error 501: This resource has not been implemented."
61
- when 204
62
- return true
63
- end
64
-
65
- response
66
- end
67
- end
68
- end
69
- end
@@ -1,27 +0,0 @@
1
- module Emailfuse
2
- class Collection
3
- attr_reader :data, :total
4
-
5
- def self.from_response(response, type:, key: nil)
6
- body = response.body
7
-
8
- if key.is_a?(String)
9
- data = body["data"][key].map { |attrs| type.new(attrs) }
10
- total = body["data"]["total"]
11
- else
12
- data = body["data"].map { |attrs| type.new(attrs) }
13
- total = body["data"].count
14
- end
15
-
16
- new(
17
- data: data,
18
- total: total
19
- )
20
- end
21
-
22
- def initialize(data:, total:)
23
- @data = data
24
- @total = total
25
- end
26
- end
27
- end
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Emailfuse
4
- class Configuration
5
- attr_accessor :host, :token
6
-
7
- def initialize
8
- end
9
- end
10
- end
@@ -1,72 +0,0 @@
1
- module Emailfuse
2
- class Deliverer
3
- class Attachment < StringIO
4
- attr_reader :original_filename, :content_type, :path
5
-
6
- def initialize (attachment, *rest)
7
- @path = ""
8
- @original_filename = attachment.filename
9
- @content_type = attachment.content_type.split(";")[0]
10
- super attachment.body.decoded
11
- end
12
- end
13
-
14
- attr_accessor :settings
15
-
16
- def initialize(settings)
17
- self.settings = settings
18
- end
19
-
20
- def api_token
21
- self.settings[:token] || Emailfuse.config.token
22
- end
23
-
24
- def deliver!(rails_message)
25
- attributes = {
26
- from: rails_message[:from],
27
- to: rails_message[:to].formatted,
28
- subject: rails_message.subject,
29
- html: extract_html(rails_message),
30
- text: extract_text(rails_message)
31
- }
32
-
33
- [ :reply_to, :cc ].each do |key|
34
- attributes[key] = rails_message[key].formatted if rails_message[key]
35
- end
36
-
37
- unless rails_message.attachments.empty?
38
- attributes[:attachments] = []
39
- rails_message.attachments.each do |attachment|
40
- attributes[:attachments] << Attachment.new(attachment, encoding: "ascii-8bit")
41
- end
42
- end
43
-
44
- Email.create(**attributes)
45
- end
46
-
47
- private
48
-
49
- # @see http://stackoverflow.com/questions/4868205/rails-mail-getting-the-body-as-plain-text
50
- def extract_html(rails_message)
51
- if rails_message.html_part
52
- rails_message.html_part.body.decoded
53
- else
54
- rails_message.content_type =~ /text\/html/ ? rails_message.body.decoded : nil
55
- end
56
- end
57
-
58
- def extract_text(rails_message)
59
- if rails_message.multipart?
60
- rails_message.text_part ? rails_message.text_part.body.decoded : nil
61
- else
62
- rails_message.content_type =~ /text\/plain/ ? rails_message.body.decoded : nil
63
- end
64
- end
65
-
66
- def email_fuse_client
67
- @email_fuse_client ||= Client.new(api_token, host)
68
- end
69
- end
70
- end
71
-
72
- ActionMailer::Base.add_delivery_method :email_fuse, Emailfuse::Deliverer
@@ -1,4 +0,0 @@
1
- module Emailfuse
2
- class Error < StandardError
3
- end
4
- end
@@ -1,21 +0,0 @@
1
- module Emailfuse
2
- class Email < Object
3
- class << self
4
- def create(to:, from:, subject:, html: nil, text: nil, **attributes)
5
- raise ArgumentError, "You must provide either html or text" if html.nil? && text.nil?
6
-
7
- attrs = attributes.merge!(
8
- to: to,
9
- from: from,
10
- subject: subject,
11
- html: html,
12
- text: text
13
- )
14
-
15
- response = Client.post_request("emails", body: attrs)
16
-
17
- Email.new(response.body) if response.success?
18
- end
19
- end
20
- end
21
- end
@@ -1,19 +0,0 @@
1
- require "ostruct"
2
-
3
- module Emailfuse
4
- class Object < OpenStruct
5
- def initialize(attributes)
6
- super to_ostruct(attributes)
7
- end
8
-
9
- def to_ostruct(obj)
10
- if obj.is_a?(Hash)
11
- OpenStruct.new(obj.map { |key, val| [ key, to_ostruct(val) ] }.to_h)
12
- elsif obj.is_a?(Array)
13
- obj.map { |o| to_ostruct(o) }
14
- else # Assumed to be a primitive value
15
- obj
16
- end
17
- end
18
- end
19
- end