sm_sms_campaign_webhook 0.1.1 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +340 -7
  4. data/app/controllers/sm_sms_campaign_webhook/application_controller.rb +22 -0
  5. data/app/controllers/sm_sms_campaign_webhook/webhook_controller.rb +25 -0
  6. data/app/exceptions/sm_sms_campaign_webhook/error.rb +6 -0
  7. data/app/exceptions/sm_sms_campaign_webhook/invalid_payload.rb +6 -0
  8. data/app/exceptions/sm_sms_campaign_webhook/invalid_payload_value.rb +6 -0
  9. data/app/exceptions/sm_sms_campaign_webhook/missing_config_error.rb +6 -0
  10. data/app/exceptions/sm_sms_campaign_webhook/payload_dispatch_error.rb +6 -0
  11. data/app/jobs/sm_sms_campaign_webhook/application_job.rb +7 -0
  12. data/app/jobs/sm_sms_campaign_webhook/dispatch_payload_job.rb +14 -0
  13. data/app/jobs/sm_sms_campaign_webhook/process_campaign_engagement_job.rb +14 -0
  14. data/app/models/sm_sms_campaign_webhook/campaign_engagement/answer.rb +76 -0
  15. data/app/models/sm_sms_campaign_webhook/campaign_engagement.rb +216 -0
  16. data/app/operations/sm_sms_campaign_webhook/campaign_engagement_operation.rb +36 -0
  17. data/app/operations/sm_sms_campaign_webhook/payload_operation.rb +26 -0
  18. data/app/processors/sm_sms_campaign_webhook/default_processor.rb +11 -0
  19. data/app/processors/sm_sms_campaign_webhook/processable.rb +21 -0
  20. data/config/routes.rb +7 -0
  21. data/lib/generators/sm_sms_campaign_webhook/install/USAGE +12 -0
  22. data/lib/generators/sm_sms_campaign_webhook/install/install_generator.rb +35 -0
  23. data/lib/generators/sm_sms_campaign_webhook/install/templates/README +25 -0
  24. data/lib/generators/sm_sms_campaign_webhook/install/templates/sm_sms_campaign_webhook.rb +9 -0
  25. data/lib/generators/sm_sms_campaign_webhook/install/templates/sms_payload_processor.rb.erb +13 -0
  26. data/lib/sm_sms_campaign_webhook/engine.rb +11 -0
  27. data/lib/sm_sms_campaign_webhook/version.rb +1 -1
  28. data/lib/sm_sms_campaign_webhook.rb +25 -2
  29. data/sm_sms_campaign_webhook.gemspec +35 -21
  30. metadata +87 -22
  31. data/.gitignore +0 -16
  32. data/.rspec +0 -3
  33. data/.travis.yml +0 -18
  34. data/Gemfile +0 -4
  35. data/Rakefile +0 -6
  36. data/bin/console +0 -14
  37. data/bin/setup +0 -8
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module SmSmsCampaignWebhook
6
+ # Data model for campaign engagement event from SMS campaign.
7
+ class CampaignEngagement
8
+ attr_reader :payload
9
+
10
+ # @param payload [Hash] Campaign engagement event payload
11
+ def initialize(payload:)
12
+ @payload = payload.deep_dup.freeze
13
+ end
14
+
15
+ # @return [String] Campaign engagement event UUID
16
+ # @raise [InvalidPayload] when uuid missing from payload
17
+ def event_uuid
18
+ @event_uuid ||= String(
19
+ payload.fetch("uuid") {
20
+ raise_invalid_payload_for("uuid")
21
+ }.freeze
22
+ )
23
+ end
24
+
25
+ # @return [String] Campaign engagement event type
26
+ # @raise [InvalidPayload] when type missing from payload
27
+ def event_type
28
+ @event_type ||= String(
29
+ payload.fetch("type") {
30
+ raise_invalid_payload_for("type")
31
+ }.freeze
32
+ )
33
+ end
34
+
35
+ # @return [DateTime] Campaign engagement event timestamp
36
+ # @raise [InvalidPayload] when created_at missing from payload
37
+ # @raise [InvalidPayloadValue] when created_at not datetime
38
+ def event_created_at
39
+ @event_created_at ||= begin
40
+ raw_created_at = payload.fetch("created_at") {
41
+ raise_invalid_payload_for("created_at")
42
+ }
43
+ DateTime.parse(raw_created_at).freeze
44
+ end
45
+ rescue ArgumentError
46
+ raise InvalidPayloadValue,
47
+ "created_at has invalid datetime value #{payload.inspect}"
48
+ end
49
+
50
+ # @return [Integer] ID of the engaged campaign
51
+ # @raise [InvalidPayload] when campaign id missing from payload
52
+ # @raise [InvalidPayloadValue] when campaign id not numeric
53
+ def campaign_id
54
+ @campaign_id ||= Integer(
55
+ campaign_hash.fetch("id") {
56
+ raise_invalid_payload_for("campaign id")
57
+ }
58
+ )
59
+ rescue ArgumentError
60
+ raise InvalidPayloadValue,
61
+ "campaign id has invalid integer value #{payload.inspect}"
62
+ end
63
+
64
+ # @return [String] Keyword of the engaged campaign
65
+ # @raise [InvalidPayload] when campaign keyword missing from payload
66
+ def campaign_keyword
67
+ @campaign_keyword ||= String(
68
+ campaign_hash.fetch("keyword") {
69
+ raise_invalid_payload_for("campaign keyword")
70
+ }.freeze
71
+ )
72
+ end
73
+
74
+ # @return [Integer] ID of the engaging phone
75
+ # @raise [InvalidPayload] when phone id missing from payload
76
+ # @raise [InvalidPayloadValue] when phone id not numeric
77
+ def phone_id
78
+ @phone_id ||= Integer(
79
+ phone_hash.fetch("id") {
80
+ raise_invalid_payload_for("phone id")
81
+ }
82
+ )
83
+ rescue ArgumentError
84
+ raise InvalidPayloadValue,
85
+ "phone id has invalid integer value #{payload.inspect}"
86
+ end
87
+
88
+ # @return [String] Phone number engaging the campaign
89
+ # @raise [InvalidPayload] when phone number missing from payload
90
+ def phone_number
91
+ @phone_number ||= String(
92
+ phone_hash.fetch("number") {
93
+ raise_invalid_payload_for("phone number")
94
+ }.freeze
95
+ )
96
+ end
97
+
98
+ # @return [Integer] ID of campaign engagement state record
99
+ # @raise [InvalidPayload] when phone_campaign_state id missing from payload
100
+ # @raise [InvalidPayloadValue] when phone_campaign_state id not numeric
101
+ def phone_campaign_state_id
102
+ @phone_campaign_state_id ||= Integer(
103
+ phone_campaign_state_hash.fetch("id") {
104
+ raise_invalid_payload_for("phone_campaign_state id")
105
+ }
106
+ )
107
+ rescue ArgumentError
108
+ raise InvalidPayloadValue,
109
+ "phone_campaign_state id has invalid integer value #{payload.inspect}"
110
+ end
111
+
112
+ # @return [TrueClass,FalseClass] Has campaign engagement completed?
113
+ # @raise [InvalidPayload] when phone_campaign_state completed missing from payload
114
+ # @raise [InvalidPayloadValue] when phone_campaign_state completed is not boolean
115
+ def phone_campaign_state_completed?
116
+ # Has the boolean value already been assigned?
117
+ unless @phone_campaign_state_completed.nil?
118
+ return @phone_campaign_state_completed
119
+ end
120
+
121
+ # Extract the value and memoize it.
122
+ @phone_campaign_state_completed = begin
123
+ completed = phone_campaign_state_hash
124
+ .fetch("completed") {
125
+ raise_invalid_payload_for("phone_campaign_state completed")
126
+ }
127
+
128
+ # Is this a boolean value?
129
+ if [true, false].none?(completed)
130
+ raise InvalidPayloadValue,
131
+ "phone_campaign_state completed has invalid boolean value #{payload.inspect}"
132
+ end
133
+
134
+ completed
135
+ end
136
+ end
137
+
138
+ # @return [DateTime,NilClass] Timestamp of campaign engagement completion if completed
139
+ # @raise [InvalidPayload] when phone_campaign_state completed_at missing from payload
140
+ # @raise [InvalidPayloadValue] when phone_campaign_state completed_at not datetime
141
+ def phone_campaign_state_completed_at
142
+ @phone_campaign_state_completed_at ||= begin
143
+ raw_completed_at = phone_campaign_state_hash.fetch("completed_at") {
144
+ raise_invalid_payload_for("phone_campaign_state completed_at")
145
+ }
146
+ DateTime.parse(raw_completed_at).freeze if raw_completed_at
147
+ end
148
+ rescue ArgumentError
149
+ raise InvalidPayloadValue,
150
+ "phone_campaign_state completed_at has invalid datetime value #{payload.inspect}"
151
+ end
152
+
153
+ # @return [Array<Answer>] Modeled campaign engagement answers
154
+ # @raise [InvalidPayload] when phone_campaign_state answers missing from payload
155
+ # @raise [InvalidPayloadValue] when phone_campaign_state answers not hash
156
+ def phone_campaign_state_answers
157
+ @phone_campaign_state_answers ||= begin
158
+ # Extract answers data from payload.
159
+ data = phone_campaign_state_hash.fetch("answers") {
160
+ raise_invalid_payload_for("phone_campaign_state answers")
161
+ }
162
+
163
+ # Is this hash data?
164
+ unless data.is_a?(Hash)
165
+ raise InvalidPayloadValue,
166
+ "phone_campaign_state answers has invalid hash value #{payload.inspect}"
167
+ end
168
+
169
+ # Cast answers data.
170
+ Answer.cast(data: data).freeze
171
+ end
172
+ end
173
+
174
+ # @return [TrueClass,FalseClass] Are any campaign engagement answers present?
175
+ def phone_campaign_state_answers?
176
+ !phone_campaign_state_answers.empty?
177
+ end
178
+
179
+ # @param field [String] Answer data to find
180
+ # @return [Answer,NilClass] Modeled answer for field when found
181
+ def answer_for(field:)
182
+ phone_campaign_state_answers.detect do |answer|
183
+ answer.field == field
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ # @param attr [String] Expected attribute missing from payload
190
+ def raise_invalid_payload_for(attr)
191
+ raise InvalidPayload, "#{attr} missing from payload #{payload.inspect}"
192
+ end
193
+
194
+ # @return [Hash] Data from campaign engagement payload
195
+ def payload_data
196
+ @payload_data ||= payload.fetch("data", {}).freeze
197
+ end
198
+
199
+ # @return [Hash] Campaign hash from payload data
200
+ def campaign_hash
201
+ @campaign_hash ||= payload_data.fetch("campaign", {}).freeze
202
+ end
203
+
204
+ # @return [Hash] Phone hash from payload data
205
+ def phone_hash
206
+ @phone_hash ||= payload_data.fetch("phone", {}).freeze
207
+ end
208
+
209
+ # @return [Hash] Campaign engagement state hash from payload data
210
+ def phone_campaign_state_hash
211
+ @phone_campaign_state_hash ||= payload_data
212
+ .fetch("phone_campaign_state", {})
213
+ .freeze
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmSmsCampaignWebhook
4
+ # Handles campaign engagement payload data modeling and processing.
5
+ class CampaignEngagementOperation
6
+ # Models the campaign engagement payload and hands of processing
7
+ # of the data to the processor.
8
+ #
9
+ # @param payload [Hash] Deserialized SMS campaign engagement payload
10
+ # @raise [PayloadDispatchError] when not campaign engagement payload
11
+ # @see .processor
12
+ def self.process(payload:)
13
+ logger.debug "#{name} - Processing campaign engagement payload: #{payload.inspect}"
14
+
15
+ if payload.fetch("type") != "campaign.engagement"
16
+ raise PayloadDispatchError,
17
+ "dispatched payload different from campaign.engagement #{payload.inspect}"
18
+ end
19
+
20
+ campaign_engagement = CampaignEngagement.new(payload: payload)
21
+ processor.process_campaign_engagement(campaign_engagement)
22
+ end
23
+
24
+ # @return [Processable] Configured SMS campaign payload processor
25
+ # @see SmSmsCampaignWebhook.processor
26
+ def self.processor
27
+ @processor ||= SmSmsCampaignWebhook.processor
28
+ end
29
+
30
+ # @return [ActiveSupport::Logger] Abstraction of app logger
31
+ def self.logger
32
+ @logger ||= Rails.logger
33
+ end
34
+ private_class_method :logger
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmSmsCampaignWebhook
4
+ # Handles payload data modeling and data processing.
5
+ class PayloadOperation
6
+ # @param payload [Hash] Deserialized payload from SMS campaign service
7
+ # @see ProcessCampaignEngagementJob#perform
8
+ def self.dispatch(payload:)
9
+ logger.debug "#{name} - Dispatching payload: #{payload.inspect}"
10
+
11
+ case payload.fetch("type", "unknown")
12
+ when "campaign.engagement"
13
+ ProcessCampaignEngagementJob.perform_later(payload)
14
+ else
15
+ # NOOP - Unsupported event type.
16
+ logger.warn "#{name} - Unsupported event type"
17
+ end
18
+ end
19
+
20
+ # @return [ActiveSupport::Logger] Abstraction of app logger
21
+ def self.logger
22
+ @logger ||= Rails.logger
23
+ end
24
+ private_class_method :logger
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency "sm_sms_campaign_webhook/processable"
4
+
5
+ module SmSmsCampaignWebhook
6
+ # Default processor with NOOP implementations.
7
+ # @see Processable
8
+ class DefaultProcessor
9
+ include Processable
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmSmsCampaignWebhook
4
+ # Define behavior that SMS campaign payload processors must implement.
5
+ module Processable
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ # Campaign engagement operation sends modeled campaign engagement payload
10
+ # to this method for applying business logic. Implementors should define
11
+ # the app behavior to properly handle this data.
12
+ #
13
+ # @param campaign_engagement [CampaignEngagement] modeled payload
14
+ # @raise [NotImplementedError] requiring implementing class to define behavior
15
+ def process_campaign_engagement(campaign_engagement)
16
+ raise NotImplementedError,
17
+ "#{self.class} must implement .process_campaign_engagement receiving campaign_engagement param"
18
+ end
19
+ end
20
+ end
21
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ SmSmsCampaignWebhook::Engine.routes.draw do
4
+ scope "/api" do
5
+ resources :webhook, only: [:create]
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ Description:
2
+ Configures the app to handle inbound requests from the SMS campaign service.
3
+
4
+ Example:
5
+ rails generate sm_sms_campaign_webhook:install
6
+
7
+ This will create:
8
+ app/processors/sms_payload_processor.rb
9
+ config/initializers/sm_sms_campaign_webhook.rb
10
+
11
+ This will mount the engine in:
12
+ config/routes.rb
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module SmSmsCampaignWebhook
6
+ # Namespace for generators provided by the gem.
7
+ module Generators
8
+ # Installs files to prep an app for SMS campaign webhook.
9
+ class InstallGenerator < Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ # Copy initializer template to config/initializers
13
+ def copy_initializer
14
+ template "sm_sms_campaign_webhook.rb", "config/initializers/sm_sms_campaign_webhook.rb"
15
+ end
16
+
17
+ # Copy processor template to app/processors
18
+ def copy_processor
19
+ template "sms_payload_processor.rb.erb", "app/processors/sms_payload_processor.rb"
20
+ end
21
+
22
+ # Mount engine to path in config/routes.rb
23
+ def add_mount_path
24
+ insert_into_file "config/routes.rb",
25
+ " mount SmSmsCampaignWebhook::Engine => \"/sms_campaign\"\n",
26
+ after: "Rails.application.routes.draw do\n"
27
+ end
28
+
29
+ # Dump the README for the app developer
30
+ def show_readme
31
+ readme "README" if behavior == :invoke
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ ================================================================================
2
+
3
+ Some setup you must do manually if you haven't yet:
4
+
5
+ 1. Set the ENV value SM_SMS_CAMPAIGN_WEBHOOK_AUTH_TOKEN with a Rails secret:
6
+
7
+ $ bundle exec rails secret
8
+
9
+ 2. Prep the app for ActiveJob using Sidekiq. In config/application.rb:
10
+
11
+ config.active_job.queue_adapter = :sidekiq
12
+
13
+ In Procfile or appropriate app launch config:
14
+
15
+ worker: bundle exec sidekiq --config config/sidekiq.yml
16
+
17
+ 3. Implement SmsPayloadProcessor behavior. Remember, the generated processor
18
+ provides the skeleton but will raise errors out of the box. Data handling
19
+ is app specific!
20
+
21
+ This will configure the app to be ready to accept SMS campaign service payloads.
22
+
23
+ Happy hacking!
24
+
25
+ ================================================================================
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sm_sms_campaign_webhook"
4
+
5
+ SmSmsCampaignWebhook.config do |config|
6
+ # SMS campaign payload processor implementing SmSmsCampaignWebhook::Processable behavior.
7
+ # default: SmSmsCampaignWebhook::DefaultProcessor (raises errors for processing)
8
+ config.processor = SmsPayloadProcessor
9
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Processes SMS campaign service payloads.
4
+ # @see https://github.com/SouthernMade/sm_sms_campaign_webhook/blob/v<%= SmSmsCampaignWebhook::VERSION %>/app/processors/sm_sms_campaign_webhook/processable.rb SmSmsCampaignWebhook::Processable
5
+ class SmsPayloadProcessor
6
+ include SmSmsCampaignWebhook::Processable
7
+
8
+ # @param campaign_engagement [SmSmsCampaignWebhook::CampaignEngagement]
9
+ # @see https://github.com/SouthernMade/sm_sms_campaign_webhook/blob/v<%= SmSmsCampaignWebhook::VERSION %>/app/models/sm_sms_campaign_webhook/campaign_engagement.rb SmSmsCampaignWebhook::CampaignEngagement
10
+ # @todo Implement business logic to process campaign engagement.
11
+ # def self.process_campaign_engagement(campaign_engagement)
12
+ # end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module SmSmsCampaignWebhook
6
+ # General Rails engine configuration.
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace SmSmsCampaignWebhook
9
+ config.generators.api_only = true
10
+ end
11
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SmSmsCampaignWebhook
4
4
  # @return [String] gem version
5
- VERSION = "0.1.1"
5
+ VERSION = "2.1.0"
6
6
  end
@@ -1,9 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "sm_sms_campaign_webhook/engine"
3
4
  require "sm_sms_campaign_webhook/version"
4
5
 
5
6
  # Namespace for SMS campaign webhook.
6
7
  module SmSmsCampaignWebhook
7
- # General base error type for custom errors.
8
- class Error < StandardError; end
8
+ # @return [SmSmsCampaignWebhook] self for configuration purposes
9
+ def self.config(&block)
10
+ yield self if block
11
+ end
12
+
13
+ # @return [String] SMS campaign webhook auth token
14
+ # @raise [MissingConfigError] when ENV does not contain SM_SMS_CAMPAIGN_WEBHOOK_AUTH_TOKEN value
15
+ def self.auth_token
16
+ @auth_token ||= ENV.fetch("SM_SMS_CAMPAIGN_WEBHOOK_AUTH_TOKEN") {
17
+ raise MissingConfigError,
18
+ "ENV does not contain SM_SMS_CAMPAIGN_WEBHOOK_AUTH_TOKEN value"
19
+ }
20
+ end
21
+
22
+ # @return [Processable] SMS campaign payload processor used by operations
23
+ def self.processor
24
+ @processor ||= DefaultProcessor
25
+ end
26
+
27
+ # @param processor [Processable] Custom SMS campaign payload processor
28
+ # @see Processable
29
+ def self.processor=(processor)
30
+ @processor = processor
31
+ end
9
32
  end
@@ -1,34 +1,48 @@
1
+ # frozen_string_literal: true
2
+
1
3
  lib = File.expand_path("lib", __dir__)
2
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
5
  require "sm_sms_campaign_webhook/version"
4
6
 
5
7
  Gem::Specification.new do |spec|
6
- spec.name = "sm_sms_campaign_webhook"
7
- spec.version = SmSmsCampaignWebhook::VERSION
8
- spec.authors = ["Cameron Dykes", "Matt Mueller"]
9
- spec.email = ["cameron@southernmade.com", "matt@southernmade.com"]
10
-
11
- spec.summary = %q{Middleware providing webhook for Southern Made SMS Campaign Engagement.}
12
- spec.homepage = "https://github.com/SouthernMade/sm_sms_campaign_webhook"
13
- spec.license = "MIT"
14
-
15
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
8
+ spec.name = "sm_sms_campaign_webhook"
9
+ spec.version = SmSmsCampaignWebhook::VERSION
10
+ spec.license = "MIT"
11
+ spec.summary = "Middleware providing webhook for Southern Made SMS Campaign Engagement."
12
+ spec.description = "Middleware providing webhook for Southern Made SMS Campaign Engagement."
13
+ spec.homepage = "https://github.com/SouthernMade/sm_sms_campaign_webhook"
14
+ spec.authors = ["Cameron Dykes", "Matt Mueller"]
15
+ spec.email = ["cameron@southernmade.com", "matt@southernmade.com"]
16
16
 
17
- spec.metadata["homepage_uri"] = spec.homepage
18
- spec.metadata["source_code_uri"] = "https://github.com/SouthernMade/sm_sms_campaign_webhook"
19
- spec.metadata["changelog_uri"] = "https://github.com/SouthernMade/sm_sms_campaign_webhook/blob/develop/CHANGELOG.md"
17
+ spec.metadata = {
18
+ "allowed_push_host" => "https://rubygems.org",
19
+ "changelog_uri" => "#{spec.homepage}/blob/develop/CHANGELOG.md",
20
+ "documentation_uri" => "https://www.rubydoc.info/gems/sm_sms_campaign_webhook/#{spec.version}",
21
+ "homepage_uri" => spec.homepage,
22
+ "source_code_uri" => spec.homepage
23
+ }
20
24
 
21
25
  # Specify which files should be added to the gem when it is released.
22
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
24
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
25
- end
26
- spec.bindir = "exe"
27
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
+ spec.files = Dir[
27
+ "{app,config,lib}/**/*",
28
+ "CHANGELOG.md",
29
+ "LICENSE.txt",
30
+ "README.md",
31
+ "sm_sms_campaign_webhook.gemspec"
32
+ ]
28
33
  spec.require_paths = ["lib"]
29
34
 
35
+ # Required version of Ruby guided by Rails.
36
+ spec.required_ruby_version = ">= 2.7.5"
37
+
38
+ # Runtime dependencies.
39
+ spec.add_dependency "rails", [">= 6.0", "< 7.1"]
40
+
41
+ # Development + test dependencies.
30
42
  spec.add_development_dependency "bundler", "~> 2.0"
31
- spec.add_development_dependency "rake", "~> 12.0"
32
- spec.add_development_dependency "rspec", "~> 3.0"
43
+ spec.add_development_dependency "rake", "~> 13.0"
44
+ spec.add_development_dependency "rspec-rails", "~> 5.0"
45
+ spec.add_development_dependency "simplecov", "~> 0.20"
46
+ spec.add_development_dependency "standard", "~> 1.0"
33
47
  spec.add_development_dependency "yard", "~> 0.9"
34
48
  end