cloudenvoy 0.1.0.dev → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +37 -0
  3. data/.gitignore +3 -0
  4. data/.rubocop.yml +1 -0
  5. data/Appraisals +9 -0
  6. data/CHANGELOG.md +4 -0
  7. data/Gemfile.lock +212 -1
  8. data/README.md +569 -7
  9. data/app/controllers/cloudenvoy/application_controller.rb +8 -0
  10. data/app/controllers/cloudenvoy/subscriber_controller.rb +59 -0
  11. data/cloudenvoy.gemspec +14 -2
  12. data/config/routes.rb +5 -0
  13. data/examples/rails/.ruby-version +1 -0
  14. data/examples/rails/Gemfile +15 -0
  15. data/examples/rails/Gemfile.lock +207 -0
  16. data/examples/rails/Procfile +1 -0
  17. data/examples/rails/README.md +31 -0
  18. data/examples/rails/Rakefile +8 -0
  19. data/examples/rails/app/assets/config/manifest.js +2 -0
  20. data/examples/rails/app/assets/images/.keep +0 -0
  21. data/examples/rails/app/assets/stylesheets/application.css +15 -0
  22. data/examples/rails/app/channels/application_cable/channel.rb +6 -0
  23. data/examples/rails/app/channels/application_cable/connection.rb +6 -0
  24. data/examples/rails/app/controllers/application_controller.rb +4 -0
  25. data/examples/rails/app/controllers/concerns/.keep +0 -0
  26. data/examples/rails/app/helpers/application_helper.rb +4 -0
  27. data/examples/rails/app/javascript/packs/application.js +15 -0
  28. data/examples/rails/app/jobs/application_job.rb +9 -0
  29. data/examples/rails/app/mailers/application_mailer.rb +6 -0
  30. data/examples/rails/app/models/application_record.rb +5 -0
  31. data/examples/rails/app/models/concerns/.keep +0 -0
  32. data/examples/rails/app/publishers/hello_publisher.rb +34 -0
  33. data/examples/rails/app/subscribers/hello_subscriber.rb +16 -0
  34. data/examples/rails/app/views/layouts/application.html.erb +14 -0
  35. data/examples/rails/app/views/layouts/mailer.html.erb +13 -0
  36. data/examples/rails/app/views/layouts/mailer.text.erb +1 -0
  37. data/examples/rails/bin/rails +6 -0
  38. data/examples/rails/bin/rake +6 -0
  39. data/examples/rails/bin/setup +35 -0
  40. data/examples/rails/config.ru +7 -0
  41. data/examples/rails/config/application.rb +19 -0
  42. data/examples/rails/config/boot.rb +7 -0
  43. data/examples/rails/config/cable.yml +10 -0
  44. data/examples/rails/config/credentials.yml.enc +1 -0
  45. data/examples/rails/config/database.yml +25 -0
  46. data/examples/rails/config/environment.rb +7 -0
  47. data/examples/rails/config/environments/development.rb +65 -0
  48. data/examples/rails/config/environments/production.rb +114 -0
  49. data/examples/rails/config/environments/test.rb +50 -0
  50. data/examples/rails/config/initializers/application_controller_renderer.rb +9 -0
  51. data/examples/rails/config/initializers/assets.rb +14 -0
  52. data/examples/rails/config/initializers/backtrace_silencers.rb +8 -0
  53. data/examples/rails/config/initializers/cloudenvoy.rb +22 -0
  54. data/examples/rails/config/initializers/content_security_policy.rb +29 -0
  55. data/examples/rails/config/initializers/cookies_serializer.rb +7 -0
  56. data/examples/rails/config/initializers/filter_parameter_logging.rb +6 -0
  57. data/examples/rails/config/initializers/inflections.rb +17 -0
  58. data/examples/rails/config/initializers/mime_types.rb +5 -0
  59. data/examples/rails/config/initializers/wrap_parameters.rb +16 -0
  60. data/examples/rails/config/locales/en.yml +33 -0
  61. data/examples/rails/config/master.key +1 -0
  62. data/examples/rails/config/puma.rb +37 -0
  63. data/examples/rails/config/routes.rb +4 -0
  64. data/examples/rails/config/spring.rb +8 -0
  65. data/examples/rails/config/storage.yml +34 -0
  66. data/examples/rails/db/development.sqlite3 +0 -0
  67. data/examples/rails/db/test.sqlite3 +0 -0
  68. data/examples/rails/lib/assets/.keep +0 -0
  69. data/examples/rails/log/.keep +0 -0
  70. data/examples/rails/public/404.html +67 -0
  71. data/examples/rails/public/422.html +67 -0
  72. data/examples/rails/public/500.html +66 -0
  73. data/examples/rails/public/apple-touch-icon-precomposed.png +0 -0
  74. data/examples/rails/public/apple-touch-icon.png +0 -0
  75. data/examples/rails/public/favicon.ico +0 -0
  76. data/examples/rails/storage/.keep +0 -0
  77. data/gemfiles/rails_5.2.gemfile +7 -0
  78. data/gemfiles/rails_5.2.gemfile.lock +248 -0
  79. data/gemfiles/rails_6.0.gemfile +7 -0
  80. data/gemfiles/rails_6.0.gemfile.lock +264 -0
  81. data/lib/cloudenvoy.rb +96 -2
  82. data/lib/cloudenvoy/authentication_error.rb +6 -0
  83. data/lib/cloudenvoy/authenticator.rb +57 -0
  84. data/lib/cloudenvoy/backend/google_pub_sub.rb +110 -0
  85. data/lib/cloudenvoy/backend/memory_pub_sub.rb +88 -0
  86. data/lib/cloudenvoy/config.rb +165 -0
  87. data/lib/cloudenvoy/engine.rb +20 -0
  88. data/lib/cloudenvoy/invalid_subscriber_error.rb +6 -0
  89. data/lib/cloudenvoy/logger_wrapper.rb +167 -0
  90. data/lib/cloudenvoy/message.rb +96 -0
  91. data/lib/cloudenvoy/middleware/chain.rb +250 -0
  92. data/lib/cloudenvoy/pub_sub_client.rb +62 -0
  93. data/lib/cloudenvoy/publisher.rb +211 -0
  94. data/lib/cloudenvoy/publisher_logger.rb +32 -0
  95. data/lib/cloudenvoy/subscriber.rb +218 -0
  96. data/lib/cloudenvoy/subscriber_logger.rb +26 -0
  97. data/lib/cloudenvoy/subscription.rb +19 -0
  98. data/lib/cloudenvoy/testing.rb +106 -0
  99. data/lib/cloudenvoy/topic.rb +19 -0
  100. data/lib/cloudenvoy/version.rb +1 -1
  101. data/lib/tasks/cloudenvoy.rake +61 -0
  102. metadata +241 -6
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudenvoy
4
+ # Interface to publishing backend (GCP, emulator or memory backend)
5
+ class PubSubClient
6
+ #
7
+ # Return the backend to use for sending messages.
8
+ #
9
+ # @return [Module<Cloudenvoy::Backend::MemoryPubSub, Cloudenvoy::Backend::GoogleCloudTask>] The backend class.
10
+ #
11
+ def self.backend
12
+ # Re-evaluate backend every time if testing mode enabled
13
+ @backend = nil if defined?(Cloudenvoy::Testing)
14
+
15
+ @backend ||= begin
16
+ if defined?(Cloudenvoy::Testing) && Cloudenvoy::Testing.in_memory?
17
+ require 'cloudenvoy/backend/memory_pub_sub'
18
+ Backend::MemoryPubSub
19
+ else
20
+ require 'cloudenvoy/backend/google_pub_sub'
21
+ Backend::GooglePubSub
22
+ end
23
+ end
24
+ end
25
+
26
+ #
27
+ # Publish a message to a topic.
28
+ #
29
+ # @param [String] topic The name of the topic
30
+ # @param [Hash, String] payload The message content.
31
+ # @param [Hash] attrs The message attributes.
32
+ #
33
+ # @return [Cloudenvoy::Message] The created message.
34
+ #
35
+ def self.publish(topic, payload, attrs = {})
36
+ backend.publish(topic, payload, attrs)
37
+ end
38
+
39
+ #
40
+ # Create or update a subscription for a specific topic.
41
+ #
42
+ # @param [String] topic The name of the topic
43
+ # @param [String] name The name of the subscription
44
+ #
45
+ # @return [Cloudenvoy::Subscription] The upserted subscription.
46
+ #
47
+ def self.upsert_subscription(topic, name)
48
+ backend.upsert_subscription(topic, name)
49
+ end
50
+
51
+ #
52
+ # Create or update a topic.
53
+ #
54
+ # @param [String] topic The topic name.
55
+ #
56
+ # @return [Cloudenvoy::Topic] The upserted/topic.
57
+ #
58
+ def self.upsert_topic(topic)
59
+ backend.upsert_topic(topic)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudenvoy
4
+ # Use this module to define publishers. The module provides
5
+ # a simple DSL for transforming and publishing data objects to
6
+ # Pub/Sub.
7
+ #
8
+ # Publishers must at least implement the `payload` method, which defines
9
+ # how arguments are mapped to a Hash or String message.
10
+ #
11
+ # E.g.
12
+ #
13
+ # class UserPublisher
14
+ # include Cloudenvoy::Publisher
15
+ #
16
+ # # Specify publishing options
17
+ # cloudenvoy_options topic: 'my-topic'
18
+ #
19
+ # # Format message objects
20
+ # def payload(user)
21
+ # {
22
+ # id: user.id,
23
+ # name: user.name
24
+ # }
25
+ # end
26
+ # end
27
+ #
28
+ module Publisher
29
+ # Add class method to including class
30
+ def self.included(base)
31
+ base.extend(ClassMethods)
32
+ base.attr_accessor :msg_args, :message, :publishing_started_at, :publishing_ended_at
33
+
34
+ # Register subscriber
35
+ Cloudenvoy.publishers.add(base)
36
+ end
37
+
38
+ # Module class methods
39
+ module ClassMethods
40
+ #
41
+ # Set the publisher runtime options.
42
+ #
43
+ # @param [Hash] opts The publisher options.
44
+ #
45
+ # @return [Hash] The options set.
46
+ #
47
+ def cloudenvoy_options(opts = {})
48
+ opt_list = opts&.map { |k, v| [k.to_sym, v] } || [] # symbolize
49
+ @cloudenvoy_options_hash = Hash[opt_list]
50
+ end
51
+
52
+ #
53
+ # Return the publisher runtime options.
54
+ #
55
+ # @return [Hash] The publisher runtime options.
56
+ #
57
+ def cloudenvoy_options_hash
58
+ @cloudenvoy_options_hash || {}
59
+ end
60
+
61
+ #
62
+ # Return the default topic this publisher publishes to.
63
+ # Raises an error if no default topic has been defined.
64
+ #
65
+ # @return [String] The default topic.
66
+ #
67
+ def default_topic
68
+ cloudenvoy_options_hash[:topic]
69
+ end
70
+
71
+ #
72
+ # Format and publish objects to Pub/Sub.
73
+ #
74
+ # @param [Any] *args The publisher arguments
75
+ #
76
+ # @return [Cloudenvoy::Message] The created message.
77
+ #
78
+ def publish(*args)
79
+ new(msg_args: args).publish
80
+ end
81
+
82
+ #
83
+ # Setup the default topic for this publisher.
84
+ #
85
+ # @return [Cloudenvoy::Topic] The upserted/topic.
86
+ #
87
+ def setup
88
+ return nil unless default_topic
89
+
90
+ PubSubClient.upsert_topic(default_topic)
91
+ end
92
+ end
93
+
94
+ #
95
+ # Build a new publisher instance.
96
+ #
97
+ # @param [Array<any>] msg_args The list of payload args.
98
+ #
99
+ def initialize(msg_args: nil)
100
+ @msg_args = msg_args || []
101
+ end
102
+
103
+ #
104
+ # Return the topic to publish to. The topic name
105
+ # can be dynamically evaluated at runtime based on
106
+ # publishing arguments.
107
+ #
108
+ # Defaults to the topic specified via cloudenvoy_options.
109
+ #
110
+ # @param [Any] *_args The publisher arguments.
111
+ #
112
+ # @return [String] The topic name.
113
+ #
114
+ def topic(*_args)
115
+ self.class.default_topic
116
+ end
117
+
118
+ #
119
+ # Publisher can optionally define message attributes (metadata).
120
+ # Message attributes are sent to Pub/Sub and can be used
121
+ # for filtering.
122
+ #
123
+ # @param [Any] *_args The publisher arguments.
124
+ #
125
+ # @return [Hash] The message attributes.
126
+ #
127
+ def metadata(*_args)
128
+ {}
129
+ end
130
+
131
+ #
132
+ # Return the Cloudenvoy logger instance.
133
+ #
134
+ # @return [Logger, any] The cloudenvoy logger.
135
+ #
136
+ def logger
137
+ @logger ||= PublisherLogger.new(self)
138
+ end
139
+
140
+ #
141
+ # Return the time taken (in seconds) to format and publish the message. This duration
142
+ # includes the middlewares and the actual publish method.
143
+ #
144
+ # @return [Float] The time taken in seconds as a floating point number.
145
+ #
146
+ def publishing_duration
147
+ return 0.0 unless publishing_ended_at && publishing_started_at
148
+
149
+ (publishing_ended_at - publishing_started_at).ceil(3)
150
+ end
151
+
152
+ #
153
+ # Send the instantiated Publisher (message) to
154
+ # Pub/Sub.
155
+ #
156
+ # @return [Cloudenvoy::Message] The created message.
157
+ #
158
+ def publish
159
+ # Format and publish message
160
+ resp = execute_middleware_chain
161
+
162
+ # Log job completion and return result
163
+ logger.info("Published message in #{publishing_duration}s") { { duration: publishing_duration } }
164
+ resp
165
+ rescue StandardError => e
166
+ logger.info("Publishing failed after #{publishing_duration}s") { { duration: publishing_duration } }
167
+ raise(e)
168
+ end
169
+
170
+ #=============================
171
+ # Private
172
+ #=============================
173
+ private
174
+
175
+ #
176
+ # Internal logic used to build, publish and capture message on the
177
+ # publisher.
178
+ #
179
+ # @return [Cloudenvoy::Message] The published message
180
+ #
181
+ def publish_message
182
+ # Publish message to pub/sub and attach created
183
+ # message to publisher
184
+ self.message = PubSubClient.publish(
185
+ topic(*msg_args),
186
+ payload(*msg_args),
187
+ metadata(*msg_args)
188
+ )
189
+ end
190
+
191
+ #
192
+ # Execute the subscriber process method through the middleware chain.
193
+ #
194
+ # @return [Any] The result of the perform method.
195
+ #
196
+ def execute_middleware_chain
197
+ self.publishing_started_at = Time.now
198
+
199
+ Cloudenvoy.config.publisher_middleware.invoke(self) do
200
+ begin
201
+ publish_message
202
+ rescue StandardError => e
203
+ try(:on_error, e)
204
+ return raise(e)
205
+ end
206
+ end
207
+ ensure
208
+ self.publishing_ended_at = Time.now
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudenvoy
4
+ # Logger configuration for publishers
5
+ class PublisherLogger < LoggerWrapper
6
+ #
7
+ # The subscriber default context processor.
8
+ #
9
+ # @return [Proc] The context processor proc.
10
+ #
11
+ def self.default_context_processor
12
+ @default_context_processor ||= ->(loggable) { loggable.message&.to_h&.slice(:id, :metadata, :topic) || {} }
13
+ end
14
+
15
+ #
16
+ # Format main log message.
17
+ #
18
+ # @param [String] msg The message to log.
19
+ #
20
+ # @return [String] The formatted log message
21
+ #
22
+ def formatted_message(msg)
23
+ [
24
+ '[Cloudenvoy]',
25
+ "[#{loggable.class}]",
26
+ loggable.message&.id ? "[#{loggable.message.id}]" : nil,
27
+ ' ',
28
+ msg
29
+ ].compact.join
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudenvoy
4
+ # Use this module to define subscribers. Subscribers must implement
5
+ # the message processsing logic in the `process` method.
6
+ #
7
+ # E.g.
8
+ #
9
+ # class UserSubscriber
10
+ # include Cloudenvoy::Subscriber
11
+ #
12
+ # # Specify subscription options
13
+ # cloudenvoy_options topics: ['my-topic']
14
+ #
15
+ # # Process message objects
16
+ # def process(message)
17
+ # ...do something...
18
+ # end
19
+ # end
20
+ #
21
+ module Subscriber
22
+ # Add class method to including class
23
+ def self.included(base)
24
+ base.extend(ClassMethods)
25
+ base.attr_accessor :message, :process_started_at, :process_ended_at
26
+
27
+ # Register subscriber
28
+ Cloudenvoy.subscribers.add(base)
29
+ end
30
+
31
+ #
32
+ # Return the subscriber class for the provided
33
+ # class name.
34
+ #
35
+ # @param [String] sub_uri The subscription uri.
36
+ #
37
+ # @return [Class] The subscriber class
38
+ #
39
+ def self.from_sub_uri(sub_uri)
40
+ klass_name = Subscriber.parse_sub_uri(sub_uri)[0]
41
+
42
+ # Check that subscriber class is a valid subscriber
43
+ sub_klass = Object.const_get(klass_name.camelize)
44
+
45
+ sub_klass.include?(self) ? sub_klass : nil
46
+ end
47
+
48
+ #
49
+ # Parse the subscription name and return the subscriber name and topic.
50
+ #
51
+ # @param [String] sub_uri The subscription URI
52
+ #
53
+ # @return [Array<String,String>] A tuple [subscriber_name, topic ]
54
+ #
55
+ def self.parse_sub_uri(sub_uri)
56
+ sub_uri.split('/').last.split('.').last(2)
57
+ end
58
+
59
+ #
60
+ # Execute a subscriber from a payload object received from
61
+ # Pub/Sub.
62
+ #
63
+ # @param [Hash] input_payload The Pub/Sub webhook hash describing
64
+ # the message to process.
65
+ #
66
+ # @return [Any] The subscriber processing return value.
67
+ #
68
+ def self.execute_from_descriptor(input_payload)
69
+ message = Message.from_descriptor(input_payload)
70
+ subscriber = message.subscriber || raise(InvalidSubscriberError)
71
+ subscriber.execute
72
+ end
73
+
74
+ # Module class methods
75
+ module ClassMethods
76
+ #
77
+ # Set the subscriber runtime options.
78
+ #
79
+ # @param [Hash] opts The subscriber options.
80
+ #
81
+ # @return [Hash] The options set.
82
+ #
83
+ def cloudenvoy_options(opts = {})
84
+ opt_list = opts&.map { |k, v| [k.to_sym, v] } || [] # symbolize
85
+ @cloudenvoy_options_hash = Hash[opt_list]
86
+ end
87
+
88
+ #
89
+ # Return the subscriber runtime options.
90
+ #
91
+ # @return [Hash] The subscriber runtime options.
92
+ #
93
+ def cloudenvoy_options_hash
94
+ @cloudenvoy_options_hash || {}
95
+ end
96
+
97
+ #
98
+ # Return the list of topics this subscriber listens
99
+ # to.
100
+ #
101
+ # @return [Array<String>] The list of subscribed topics.
102
+ #
103
+ def topics
104
+ cloudenvoy_options_hash[:topics] || []
105
+ end
106
+
107
+ #
108
+ # Return the subscription name used by this subscriber
109
+ # to subscribe to a specific topic.
110
+ #
111
+ # @return [String] The subscription name.
112
+ #
113
+ def subscription_name(topic)
114
+ [
115
+ Cloudenvoy.config.gcp_sub_prefix.tr('.', '-'),
116
+ to_s.underscore,
117
+ topic
118
+ ].join('.')
119
+ end
120
+
121
+ #
122
+ # Create the Subscriber subscription in Pub/Sub.
123
+ #
124
+ # @return [Array<Cloudenvoy::Subscription>] The upserted subscription.
125
+ #
126
+ def setup
127
+ topics.map do |t|
128
+ PubSubClient.upsert_subscription(t, subscription_name(t))
129
+ end
130
+ end
131
+ end
132
+
133
+ #
134
+ # Build a new subscriber instance.
135
+ #
136
+ # @param [Cloudenvoy::Message] message The message to process.
137
+ #
138
+ def initialize(message:)
139
+ @message = message
140
+ end
141
+
142
+ #
143
+ # Return the Cloudenvoy logger instance.
144
+ #
145
+ # @return [Logger, any] The cloudenvoy logger.
146
+ #
147
+ def logger
148
+ @logger ||= SubscriberLogger.new(self)
149
+ end
150
+
151
+ #
152
+ # Return the time taken (in seconds) to process the message. This duration
153
+ # includes the middlewares and the actual process method.
154
+ #
155
+ # @return [Float] The time taken in seconds as a floating point number.
156
+ #
157
+ def process_duration
158
+ return 0.0 unless process_ended_at && process_started_at
159
+
160
+ (process_ended_at - process_started_at).ceil(3)
161
+ end
162
+
163
+ #
164
+ # Execute the subscriber's logic.
165
+ #
166
+ # @return [Any] The logic return value
167
+ #
168
+ def execute
169
+ logger.info('Processing message...')
170
+
171
+ # Process message
172
+ resp = execute_middleware_chain
173
+
174
+ # Log processing completion and return result
175
+ logger.info("Processing done after #{process_duration}s") { { duration: process_duration } }
176
+ resp
177
+ rescue StandardError => e
178
+ logger.info("Processing failed after #{process_duration}s") { { duration: process_duration } }
179
+ raise(e)
180
+ end
181
+
182
+ #
183
+ # Equality operator.
184
+ #
185
+ # @param [Any] other The object to compare.
186
+ #
187
+ # @return [Boolean] True if the object is equal.
188
+ #
189
+ def ==(other)
190
+ other.is_a?(self.class) && other.message == message
191
+ end
192
+
193
+ #=============================
194
+ # Private
195
+ #=============================
196
+ private
197
+
198
+ #
199
+ # Execute the subscriber process method through the middleware chain.
200
+ #
201
+ # @return [Any] The result of the perform method.
202
+ #
203
+ def execute_middleware_chain
204
+ self.process_started_at = Time.now
205
+
206
+ Cloudenvoy.config.subscriber_middleware.invoke(self) do
207
+ begin
208
+ process(message)
209
+ rescue StandardError => e
210
+ try(:on_error, e)
211
+ return raise(e)
212
+ end
213
+ end
214
+ ensure
215
+ self.process_ended_at = Time.now
216
+ end
217
+ end
218
+ end