nulogy_message_bus_producer 2.1.1 → 3.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +32 -16
  3. data/db/migrate/20201005150212_rename_tenant_id_and_public.rb +6 -0
  4. data/lib/nulogy_message_bus_producer.rb +40 -15
  5. data/lib/nulogy_message_bus_producer/{base_public_subscription.rb → base_subscription.rb} +1 -1
  6. data/lib/nulogy_message_bus_producer/config.rb +6 -0
  7. data/lib/nulogy_message_bus_producer/repopulate_replication_slots.rb +2 -2
  8. data/lib/nulogy_message_bus_producer/subscriber_graphql_schema_validator.rb +1 -1
  9. data/lib/nulogy_message_bus_producer/{public_subscription.rb → subscription.rb} +1 -2
  10. data/lib/nulogy_message_bus_producer/{public_subscription_event.rb → subscription_event.rb} +1 -1
  11. data/lib/nulogy_message_bus_producer/subscriptions/postgres_transport.rb +83 -0
  12. data/lib/nulogy_message_bus_producer/subscriptions/risky_subscription_blocker.rb +58 -0
  13. data/lib/nulogy_message_bus_producer/version.rb +1 -1
  14. data/spec/dummy/db/migrate/20201005164116_create_active_storage_tables.active_storage.rb +5 -0
  15. data/spec/dummy/db/migrate/20201005164117_create_public_subscriptions_and_events_tables.nulogy_message_bus_producer.rb +26 -0
  16. data/spec/dummy/db/migrate/20201005164141_rename_tenant_id_and_public.nulogy_message_bus_producer.rb +7 -0
  17. data/spec/dummy/db/schema.rb +3 -5
  18. data/spec/dummy/log/development.log +198 -0
  19. data/spec/dummy/log/test.log +38894 -0
  20. data/spec/integration/lib/nulogy_message_bus_producer/repopulate_replication_slots_spec.rb +4 -4
  21. data/spec/integration/lib/nulogy_message_bus_producer/subscriber_graphql_schema_validator_spec.rb +7 -17
  22. data/spec/integration/lib/nulogy_message_bus_producer/subscription_spec.rb +63 -0
  23. data/spec/integration/lib/nulogy_message_bus_producer/subscriptions/postgres_transport_spec.rb +138 -0
  24. data/spec/integration/lib/nulogy_message_bus_producer/subscriptions/risky_subscription_blocker_spec.rb +51 -0
  25. data/spec/integration_spec_helper.rb +1 -0
  26. data/spec/support/sql_helpers.rb +2 -2
  27. data/spec/support/subscription_helpers.rb +44 -16
  28. data/spec/support/test_graphql_schema.rb +17 -13
  29. metadata +38 -16
  30. data/lib/nulogy_message_bus_producer/postgres_public_subscriptions.rb +0 -117
  31. data/spec/integration/lib/nulogy_message_bus_producer/postgres_public_subscriptions_spec.rb +0 -83
  32. data/spec/unit/lib/nulogy_message_bus_producer/public_subscription_spec.rb +0 -62
  33. data/spec/unit_spec_helper.rb +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4cc826f5c2b4bf8ebf1e9e97b3dee97cbd923c1d954d73100755f3497837e28
4
- data.tar.gz: a59dae8e534e69b9b0aba0b5e572a6d98c75510e77b5d6b763bc8a80ca1e3e56
3
+ metadata.gz: 3f7851fbbe2f63a88307bc6f8c735b78d998a576e8c0beeb3feb2497171a341f
4
+ data.tar.gz: 8fb164dd469b34498f4b205277811527507930d24ae56202451ab31758a87c58
5
5
  SHA512:
6
- metadata.gz: a72e3b5e4147f8d7db19830bb09ccc97ddb07e5e659c6c1fb34c64edf867fd3c3ae6a3aa2b8942bc312bb8633831cf13d5d9b19fd5559d808330998c3d96e079
7
- data.tar.gz: 7ea5411883b1bc0acbb92f6625928c94118d73c613bf763a8898d4d2db6c6fa4662fb00ea5cca11c9da88120a201c0cc38ee0e98bd9d3a2d22d4e55f93b6b5dc
6
+ metadata.gz: c42247256982eff8fbf1a93900fa24deb61094e33d46387737e44380d29b36bba66ab61c2df2d7cbcd46610fdf7e3a76494e395b7ecca1aba8170c43a2baeca6
7
+ data.tar.gz: 14235e2f752cf55ee5a1499d9f1aedde3267124a40c662a08bee80abcc42ed1373a5539f2607c127fbf80aa976b6171575c51283b57f37d974680a6b5eef4186
data/README.md CHANGED
@@ -11,7 +11,7 @@ Nulogy's code for producing to to the Message Bus
11
11
 
12
12
  This engine contains the classes and validations for producing messages to the Message Bus.
13
13
 
14
- In general, as long as a subscription inherits from `NulogyMessageBusProducer::BasePublicSubscription`, everything
14
+ In general, as long as a subscription inherits from `NulogyMessageBusProducer::BaseSubscription`, everything
15
15
  should be set up.
16
16
 
17
17
  Subscriptions will be written to the `NulogyMessageBusProducer.subscriptions_table_name` while events generated from the subscriptions will be written to
@@ -19,10 +19,12 @@ Subscriptions will be written to the `NulogyMessageBusProducer.subscriptions_tab
19
19
 
20
20
  ## Configuration
21
21
 
22
- | Configuration | Default | Description |
23
- |--------------------------------|---------------------------------|--------------------------------------------------------------|
24
- | subscriptions_table_name | message_bus_subscriptions | The name of the table for the PublicSubscription model |
25
- | subscription_events_table_name | message_bus_subscription_events | The name of the table for the PublicSubscriptionEvents model |
22
+ | Configuration | Default | Description |
23
+ |--------------------------------|---------------------------------|---------------------------------------------------------------------------------------------|
24
+ | context_for_subscription | `->(_subscription) { {} }` | A lambda used to inject any GraphQL Context to be used when producing subscription events |
25
+ | producing_events_fails_with | :raise | How the producer should handle errors when producing subscription events |
26
+ | subscriptions_table_name | "message_bus_subscriptions" | The name of the table for the Subscription model |
27
+ | subscription_events_table_name | "message_bus_subscription_events" | The name of the table for the SubscriptionEvents model |
26
28
 
27
29
  ## Setup
28
30
 
@@ -30,13 +32,20 @@ You should configure the gem with an initializer. A recommended setup:
30
32
 
31
33
  ```ruby
32
34
  # Table names are configurable (optional)
33
- NulogyMessageBusProducer.subscriptions_table_name = :public_subscriptions
34
- NulogyMessageBusProducer.subscription_events_table_name = :public_subscription_events
35
+ NulogyMessageBusProducer.subscriptions_table_name = :message_bus_subscriptions
36
+ NulogyMessageBusProducer.subscription_events_table_name = :message_bus_subscription_events
35
37
 
36
38
  # Register known schemas
37
39
  NulogyMessageBusProducer.configure do |config|
38
40
  config.register_schema(schema: "ExampleDomain::Schema", key: "exampleDomain")
39
-
41
+
42
+ config.context_for_subscription = lambda do |_subscription|
43
+ {
44
+ current_user: Current.user,
45
+ current_company: Current.company
46
+ }
47
+ end
48
+
40
49
  # Can fail with either :soft_fail or :raise
41
50
  # :raise will cause the application to halt when event generation fails.
42
51
  # :soft_fail will allow the application to continue, even if generating events fails.
@@ -58,17 +67,24 @@ NulogyMessageBusProducer.configure do |config|
58
67
  end
59
68
 
60
69
  # It is *strongly* recommended to run this validation
61
- # It ensures the query in a subscription is valid agains the current schema
70
+ # It ensures the query in a subscription is valid against the current schema
62
71
  # If you do not run this, a change to the schema could break the app
63
72
  # Note: ensure to register schemas before running this
64
73
  NulogyMessageBusProducer.validate_existing!
65
74
  ```
66
75
 
67
- Additionally, your GraphQL Schema will need to use `NulogyMessageBusProducer::PostgresPublicSubscriptions` and expose subscriptions and queries for the objects to be integrated. Below is an example of how to expose `:sku_created` and `:sku_updated` events.
76
+ Additionally, your GraphQL Schema will need to use `NulogyMessageBusProducer::Subscriptions::PostgresTransport`, `NulogyMessageBusProducer::Subscriptions::RiskySubscriptionBlocker` and expose subscriptions and queries for the objects to be integrated. Below is an example of how to expose `:sku_created` and `:sku_updated` events.
68
77
  ```ruby
69
78
  module ExampleDomainPublicApi
70
79
  class Schema < GraphQL::Schema
71
- use NulogyMessageBusProducer::PostgresPublicSubscriptions
80
+ use NulogyMessageBusProducer::Subscriptions::PostgresTransport
81
+
82
+ # This prevents subscriptions which contain arguments or expand lists form being registered.
83
+ # Expanding unbounded lists inside of an event may severely impact the performance of the system.
84
+ # Arguments are a source of variation that may not be appropriately tested.
85
+ # For now, these are banned until we have a use-case which would require them.
86
+ use GraphQL::Analysis::AST
87
+ query_analyzer NulogyMessageBusProducer::Subscriptions::RiskySubscriptionBlocker
72
88
 
73
89
  query Types::QueryType
74
90
  subscription Types::SubscriptionType
@@ -111,7 +127,7 @@ end
111
127
 
112
128
  module ExampleDomainPublicApi
113
129
  module Subscriptions
114
- class SkuCreated < NulogyMessageBusProducer::BasePublicSubscription
130
+ class SkuCreated < NulogyMessageBusProducer::BaseSubscription
115
131
  field :sku, Types::SkuType, null: false
116
132
  end
117
133
  end
@@ -119,7 +135,7 @@ end
119
135
 
120
136
  module ExampleDomainPublicApi
121
137
  module Subscriptions
122
- class SkuUpdated < NulogyMessageBusProducer::BasePublicSubscription
138
+ class SkuUpdated < NulogyMessageBusProducer::BaseSubscription
123
139
  field :sku, Types::SkuType, null: false
124
140
  end
125
141
  end
@@ -160,11 +176,11 @@ class CreateSku
160
176
  def trigger_subscription(event, entity)
161
177
  object = {
162
178
  uuid: entity.id,
163
- tenant_id: entity.company_id,
179
+ company_uuid: entity.company_id,
164
180
  context: {}
165
181
  }
166
-
167
- ExampleDomainPublicApi::Schema.subscriptions.trigger(event, {}, object)
182
+
183
+ NulogyMessageBusProducer.trigger_event(ExampleDomainPublicApi::Schema, event, object)
168
184
  end
169
185
  end
170
186
  ```
@@ -0,0 +1,6 @@
1
+ class RenameTenantIdAndPublic < ActiveRecord::Migration[5.2]
2
+ def change
3
+ rename_column NulogyMessageBusProducer.subscription_events_table_name, :tenant_id, :company_uuid
4
+ rename_column NulogyMessageBusProducer.subscription_events_table_name, :public_subscription_id, :subscription_id
5
+ end
6
+ end
@@ -14,24 +14,50 @@ module NulogyMessageBusProducer
14
14
  end
15
15
 
16
16
  def self.subscriptions_table_name=(table_name)
17
- NulogyMessageBusProducer::PublicSubscription.table_name = table_name
17
+ NulogyMessageBusProducer::Subscription.table_name = table_name
18
18
  end
19
19
 
20
20
  def self.subscriptions_table_name
21
- NulogyMessageBusProducer::PublicSubscription.table_name
21
+ NulogyMessageBusProducer::Subscription.table_name
22
22
  end
23
23
 
24
24
  def self.subscription_events_table_name=(table_name)
25
- NulogyMessageBusProducer::PublicSubscriptionEvent.table_name = table_name
25
+ NulogyMessageBusProducer::SubscriptionEvent.table_name = table_name
26
26
  end
27
27
 
28
28
  def self.subscription_events_table_name
29
- NulogyMessageBusProducer::PublicSubscriptionEvent.table_name
29
+ NulogyMessageBusProducer::SubscriptionEvent.table_name
30
+ end
31
+
32
+ def self.context_for_subscription(subscription)
33
+ config.context_for_subscription(subscription)
34
+ end
35
+
36
+ def self.trigger_event(schema, event_type, root_object)
37
+ schema_key = NulogyMessageBusProducer.resolve_schema_key(schema)
38
+
39
+ subscriptions = Subscription.where(
40
+ event_type: event_type,
41
+ schema_key: schema_key
42
+ )
43
+
44
+ subscriptions.each do |subscription|
45
+ args = {
46
+ subscriptionId: subscription.id,
47
+ subscriptionGroupId: subscription.subscription_group_id,
48
+ topicName: subscription.topic_name
49
+ }
50
+ schema.subscriptions.trigger(event_type, args, root_object)
51
+ end
30
52
  end
31
53
 
32
54
  def self.resolve_schema(schema_key)
33
- config.registered_schemas.fetch(schema_key) do
34
- raise KeyError, <<~MESSAGE unless block_given?
55
+ if config.registered_schemas.key?(schema_key)
56
+ config.registered_schemas[schema_key].constantize
57
+ elsif block_given?
58
+ yield
59
+ else
60
+ raise KeyError, <<~MESSAGE
35
61
  The schema registry did not contain an entry for the schema key '#{schema_key}'.
36
62
 
37
63
  Please register the schema first with `NulogyMessageBusProducer.schemas.register(schema_key, schema)`
@@ -39,9 +65,7 @@ module NulogyMessageBusProducer
39
65
  Schemas Registered:
40
66
  #{config.registered_schemas.pretty_inspect}
41
67
  MESSAGE
42
-
43
- yield
44
- end.constantize
68
+ end
45
69
  end
46
70
 
47
71
  def self.resolve_schema_key(schema)
@@ -67,7 +91,7 @@ module NulogyMessageBusProducer
67
91
  raise <<~MESSAGE
68
92
  #{"#" * 80}
69
93
 
70
- The GraphQL queries stored in public_subscriptions.query must always remain valid against the current schema.
94
+ The GraphQL queries stored in subscriptions.query must always remain valid against the current schema.
71
95
  If one is not valid, a domain event firing could fail and users would not be able to perform critical duties
72
96
  (e.g. adding production).
73
97
 
@@ -106,7 +130,7 @@ module NulogyMessageBusProducer
106
130
  FROM pg_tables
107
131
  WHERE tablename = '#{NulogyMessageBusProducer.subscriptions_table_name}'
108
132
  AND schemaname = 'public';
109
- SQL
133
+ SQL
110
134
  rescue # rubocop:disable Style/RescueStandardError
111
135
  false
112
136
  end
@@ -115,9 +139,10 @@ module NulogyMessageBusProducer
115
139
  end
116
140
 
117
141
  require "nulogy_message_bus_producer/application_record"
118
- require "nulogy_message_bus_producer/base_public_subscription"
119
- require "nulogy_message_bus_producer/postgres_public_subscriptions"
120
- require "nulogy_message_bus_producer/public_subscription"
121
- require "nulogy_message_bus_producer/public_subscription_event"
142
+ require "nulogy_message_bus_producer/base_subscription"
143
+ require "nulogy_message_bus_producer/subscription"
144
+ require "nulogy_message_bus_producer/subscription_event"
122
145
  require "nulogy_message_bus_producer/repopulate_replication_slots"
123
146
  require "nulogy_message_bus_producer/subscriber_graphql_schema_validator"
147
+ require "nulogy_message_bus_producer/subscriptions/postgres_transport"
148
+ require "nulogy_message_bus_producer/subscriptions/risky_subscription_blocker"
@@ -5,7 +5,7 @@ module NulogyMessageBusProducer
5
5
  # class AggregateRootCreated < NulogyMessageBusProducer::BasePublicSubscription
6
6
  # field :aggregate_root, Domain::Public::Types::AggregateRootType, null: true
7
7
  # end
8
- class BasePublicSubscription < GraphQL::Schema::Subscription
8
+ class BaseSubscription < GraphQL::Schema::Subscription
9
9
  argument :subscription_id, ID, required: true
10
10
  argument :subscription_group_id, ID, required: true
11
11
  argument :topic_name, String, required: true
@@ -5,6 +5,7 @@ module NulogyMessageBusProducer
5
5
  FAILURE_MODES = [:raise, :soft_fail].freeze
6
6
 
7
7
  attr_reader :registered_schemas
8
+ attr_writer :context_for_subscription
8
9
 
9
10
  def initialize(options = {})
10
11
  @registered_schemas = {}
@@ -13,6 +14,11 @@ module NulogyMessageBusProducer
13
14
  update(options)
14
15
  end
15
16
 
17
+ def context_for_subscription(subscription)
18
+ @context_for_subscription ||= ->(_) {}
19
+ @context_for_subscription.call(subscription)
20
+ end
21
+
16
22
  def register_schema(schema:, key:)
17
23
  @registered_schemas[key] = schema
18
24
  end
@@ -1,8 +1,8 @@
1
1
  module NulogyMessageBusProducer
2
2
  module RepopulateReplicationSlots
3
3
  def self.repopulate
4
- table_name = NulogyMessageBusProducer::PublicSubscriptionEvent.table_name
5
- temp_table_name = "#{NulogyMessageBusProducer::PublicSubscriptionEvent.table_name}_tmp_event_repopulation"
4
+ table_name = NulogyMessageBusProducer::SubscriptionEvent.table_name
5
+ temp_table_name = "#{NulogyMessageBusProducer::SubscriptionEvent.table_name}_tmp_event_repopulation"
6
6
 
7
7
  ActiveRecord::Base.connection.execute(<<~SQL)
8
8
  BEGIN;
@@ -13,7 +13,7 @@ module NulogyMessageBusProducer
13
13
  @errors = []
14
14
  end
15
15
 
16
- def validate(subscription_or_subscriptions = NulogyMessageBusProducer::PublicSubscription.all)
16
+ def validate(subscription_or_subscriptions = NulogyMessageBusProducer::Subscription.all)
17
17
  Array(subscription_or_subscriptions).each do |subscription|
18
18
  schema = find_schema(subscription)
19
19
  next unless schema
@@ -1,7 +1,7 @@
1
1
  module NulogyMessageBusProducer
2
2
  # This model saves all subscriptions to external systems.
3
3
  # An external system can subscribe to events and specify the shape of data it would like to receive for the event.
4
- class PublicSubscription < ApplicationRecord
4
+ class Subscription < ApplicationRecord
5
5
  self.table_name = :message_bus_subscriptions
6
6
 
7
7
  # Run our validator with familar syntax in this model
@@ -12,7 +12,6 @@ module NulogyMessageBusProducer
12
12
  validator = NulogyMessageBusProducer::SubscriberGraphqlSchemaValidator.new
13
13
 
14
14
  validator.validate(record)
15
-
16
15
  validator.errors.each { |e| record.errors.add(attribute, e) }
17
16
  end
18
17
  end
@@ -1,7 +1,7 @@
1
1
  module NulogyMessageBusProducer
2
2
  # A model that contains the event data for a particular subscription
3
3
  # It is simply saved in the database and shipped to Kafka by Debezium
4
- class PublicSubscriptionEvent < ApplicationRecord
4
+ class SubscriptionEvent < ApplicationRecord
5
5
  self.table_name = :message_bus_subscription_events
6
6
 
7
7
  def self.create_or_update(attrs)
@@ -0,0 +1,83 @@
1
+ module NulogyMessageBusProducer
2
+ module Subscriptions
3
+ # Subscription class to `use` when developing Message Bus-backed subscriptions
4
+ # For example,
5
+ #
6
+ # class SomeSchema < GraphQL::Schema
7
+ # ...
8
+ # use NulogyMessageBusProducer::PostgresPublicSubscriptions
9
+ # end
10
+ #
11
+ # It expects that schema to already be registered, or will raise an error.
12
+ #
13
+ # NulogyMessageBusProducer.register_schema("some_schema", "SomeSchema")
14
+ class PostgresTransport < GraphQL::Subscriptions
15
+ def initialize(options = {})
16
+ super
17
+ @schema_key = NulogyMessageBusProducer.resolve_schema_key(options.fetch(:schema))
18
+ end
19
+
20
+ def each_subscription_id(event)
21
+ yield event.arguments["subscriptionId"]
22
+ end
23
+
24
+ def read_subscription(subscription_id)
25
+ subscription = Subscription.find(subscription_id)
26
+ context = NulogyMessageBusProducer.context_for_subscription(subscription)
27
+
28
+ {
29
+ context: context,
30
+ operation_name: nil,
31
+ query_string: subscription.query,
32
+ variables: {},
33
+ }
34
+ end
35
+
36
+ def deliver(subscription_id, result)
37
+ if result["errors"]&.any?
38
+ NulogyMessageBusProducer.config.handle_event_generation_error(
39
+ subscription_id: subscription_id,
40
+ context: result.query.context.object,
41
+ variables: result.query.provided_variables,
42
+ result: result
43
+ )
44
+ else
45
+ create_event(subscription_id, result)
46
+ end
47
+ end
48
+
49
+ def write_subscription(query, events)
50
+ events.each do |event|
51
+ Subscription.create_or_update(
52
+ id: event.arguments["subscriptionId"],
53
+ subscription_group_id: event.arguments["subscriptionGroupId"],
54
+ event_type: event.name,
55
+ schema_key: @schema_key,
56
+ query: query.query_string,
57
+ topic_name: event.arguments["topicName"]
58
+ )
59
+ end
60
+ end
61
+
62
+ def delete_subscription(subscription_id)
63
+ Subscription.find_by(id: subscription_id).destroy
64
+ end
65
+
66
+ private
67
+
68
+ def create_event(subscription_id, result)
69
+ company_uuid = result.query.context.object[:company_uuid]
70
+ subscription = Subscription.find_by(id: subscription_id)
71
+
72
+ SubscriptionEvent.create_or_update(
73
+ id: SecureRandom.uuid,
74
+ subscription_id: subscription_id,
75
+ partition_key: "#{subscription.subscription_group_id},#{company_uuid}",
76
+ company_uuid: company_uuid,
77
+ event_json: result.to_h["data"],
78
+ topic_name: subscription.topic_name
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,58 @@
1
+ module NulogyMessageBusProducer
2
+ module Subscriptions
3
+ class RiskySubscriptionBlocker < GraphQL::Analysis::AST::Analyzer
4
+ def initialize(query_or_multiplex)
5
+ super
6
+ @nodes_using_arguments = Set.new
7
+ @nodes_are_lists = Set.new
8
+ end
9
+
10
+ PERMITTED_ARGUMENTS = Set.new(%w[subscriptionId subscriptionGroupId topicName])
11
+
12
+ def analyze?
13
+ subject.subscription?
14
+ end
15
+
16
+ def on_enter_argument(argument, parent, _visitor)
17
+ @nodes_using_arguments << parent unless PERMITTED_ARGUMENTS.include?(argument.name)
18
+ end
19
+
20
+ def on_enter_field(node, _parent, visitor)
21
+ @nodes_are_lists << node if list?(node, visitor)
22
+ end
23
+
24
+ def result
25
+ error_messages = []
26
+
27
+ if @nodes_using_arguments.any?
28
+ node_names = @nodes_using_arguments.map(&:name).join("\n")
29
+ error_messages << "Arguments may not be used:\n#{node_names}"
30
+ end
31
+
32
+ if @nodes_are_lists.any?
33
+ node_names = @nodes_are_lists.map(&:name).join("\n")
34
+ error_messages << "Lists may not be queried:\n#{node_names}"
35
+ end
36
+
37
+ GraphQL::AnalysisError.new(error_messages.join("\n\n")) if error_messages.any?
38
+ end
39
+
40
+ private
41
+
42
+ def list?(node, visitor)
43
+ field_definition = visitor.field_definition
44
+ nested_type = field_definition.type_class.type
45
+
46
+ loop do
47
+ @nodes_are_lists << node if nested_type.is_a?(GraphQL::Schema::List)
48
+
49
+ if nested_type.respond_to?(:of_type)
50
+ nested_type = nested_type.of_type
51
+ else
52
+ break
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end