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
@@ -1,8 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/core_ext/string/inflections'
4
+
3
5
  require 'cloudenvoy/version'
6
+ require 'cloudenvoy/config'
7
+
8
+ require 'cloudenvoy/authentication_error'
9
+ require 'cloudenvoy/invalid_subscriber_error'
10
+
11
+ require 'cloudenvoy/middleware/chain'
12
+ require 'cloudenvoy/authenticator'
13
+ require 'cloudenvoy/topic'
14
+ require 'cloudenvoy/subscription'
15
+ require 'cloudenvoy/pub_sub_client'
16
+ require 'cloudenvoy/logger_wrapper'
17
+ require 'cloudenvoy/publisher_logger'
18
+ require 'cloudenvoy/subscriber_logger'
19
+ require 'cloudenvoy/message'
20
+ require 'cloudenvoy/publisher'
21
+ require 'cloudenvoy/subscriber'
4
22
 
23
+ # Define and manage Cloud Pub/Sub publishers and subscribers
5
24
  module Cloudenvoy
6
- class Error < StandardError; end
7
- # Your code goes here...
25
+ attr_writer :config
26
+
27
+ #
28
+ # Cloudenvoy configurator.
29
+ #
30
+ def self.configure
31
+ yield(config)
32
+ end
33
+
34
+ #
35
+ # Return the Cloudenvoy configuration.
36
+ #
37
+ # @return [Cloudenvoy::Config] The Cloudenvoy configuration.
38
+ #
39
+ def self.config
40
+ @config ||= Config.new
41
+ end
42
+
43
+ #
44
+ # Return the Cloudenvoy logger.
45
+ #
46
+ # @return [Logger] The Cloudenvoy logger.
47
+ #
48
+ def self.logger
49
+ config.logger
50
+ end
51
+
52
+ #
53
+ # Publish a message to a topic. Shorthand method to Cloudenvoy::PubSubClient#publish.
54
+ #
55
+ # @param [String] topic The name of the topic
56
+ # @param [Hash, String] payload The message content.
57
+ # @param [Hash] attrs The message attributes.
58
+ #
59
+ # @return [Cloudenvoy::Message] The created message.
60
+ #
61
+ def self.publish(topic, payload, attrs = {})
62
+ PubSubClient.publish(topic, payload, attrs)
63
+ end
64
+
65
+ #
66
+ # Return the list of registered publishers.
67
+ #
68
+ # @return [Set<Cloudenvoy::Subscriber>] The list of registered publishers.
69
+ #
70
+ def self.publishers
71
+ @publishers ||= Set.new
72
+ end
73
+
74
+ #
75
+ # Return the list of registered subscribers.
76
+ #
77
+ # @return [Set<Cloudenvoy::Subscriber>] The list of registered subscribers.
78
+ #
79
+ def self.subscribers
80
+ @subscribers ||= Set.new
81
+ end
82
+
83
+ #
84
+ # Create/update subscriptions for all registered subscribers.
85
+ #
86
+ # @return [Array<Cloudenvoy::Subscription>] The upserted subscriptions.
87
+ #
88
+ def self.setup_subscribers
89
+ subscribers.flat_map(&:setup)
90
+ end
91
+
92
+ #
93
+ # Create/update default topics for all registered publishers.
94
+ #
95
+ # @return [Array<Cloudenvoy::Subscription>] The upserted topics.
96
+ #
97
+ def self.setup_publishers
98
+ publishers.flat_map(&:setup)
99
+ end
8
100
  end
101
+
102
+ require 'cloudenvoy/engine' if defined?(::Rails::Engine)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudenvoy
4
+ class AuthenticationError < StandardError
5
+ end
6
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module Cloudenvoy
6
+ # Manage token generation and verification
7
+ module Authenticator
8
+ module_function
9
+
10
+ # Algorithm used to sign the verification token
11
+ JWT_ALG = 'HS256'
12
+
13
+ #
14
+ # Return the cloudenvoy configuration. See Cloudenvoy#configure.
15
+ #
16
+ # @return [Cloudenvoy::Config] The library configuration.
17
+ #
18
+ def config
19
+ Cloudenvoy.config
20
+ end
21
+
22
+ #
23
+ # A Json Web Token (JWT) which is embedded as part of the receiving endpoint
24
+ # and will be used by the processor to authenticate the source of the message.
25
+ #
26
+ # @return [String] The jwt token
27
+ #
28
+ def verification_token
29
+ JWT.encode({ iat: Time.now.to_i }, config.secret, JWT_ALG)
30
+ end
31
+
32
+ #
33
+ # Verify a bearer token (jwt token)
34
+ #
35
+ # @param [String] bearer_token The token to verify.
36
+ #
37
+ # @return [Boolean] Return true if the token is valid
38
+ #
39
+ def verify(bearer_token)
40
+ JWT.decode(bearer_token, config.secret)
41
+ rescue JWT::VerificationError, JWT::DecodeError
42
+ false
43
+ end
44
+
45
+ #
46
+ # Verify a bearer token and raise a `Cloudenvoy::AuthenticationError`
47
+ # if the token is invalid.
48
+ #
49
+ # @param [String] bearer_token The token to verify.
50
+ #
51
+ # @return [Boolean] Return true if the token is valid
52
+ #
53
+ def verify!(bearer_token)
54
+ verify(bearer_token) || raise(AuthenticationError)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google/cloud/pubsub'
4
+
5
+ module Cloudenvoy
6
+ module Backend
7
+ # Interface to GCP Pub/Sub and Pub/Sub local emulator
8
+ module GooglePubSub
9
+ module_function
10
+
11
+ #
12
+ # Return the cloudenvoy configuration. See Cloudenvoy#configure.
13
+ #
14
+ # @return [Cloudenvoy::Config] The library configuration.
15
+ #
16
+ def config
17
+ Cloudenvoy.config
18
+ end
19
+
20
+ #
21
+ # Return the backend to use for sending messages.
22
+ #
23
+ # @return [Google::Cloud::Pub] The low level client instance.
24
+ #
25
+ def backend
26
+ @backend ||= Google::Cloud::PubSub.new({
27
+ project_id: config.gcp_project_id,
28
+ emulator_host: config.mode == :development ? Cloudenvoy::Config::EMULATOR_HOST : nil
29
+ }.compact)
30
+ end
31
+
32
+ #
33
+ # Return an authenticated endpoint for processing Pub/Sub webhooks.
34
+ #
35
+ # @return [String] An authenticated endpoint.
36
+ #
37
+ def webhook_url
38
+ "#{config.processor_url}?token=#{Authenticator.verification_token}"
39
+ end
40
+
41
+ #
42
+ # Publish a message to a topic.
43
+ #
44
+ # @param [String] topic The name of the topic
45
+ # @param [Hash, String] payload The message content.
46
+ # @param [Hash] metadata The message attributes.
47
+ #
48
+ # @return [Cloudenvoy::Message] The created message.
49
+ #
50
+ def publish(topic, payload, metadata = {})
51
+ # Retrieve the topic
52
+ ps_topic = backend.topic(topic, skip_lookup: true)
53
+
54
+ # Publish the message
55
+ ps_msg = ps_topic.publish(payload.to_json, metadata.to_h)
56
+
57
+ # Return formatted message
58
+ Message.new(
59
+ id: ps_msg.message_id,
60
+ payload: payload,
61
+ metadata: metadata,
62
+ topic: topic
63
+ )
64
+ end
65
+
66
+ #
67
+ # Create or update a subscription for a specific topic.
68
+ #
69
+ # @param [String] topic The name of the topic
70
+ # @param [String] name The name of the subscription
71
+ #
72
+ # @return [Cloudenvoy::Subscription] The upserted subscription.
73
+ #
74
+ def upsert_subscription(topic, name)
75
+ ps_sub = begin
76
+ # Retrieve the topic
77
+ ps_topic = backend.topic(topic, skip_lookup: true)
78
+
79
+ # Attempt to create the subscription
80
+ ps_topic.subscribe(name, endpoint: webhook_url)
81
+ rescue Google::Cloud::AlreadyExistsError
82
+ # Update endpoint on subscription
83
+ # Topic is not updated as it is name-dependent
84
+ backend.subscription(name).tap { |e| e.endpoint = webhook_url }
85
+ end
86
+
87
+ # Return formatted subscription
88
+ Subscription.new(name: ps_sub.name, original: ps_sub)
89
+ end
90
+
91
+ #
92
+ # Create or update a topic.
93
+ #
94
+ # @param [String] topic The topic name.
95
+ #
96
+ # @return [Cloudenvoy::Topic] The upserted topic.
97
+ #
98
+ def upsert_topic(topic)
99
+ ps_topic = begin
100
+ backend.create_topic(topic)
101
+ rescue Google::Cloud::AlreadyExistsError
102
+ backend.topic(topic)
103
+ end
104
+
105
+ # Return formatted subscription
106
+ Topic.new(name: ps_topic.name, original: ps_topic)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google/cloud/pubsub'
4
+
5
+ module Cloudenvoy
6
+ module Backend
7
+ # Store messages in a memory queue. Used for testing
8
+ module MemoryPubSub
9
+ module_function
10
+
11
+ #
12
+ # Return the message queue for a specific topic.
13
+ #
14
+ # @param [String] name The topic to retrieve.
15
+ #
16
+ # @return [Array] The list of messages for the provided topic
17
+ #
18
+ def queue(topic)
19
+ @queues ||= {}
20
+ @queues[topic.to_s] ||= []
21
+ end
22
+
23
+ #
24
+ # Clear all messages in a specific topic.
25
+ #
26
+ # @param [String] name The topic to clear.
27
+ #
28
+ # @return [Array] The cleared array.
29
+ #
30
+ def clear(topic)
31
+ queue(topic).clear
32
+ end
33
+
34
+ #
35
+ # Clear all messages across all topics.
36
+ #
37
+ # @param [String] name The topic to clear.
38
+ #
39
+ def clear_all
40
+ @queues&.values&.each { |e| e.clear }
41
+ end
42
+
43
+ #
44
+ # Publish a message to a topic.
45
+ #
46
+ # @param [String] topic The name of the topic
47
+ # @param [Hash, String] payload The message content.
48
+ # @param [Hash] attrs The message attributes.
49
+ #
50
+ # @return [Cloudenvoy::Message] The created message.
51
+ #
52
+ def publish(topic, payload, metadata = {})
53
+ msg = Message.new(
54
+ id: SecureRandom.uuid,
55
+ payload: payload,
56
+ metadata: metadata,
57
+ topic: topic
58
+ )
59
+ queue(topic).push(msg)
60
+
61
+ msg
62
+ end
63
+
64
+ #
65
+ # Create or update a subscription for a specific topic.
66
+ #
67
+ # @param [String] topic The name of the topic
68
+ # @param [String] name The name of the subscription
69
+ #
70
+ # @return [Cloudenvoy::Subscription] The upserted subscription.
71
+ #
72
+ def upsert_subscription(_topic, name)
73
+ Subscription.new(name: name)
74
+ end
75
+
76
+ #
77
+ # Create or update a topic.
78
+ #
79
+ # @param [String] topic The topic name.
80
+ #
81
+ # @return [Cloudenvoy::Topic] The upserted topic.
82
+ #
83
+ def upsert_topic(topic)
84
+ Topic.new(name: topic)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Cloudenvoy
6
+ # Holds cloudenvoy configuration. See Cloudenvoy#configure
7
+ class Config
8
+ attr_writer :secret, :gcp_project_id,
9
+ :gcp_sub_prefix, :processor_path, :logger, :mode
10
+
11
+ # Emulator host
12
+ EMULATOR_HOST = ENV['PUBSUB_EMULATOR_HOST'] || 'localhost:8085'
13
+
14
+ # Default application path used for processing messages
15
+ DEFAULT_PROCESSOR_PATH = '/cloudenvoy/receive'
16
+
17
+ PROCESSOR_HOST_MISSING = <<~DOC
18
+ Missing host for processing.
19
+ Please specify a processor hostname in form of `https://some-public-dns.example.com`'
20
+ DOC
21
+ SUB_PREFIX_MISSING_ERROR = <<~DOC
22
+ Missing GCP subscription prefix.
23
+ Please specify a subscription prefix in the form of `my-app`.
24
+ DOC
25
+ PROJECT_ID_MISSING_ERROR = <<~DOC
26
+ Missing GCP project ID.
27
+ Please specify a project ID in the cloudenvoy configurator.
28
+ DOC
29
+ SECRET_MISSING_ERROR = <<~DOC
30
+ Missing cloudenvoy secret.
31
+ Please specify a secret in the cloudenvoy initializer or add Rails secret_key_base in your credentials
32
+ DOC
33
+
34
+ #
35
+ # The operating mode.
36
+ # - :production => send messages to GCP Pub/Sub
37
+ # - :development => send message to gcloud CLI Pub/Sub emulator
38
+ #
39
+ # @return [<Type>] <description>
40
+ #
41
+ def mode
42
+ @mode ||= environment == 'development' ? :development : :production
43
+ end
44
+
45
+ #
46
+ # Return the current environment.
47
+ #
48
+ # @return [String] The environment name.
49
+ #
50
+ def environment
51
+ ENV['CLOUDENVOY_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
52
+ end
53
+
54
+ #
55
+ # Return the Cloudenvoy logger.
56
+ #
57
+ # @return [Logger, any] The cloudenvoy logger.
58
+ #
59
+ def logger
60
+ @logger ||= defined?(Rails) ? Rails.logger : ::Logger.new(STDOUT)
61
+ end
62
+
63
+ #
64
+ # Return the full URL of the processor. Message payloads will be sent
65
+ # to this URL.
66
+ #
67
+ # @return [String] The processor URL.
68
+ #
69
+ def processor_url
70
+ File.join(processor_host, processor_path)
71
+ end
72
+
73
+ #
74
+ # Set the processor host. In the context of Rails the host will
75
+ # also be added to the list of authorized Rails hosts.
76
+ #
77
+ # @param [String] val The processor host to set.
78
+ #
79
+ def processor_host=(val)
80
+ @processor_host = val
81
+
82
+ # Check if Rails supports host filtering
83
+ return unless val &&
84
+ defined?(Rails) &&
85
+ Rails.application.config.respond_to?(:hosts) &&
86
+ Rails.application.config.hosts&.any?
87
+
88
+ # Add processor host to the list of authorized hosts
89
+ Rails.application.config.hosts << val.gsub(%r{https?://}, '')
90
+ end
91
+
92
+ #
93
+ # The hostname of the application processing the messages. The hostname must
94
+ # be reachable from Cloud Pub/Sub.
95
+ #
96
+ # @return [String] The processor host.
97
+ #
98
+ def processor_host
99
+ @processor_host || raise(StandardError, PROCESSOR_HOST_MISSING)
100
+ end
101
+
102
+ #
103
+ # The path on the host when message payloads will be sent.
104
+ # Default to `/cloudenvoy/receive`
105
+ #
106
+ #
107
+ # @return [String] The processor path
108
+ #
109
+ def processor_path
110
+ @processor_path || DEFAULT_PROCESSOR_PATH
111
+ end
112
+
113
+ #
114
+ # Return the prefix used for queues.
115
+ #
116
+ # @return [String] The prefix used when creating subscriptions.
117
+ #
118
+ def gcp_sub_prefix
119
+ @gcp_sub_prefix || raise(StandardError, SUB_PREFIX_MISSING_ERROR)
120
+ end
121
+
122
+ #
123
+ # Return the GCP project ID.
124
+ #
125
+ # @return [String] The ID of the project where pub/sub messages are hosted.
126
+ #
127
+ def gcp_project_id
128
+ @gcp_project_id || raise(StandardError, PROJECT_ID_MISSING_ERROR)
129
+ end
130
+
131
+ #
132
+ # Return the secret to use to sign the verification tokens
133
+ # attached to messages.
134
+ #
135
+ # @return [String] The cloudenvoy secret
136
+ #
137
+ def secret
138
+ @secret || (
139
+ defined?(Rails) && Rails.application.credentials&.dig(:secret_key_base)
140
+ ) || raise(StandardError, SECRET_MISSING_ERROR)
141
+ end
142
+
143
+ #
144
+ # Return the chain of publisher middlewares.
145
+ #
146
+ # @return [Cloudenvoy::Middleware::Chain] The chain of middlewares.
147
+ #
148
+ def publisher_middleware
149
+ @publisher_middleware ||= Middleware::Chain.new
150
+ yield @publisher_middleware if block_given?
151
+ @publisher_middleware
152
+ end
153
+
154
+ #
155
+ # Return the chain of subscriber middlewares.
156
+ #
157
+ # @return [Cloudenvoy::Middleware::Chain] The chain of middlewares.
158
+ #
159
+ def subscriber_middleware
160
+ @subscriber_middleware ||= Middleware::Chain.new
161
+ yield @subscriber_middleware if block_given?
162
+ @subscriber_middleware
163
+ end
164
+ end
165
+ end