lepus 0.0.1.beta2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/specs.yml +44 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +35 -0
  6. data/.tool-versions +1 -0
  7. data/CHANGELOG.md +10 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +120 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +213 -0
  12. data/Rakefile +4 -0
  13. data/bin/console +9 -0
  14. data/bin/setup +7 -0
  15. data/docker-compose.yml +8 -0
  16. data/exec/lepus +9 -0
  17. data/gemfiles/rails52.gemfile +5 -0
  18. data/gemfiles/rails52.gemfile.lock +242 -0
  19. data/gemfiles/rails61.gemfile +5 -0
  20. data/gemfiles/rails61.gemfile.lock +260 -0
  21. data/lepus.gemspec +53 -0
  22. data/lib/lepus/app_executor.rb +19 -0
  23. data/lib/lepus/cli.rb +27 -0
  24. data/lib/lepus/configuration.rb +90 -0
  25. data/lib/lepus/consumer.rb +177 -0
  26. data/lib/lepus/consumer_config.rb +149 -0
  27. data/lib/lepus/consumer_wrapper.rb +46 -0
  28. data/lib/lepus/lifecycle_hooks.rb +49 -0
  29. data/lib/lepus/message.rb +37 -0
  30. data/lib/lepus/middleware.rb +18 -0
  31. data/lib/lepus/middlewares/honeybadger.rb +23 -0
  32. data/lib/lepus/middlewares/json.rb +35 -0
  33. data/lib/lepus/middlewares/max_retry.rb +57 -0
  34. data/lib/lepus/primitive/string.rb +55 -0
  35. data/lib/lepus/process.rb +136 -0
  36. data/lib/lepus/process_registry.rb +37 -0
  37. data/lib/lepus/processes/base.rb +50 -0
  38. data/lib/lepus/processes/callbacks.rb +72 -0
  39. data/lib/lepus/processes/consumer.rb +113 -0
  40. data/lib/lepus/processes/interruptible.rb +38 -0
  41. data/lib/lepus/processes/procline.rb +11 -0
  42. data/lib/lepus/processes/registrable.rb +67 -0
  43. data/lib/lepus/processes/runnable.rb +102 -0
  44. data/lib/lepus/processes/supervised.rb +44 -0
  45. data/lib/lepus/processes.rb +6 -0
  46. data/lib/lepus/producer.rb +42 -0
  47. data/lib/lepus/rails/log_subscriber.rb +120 -0
  48. data/lib/lepus/rails/railtie.rb +31 -0
  49. data/lib/lepus/rails.rb +7 -0
  50. data/lib/lepus/supervisor/config.rb +45 -0
  51. data/lib/lepus/supervisor/maintenance.rb +35 -0
  52. data/lib/lepus/supervisor/pidfile.rb +61 -0
  53. data/lib/lepus/supervisor/pidfiled.rb +29 -0
  54. data/lib/lepus/supervisor/signals.rb +71 -0
  55. data/lib/lepus/supervisor.rb +204 -0
  56. data/lib/lepus/timer.rb +29 -0
  57. data/lib/lepus/version.rb +5 -0
  58. data/lib/lepus.rb +95 -0
  59. data/lib/puma/plugin/lepus.rb +74 -0
  60. metadata +290 -0
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ # The abstract base class for consumers processing messages from queues.
5
+ # @abstract Subclass and override {#work} to implement.
6
+ class Consumer
7
+ class << self
8
+ def abstract_class?
9
+ return @abstract_class == true if defined?(@abstract_class)
10
+
11
+ instance_variable_get(:@config).nil?
12
+ end
13
+
14
+ def abstract_class=(value)
15
+ @config = nil
16
+ @abstract_class = value
17
+ end
18
+
19
+ def inherited(subclass)
20
+ super
21
+ subclass.abstract_class = false
22
+ end
23
+
24
+ def config
25
+ return if @abstract_class == true
26
+ return @config if defined?(@config)
27
+
28
+ name = Primitive::String.new(to_s).underscore.split("/").last
29
+ @config = ConsumerConfig.new(queue: name, exchange: name)
30
+ end
31
+
32
+ # List of registered middlewares. Register new middlewares with {.use}.
33
+ # @return [Array<Lepus::Middleware>]
34
+ def middlewares
35
+ @middlewares ||= []
36
+ end
37
+
38
+ # Registers a new middleware by instantiating +middleware+ and passing it +opts+.
39
+ #
40
+ # @param [Symbol, Class<Lepus::Middleware>] middleware The middleware class to instantiate and register.
41
+ # @param [Hash] opts The options for instantiating the middleware.
42
+ def use(middleware, opts = {})
43
+ if middleware.is_a?(Symbol) || middleware.is_a?(String)
44
+ begin
45
+ require_relative "middlewares/#{middleware}"
46
+ class_name = Primitive::String.new(middleware.to_s).classify
47
+ class_name = "JSON" if class_name == "Json"
48
+ middleware = Lepus::Middlewares.const_get(class_name)
49
+ rescue LoadError, NameError
50
+ raise ArgumentError, "Middleware #{middleware} not found"
51
+ end
52
+ end
53
+
54
+ middlewares << middleware.new(**opts)
55
+ end
56
+
57
+ # Configures the consumer, setting queue, exchange and other options to be used by
58
+ # the add_consumer method.
59
+ #
60
+ # @param [Hash] opts The options to configure the consumer with.
61
+ # @option opts [String, Hash] :queue The name of the queue to consume from.
62
+ # @option opts [String, Hash] :exchange The name of the exchange the queue should be bound to.
63
+ # @option opts [Array] :routing_key The routing keys used for the queue binding.
64
+ # @option opts [Boolean, Hash] :retry_queue (false) Whether a retry queue should be provided.
65
+ # @option opts [Boolean, Hash] :error_queue (false) Whether an error queue should be provided.
66
+ def configure(opts = {})
67
+ @config = ConsumerConfig.new(opts)
68
+ yield(@config) if block_given?
69
+ @config
70
+ end
71
+
72
+ def descendants # :nodoc:
73
+ descendants = []
74
+ ObjectSpace.each_object(singleton_class) do |k|
75
+ descendants.unshift k unless k == self
76
+ end
77
+ descendants.uniq
78
+ end
79
+ end
80
+
81
+ # The method that is called when a message from the queue is received.
82
+ # Keep in mind that the parameters received can be altered by middlewares!
83
+ #
84
+ # @param [Leupus::Message] message The message to process.
85
+ #
86
+ # @return [:ack, :reject, :requeue] A symbol denoting what should be done with the message.
87
+ def perform(message)
88
+ raise NotImplementedError
89
+ end
90
+
91
+ # Wraps #perform to add middlewares. This is being called by Lepus when a message is received for the consumer.
92
+ #
93
+ # @param [Bunny::DeliveryInfo] delivery_info The delivery info of the received message.
94
+ # @param [Bunny::MessageProperties] metadata The metadata of the received message.
95
+ # @param [String] payload The payload of the received message.
96
+ # @raise [InvalidConsumerReturnError] if you return something other than +:ack+, +:reject+ or +:requeue+ from {#perform}.
97
+ def process_delivery(delivery_info, metadata, payload)
98
+ message = Message.new(delivery_info, metadata, payload)
99
+ self
100
+ .class
101
+ .middlewares
102
+ .reverse
103
+ .reduce(work_proc) do |next_middleware, middleware|
104
+ nest_middleware(middleware, next_middleware)
105
+ end
106
+ .call(message)
107
+ rescue Lepus::InvalidConsumerReturnError
108
+ raise
109
+ rescue Exception => ex # rubocop:disable Lint/RescueException
110
+ # @TODO: add error handling
111
+ logger.error(ex)
112
+
113
+ reject!
114
+ end
115
+
116
+ protected
117
+
118
+ def logger
119
+ Lepus.logger
120
+ end
121
+
122
+ # Helper method to ack a message.
123
+ #
124
+ # @return [:ack]
125
+ def ack!
126
+ :ack
127
+ end
128
+ alias_method :ack, :ack!
129
+
130
+ # Helper method to reject a message.
131
+ #
132
+ # @return [:reject]
133
+ #
134
+ def reject!
135
+ :reject
136
+ end
137
+ alias_method :reject, :reject!
138
+
139
+ # Helper method to requeue a message.
140
+ #
141
+ # @return [:requeue]
142
+ def requeue!
143
+ :requeue
144
+ end
145
+ alias_method :requeue, :requeue!
146
+
147
+ # Helper method to nack a message.
148
+ #
149
+ # @return [:nack]
150
+ def nack!
151
+ :nack
152
+ end
153
+ alias_method :nack, :nack!
154
+
155
+ private
156
+
157
+ def work_proc
158
+ ->(message) do
159
+ perform(message).tap do |result|
160
+ verify_result(result)
161
+ end
162
+ end
163
+ end
164
+
165
+ def nest_middleware(middleware, next_middleware)
166
+ ->(message) do
167
+ middleware.call(message, next_middleware)
168
+ end
169
+ end
170
+
171
+ def verify_result(result)
172
+ return if %i[ack reject requeue nack].include?(result)
173
+
174
+ raise InvalidConsumerReturnError, result
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,149 @@
1
+ require "bunny"
2
+
3
+ module Lepus
4
+ # Parse the list of options for the consumer.
5
+ class ConsumerConfig
6
+ DEFAULT_EXCHANGE_OPTIONS = {
7
+ name: nil,
8
+ type: :topic, # The type of the exchange (:direct, :fanout, :topic or :headers).
9
+ durable: true
10
+ }.freeze
11
+
12
+ DEFAULT_QUEUE_OPTIONS = {
13
+ name: nil,
14
+ durable: true
15
+ }.freeze
16
+
17
+ DEFAULT_RETRY_QUEUE_OPTIONS = {
18
+ name: nil,
19
+ durable: true,
20
+ delay: 5000,
21
+ arguments: {}
22
+ }
23
+
24
+ DEFAULT_ERROR_QUEUE_OPTIONS = DEFAULT_QUEUE_OPTIONS
25
+
26
+ attr_reader :options
27
+
28
+ def initialize(options = {})
29
+ opts = HashUtil.deep_symbolize_keys(options)
30
+
31
+ @exchange_opts = DEFAULT_EXCHANGE_OPTIONS.merge(
32
+ declaration_config(opts.delete(:exchange))
33
+ )
34
+ @queue_opts = DEFAULT_QUEUE_OPTIONS.merge(
35
+ declaration_config(opts.delete(:queue))
36
+ )
37
+ if (value = opts.delete(:retry_queue))
38
+ @retry_queue_opts = DEFAULT_RETRY_QUEUE_OPTIONS.merge(
39
+ declaration_config(value)
40
+ )
41
+ end
42
+ if (value = opts.delete(:error_queue))
43
+ @error_queue_opts = DEFAULT_ERROR_QUEUE_OPTIONS.merge(
44
+ declaration_config(value)
45
+ )
46
+ end
47
+ @bind_opts = opts.delete(:bind) || {}
48
+ if (routing_key = opts.delete(:routing_key))
49
+ @bind_opts[:routing_key] ||= routing_key
50
+ end
51
+ @options = opts
52
+ end
53
+
54
+ def channel_args
55
+ @channel_opts.values_at(
56
+ :consumer_pool_size,
57
+ :consumer_pool_abort_on_exception,
58
+ :consumer_pool_shutdown_timeout
59
+ )
60
+ end
61
+
62
+ def exchange_args
63
+ [exchange_name, @exchange_opts.reject { |k, v| k == :name }]
64
+ end
65
+
66
+ def consumer_queue_args
67
+ opts = @queue_opts.reject { |k, v| k == :name }
68
+ return [queue_name, opts] unless retry_queue_args
69
+
70
+ opts[:arguments] ||= {}
71
+ opts[:arguments]["x-dead-letter-exchange"] = ""
72
+ opts[:arguments]["x-dead-letter-routing-key"] = retry_queue_name
73
+
74
+ [queue_name, opts]
75
+ end
76
+
77
+ def retry_queue_args
78
+ return unless @retry_queue_opts
79
+
80
+ delay = @retry_queue_opts[:delay]
81
+ args = (@retry_queue_opts[:arguments] || {}).merge(
82
+ "x-dead-letter-exchange" => "",
83
+ "x-dead-letter-routing-key" => queue_name,
84
+ "x-message-ttl" => delay
85
+ )
86
+ extra_keys = %i[name delay]
87
+ opts = @retry_queue_opts.reject { |k, v| extra_keys.include?(k) }
88
+ [retry_queue_name, opts.merge(arguments: args)]
89
+ end
90
+
91
+ def error_queue_args
92
+ return unless @error_queue_opts
93
+
94
+ name = @error_queue_opts[:name]
95
+ name ||= "#{queue_name}.error"
96
+ [name, @error_queue_opts.reject { |k, v| k == :name }]
97
+ end
98
+
99
+ def binds_args
100
+ arguments = @bind_opts.fetch(:arguments, {}).transform_keys(&:to_s)
101
+ opts = {}
102
+ opts[:arguments] = arguments unless arguments.empty?
103
+ if (routing_keys = @bind_opts[:routing_key]).is_a?(Array)
104
+ routing_keys.map { |key| opts.merge(routing_key: key) }
105
+ elsif (routing_key = @bind_opts[:routing_key])
106
+ [opts.merge(routing_key: routing_key)]
107
+ else
108
+ [opts]
109
+ end
110
+ end
111
+
112
+ protected
113
+
114
+ def exchange_name
115
+ @exchange_opts[:name] || raise(InvalidConsumerConfigError, "Exchange name is required")
116
+ end
117
+
118
+ def queue_name
119
+ @queue_opts[:name] || raise(InvalidConsumerConfigError, "Queue name is required")
120
+ end
121
+
122
+ def retry_queue_name
123
+ name = @retry_queue_opts[:name]
124
+ name ||= "#{queue_name}.retry"
125
+ name
126
+ end
127
+
128
+ # Normalizes a declaration config (for exchanges and queues) into a configuration Hash.
129
+ #
130
+ # If the given `value` is a String, convert it to a Hash with the key `:name` and the value.
131
+ # If the given `value` is a Hash, leave it as is.
132
+ def declaration_config(value)
133
+ case value
134
+ when Hash then value
135
+ when String then {name: value}
136
+ when NilClass then {}
137
+ when TrueClass then {}
138
+ end
139
+ end
140
+
141
+ class HashUtil
142
+ def self.deep_symbolize_keys(hash)
143
+ hash.each_with_object({}) do |(k, v), memo|
144
+ memo[k.to_sym] = v.is_a?(Hash) ? deep_symbolize_keys(v) : v
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,46 @@
1
+ require "bunny"
2
+
3
+ module Lepus
4
+ # Wraps the user-defined consumer to provide the expected interface to Bunny.
5
+ class ConsumerWrapper < Bunny::Consumer
6
+ # @param [Lepus::Consumer] consumer The user-defined consumer implementation derived from {Lepus::Consumer}.
7
+ # @param [Bunny::Channel] channel The channel used for the consumer.
8
+ # @param [Bunny::Queue] queue The queue the consumer is subscribed to.
9
+ # @param [String] consumer_tag A string identifying the consumer instance.
10
+ # @param [Hash] arguments Arguments that are passed on to +Bunny::Consumer.new+.
11
+ def initialize(consumer, channel, queue, consumer_tag, arguments = {})
12
+ @consumer = consumer
13
+ super(channel, queue, consumer_tag, false, false, arguments)
14
+ end
15
+
16
+ # Called when a message is received from the subscribed queue.
17
+ #
18
+ # @param [Bunny::DeliveryInfo] delivery_info The delivery info of the received message.
19
+ # @param [Bunny::MessageProperties] metadata The metadata of the received message.
20
+ # @param [String] payload The payload of the received message.
21
+ def process_delivery(delivery_info, metadata, payload)
22
+ consumer
23
+ .process_delivery(delivery_info, metadata, payload)
24
+ .tap { |result| process_result(result, delivery_info.delivery_tag) }
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :consumer
30
+
31
+ def process_result(result, delivery_tag)
32
+ case result
33
+ when :ack
34
+ channel.ack(delivery_tag, false)
35
+ when :reject
36
+ channel.reject(delivery_tag)
37
+ when :requeue
38
+ channel.reject(delivery_tag, true)
39
+ when :nack
40
+ channel.nack(delivery_tag, false, true)
41
+ else
42
+ raise Lepus::InvalidConsumerReturnError, result
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ # @TODO: Move after/before fork hooks to this module
5
+ module LifecycleHooks
6
+ def self.included(base)
7
+ base.extend ClassMethods
8
+ base.send :include, InstanceMethods
9
+ base.instance_variable_set(:@lifecycle_hooks, {start: [], stop: []})
10
+ end
11
+
12
+ module ClassMethods
13
+ attr_reader :lifecycle_hooks
14
+
15
+ def on_start(&block)
16
+ lifecycle_hooks[:start] << block
17
+ end
18
+
19
+ def on_stop(&block)
20
+ lifecycle_hooks[:stop] << block
21
+ end
22
+
23
+ def clear_hooks
24
+ lifecycle_hooks[:start] = []
25
+ lifecycle_hooks[:stop] = []
26
+ end
27
+ end
28
+
29
+ module InstanceMethods
30
+ private
31
+
32
+ def run_start_hooks
33
+ run_hooks_for :start
34
+ end
35
+
36
+ def run_stop_hooks
37
+ run_hooks_for :stop
38
+ end
39
+
40
+ def run_hooks_for(event)
41
+ self.class.lifecycle_hooks.fetch(event, []).each do |block|
42
+ block.call
43
+ rescue Exception => exception # rubocop:disable Lint/RescueException
44
+ handle_thread_error(exception)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Message
5
+ attr_reader :delivery_info, :metadata, :payload
6
+
7
+ def initialize(delivery_info, metadata, payload)
8
+ @delivery_info = delivery_info
9
+ @metadata = metadata
10
+ @payload = payload
11
+ end
12
+
13
+ def mutate(payload: nil, metadata: nil, delivery_info: nil)
14
+ self.class.new(
15
+ delivery_info || @delivery_info,
16
+ metadata || @metadata,
17
+ payload || @payload
18
+ )
19
+ end
20
+
21
+ def to_h
22
+ {
23
+ delivery: delivery_info&.to_h,
24
+ metadata: metadata&.to_h,
25
+ payload: payload
26
+ }
27
+ end
28
+
29
+ def eql?(other)
30
+ other.is_a?(self.class) &&
31
+ delivery_info == other.delivery_info &&
32
+ metadata == other.metadata &&
33
+ payload == other.payload
34
+ end
35
+ alias_method :==, :eql?
36
+ end
37
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ # The abstract base class for middlewares.
5
+ # @abstract Subclass and override {#call} (and maybe +#initialize+) to implement.
6
+ class Middleware
7
+ def initialize(**)
8
+ end
9
+
10
+ # Invokes the middleware.
11
+ #
12
+ # @param [Lepus::Message] message The message to process.
13
+ # @param app The next middleware to call or the actual consumer instance.
14
+ def call(message, app)
15
+ raise NotImplementedError
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Middlewares
5
+ # A middleware that automatically wraps {Lepus::Consumer#perform]} in an Honeybadger transaction.
6
+ class Honeybadger < Lepus::Middleware
7
+ # @param [Hash] opts The options for the middleware.
8
+ # @option opts [String] :class_name The name of the class you want to monitor.
9
+ def initialize(class_name:, **)
10
+ super
11
+
12
+ @class_name = class_name
13
+ end
14
+
15
+ def call(message, app)
16
+ app.call(message)
17
+ rescue => err
18
+ ::Honeybadger.notify(err, context: {class_name: @class_name})
19
+ raise err
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "multi_json"
4
+
5
+ module Lepus
6
+ module Middlewares
7
+ # A middleware that automatically parses your JSON payload.
8
+ class JSON < Lepus::Middleware
9
+ # @param [Hash] opts The options for the middleware.
10
+ # @option opts [Proc] :on_error (Proc.new { :reject }) A Proc to be called when an error occurs during processing.
11
+ # @option opts [Boolean] :symbolize_keys (false) Whether to symbolize the keys of your payload.
12
+ def initialize(**opts)
13
+ super
14
+
15
+ @on_error = opts.fetch(:on_error, proc { :reject })
16
+ @symbolize_keys = opts.fetch(:symbolize_keys, false)
17
+ end
18
+
19
+ def call(message, app)
20
+ begin
21
+ parsed_payload =
22
+ MultiJson.load(message.payload, symbolize_keys: symbolize_keys)
23
+ rescue => e
24
+ return on_error.call(e)
25
+ end
26
+
27
+ app.call(message.mutate(payload: parsed_payload))
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :symbolize_keys, :on_error
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,57 @@
1
+ module Lepus
2
+ module Middlewares
3
+ # A middleware that automatically puts messages on an error queue when the specified number of retries are exceeded.
4
+ class MaxRetry < Lepus::Middleware
5
+ include Lepus::AppExecutor
6
+
7
+ # @param [Hash] opts The options for the middleware.
8
+ # @option opts [Integer] :retries The number of retries before the message is sent to the error queue.
9
+ # @option opts [String] :error_queue The name of the queue where messages should be sent to when the max retries are reached.
10
+ def initialize(retries:, error_queue:)
11
+ super
12
+
13
+ @retries = retries
14
+ @error_queue = error_queue
15
+ end
16
+
17
+ def call(message, app)
18
+ return handle_exceeded(message) if retries_exceeded?(message.metadata)
19
+
20
+ app.call(message)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :retries, :error_queue
26
+
27
+ def handle_exceeded(message)
28
+ payload = message.payload
29
+ payload = MultiJson.dump(payload) if payload.is_a?(Hash)
30
+ ::Bunny::Exchange.default(message.delivery_info.channel).publish(
31
+ payload,
32
+ routing_key: error_queue
33
+ )
34
+ :ack
35
+ rescue => err
36
+ handle_thread_error(err)
37
+ end
38
+
39
+ def retries_exceeded?(metadata)
40
+ return false if metadata.headers.nil?
41
+
42
+ rejected_deaths =
43
+ metadata
44
+ .headers
45
+ .fetch("x-death", [])
46
+ .select { |death| death["reason"] == "rejected" }
47
+
48
+ return false unless rejected_deaths.any?
49
+
50
+ retry_count = rejected_deaths.map { |death| death["count"] }.compact.max
51
+ return false unless retry_count
52
+
53
+ retry_count > @retries
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "dry/inflector"
5
+ rescue LoadError
6
+ # noop
7
+ end
8
+
9
+ begin
10
+ require "active_support/inflector"
11
+ rescue LoadError
12
+ # noop
13
+ end
14
+
15
+ module Lepus::Primitive
16
+ class String < ::String
17
+ def classify
18
+ new_str = if defined?(Dry::Inflector)
19
+ Dry::Inflector.new.classify(self)
20
+ elsif defined?(ActiveSupport::Inflector)
21
+ ActiveSupport::Inflector.classify(self)
22
+ else
23
+ split("/").collect do |c|
24
+ c.split("_").collect(&:capitalize).join
25
+ end.join("::")
26
+ end
27
+
28
+ self.class.new(new_str)
29
+ end
30
+
31
+ def constantize
32
+ if defined?(Dry::Inflector)
33
+ Dry::Inflector.new.constantize(self)
34
+ elsif defined?(ActiveSupport::Inflector)
35
+ ActiveSupport::Inflector.constantize(self)
36
+ else
37
+ Object.const_get(self)
38
+ end
39
+ end
40
+
41
+ def underscore
42
+ new_str = sub(/^::/, "")
43
+ .gsub("::", "/")
44
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
45
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
46
+ .tr("-", "_")
47
+ .tr(".", "_")
48
+ .gsub(/\s/, "_")
49
+ .gsub(/__+/, "_")
50
+ .downcase
51
+
52
+ self.class.new(new_str)
53
+ end
54
+ end
55
+ end