nulogy_message_bus_producer 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +35 -0
  3. data/Rakefile +22 -0
  4. data/db/migrate/20200611150212_create_public_subscriptions_and_events_tables.rb +25 -0
  5. data/lib/nulogy_message_bus_producer.rb +112 -0
  6. data/lib/nulogy_message_bus_producer/application_record.rb +6 -0
  7. data/lib/nulogy_message_bus_producer/base_public_subscription.rb +13 -0
  8. data/lib/nulogy_message_bus_producer/engine.rb +6 -0
  9. data/lib/nulogy_message_bus_producer/postgres_public_subscriptions.rb +102 -0
  10. data/lib/nulogy_message_bus_producer/public_subscription.rb +27 -0
  11. data/lib/nulogy_message_bus_producer/public_subscription_event.rb +13 -0
  12. data/lib/nulogy_message_bus_producer/subscriber_graphql_schema_validator.rb +45 -0
  13. data/lib/nulogy_message_bus_producer/version.rb +3 -0
  14. data/spec/dummy/Rakefile +6 -0
  15. data/spec/dummy/app/assets/config/manifest.js +4 -0
  16. data/spec/dummy/app/assets/javascripts/application.js +13 -0
  17. data/spec/dummy/app/assets/javascripts/cable.js +13 -0
  18. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  19. data/spec/dummy/app/channels/application_cable/channel.rb +4 -0
  20. data/spec/dummy/app/channels/application_cable/connection.rb +4 -0
  21. data/spec/dummy/app/controllers/application_controller.rb +7 -0
  22. data/spec/dummy/app/controllers/dummy_controller.rb +17 -0
  23. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  24. data/spec/dummy/app/jobs/application_job.rb +2 -0
  25. data/spec/dummy/app/mailers/application_mailer.rb +4 -0
  26. data/spec/dummy/app/models/application_record.rb +3 -0
  27. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  28. data/spec/dummy/app/views/layouts/mailer.html.erb +13 -0
  29. data/spec/dummy/app/views/layouts/mailer.text.erb +1 -0
  30. data/spec/dummy/bin/bundle +3 -0
  31. data/spec/dummy/bin/rails +4 -0
  32. data/spec/dummy/bin/rake +4 -0
  33. data/spec/dummy/bin/setup +38 -0
  34. data/spec/dummy/bin/update +29 -0
  35. data/spec/dummy/bin/yarn +11 -0
  36. data/spec/dummy/config.ru +5 -0
  37. data/spec/dummy/config/application.rb +22 -0
  38. data/spec/dummy/config/boot.rb +5 -0
  39. data/spec/dummy/config/cable.yml +10 -0
  40. data/spec/dummy/config/database.yml +22 -0
  41. data/spec/dummy/config/environment.rb +5 -0
  42. data/spec/dummy/config/environments/development.rb +54 -0
  43. data/spec/dummy/config/environments/production.rb +91 -0
  44. data/spec/dummy/config/environments/test.rb +42 -0
  45. data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
  46. data/spec/dummy/config/initializers/assets.rb +14 -0
  47. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  48. data/spec/dummy/config/initializers/cookies_serializer.rb +5 -0
  49. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  50. data/spec/dummy/config/initializers/inflections.rb +16 -0
  51. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  52. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  53. data/spec/dummy/config/locales/en.yml +33 -0
  54. data/spec/dummy/config/puma.rb +56 -0
  55. data/spec/dummy/config/routes.rb +6 -0
  56. data/spec/dummy/config/secrets.yml +32 -0
  57. data/spec/dummy/config/spring.rb +6 -0
  58. data/spec/dummy/db/schema.rb +42 -0
  59. data/spec/dummy/log/development.log +1771 -0
  60. data/spec/dummy/log/test.log +14189 -0
  61. data/spec/dummy/package.json +5 -0
  62. data/spec/dummy/public/404.html +67 -0
  63. data/spec/dummy/public/422.html +67 -0
  64. data/spec/dummy/public/500.html +66 -0
  65. data/spec/dummy/public/apple-touch-icon-precomposed.png +0 -0
  66. data/spec/dummy/public/apple-touch-icon.png +0 -0
  67. data/spec/dummy/public/favicon.ico +0 -0
  68. data/spec/integration/lib/graphql_api/postgres_public_subscriptions_spec.rb +16 -0
  69. data/spec/integration/lib/graphql_api/validators/subscriber_graphql_schema_validator_spec.rb +76 -0
  70. data/spec/integration_spec_helper.rb +35 -0
  71. data/spec/spec_helper.rb +60 -0
  72. data/spec/unit/lib/graphql_api/models/public_subscription_spec.rb +56 -0
  73. data/spec/unit_spec_helper.rb +6 -0
  74. metadata +342 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d983bc5429dfc09c434c773bcd93d71a6bb26fe72a14fe16c95a2d0819f1e540
4
+ data.tar.gz: 15b57a37e9a6975ef474826fb2156071d19f6b95c7d800e98ea39de36908ad80
5
+ SHA512:
6
+ metadata.gz: 5b7971fa352fbfbbf7ec0d2f8f190eb9901a7dccdb76b121b8a20459f231e76d89b59994f29a66c90b713458b6c5e9367559a5663af78b38ef5bc09728663d68
7
+ data.tar.gz: 37e525876eedc66d78d3b22d5b9f47f610bfe7e08f352054cd194d50096740ab16058c1dcb4e2a260a4de2b55fe9a77bfd995162a9772c0991eb0ab4246f7151
@@ -0,0 +1,35 @@
1
+ # Nulogy Message Bus Producer
2
+
3
+ Nulogy's code for producing to to the Message Bus
4
+
5
+ ## Installation
6
+
7
+ 1. Add the gem to your Gemfile `gem "nulogy_message_bus_producer"`
8
+ 2. Install the migration `rake railties:install:migrations`
9
+
10
+ ## Usage
11
+
12
+ This engine contains the classes and validations for producing messages to the Message Bus.
13
+
14
+ In general, as long as a subscription inherits from `NulogyMessageBusProducer::BasePublicSubscription`, everything
15
+ should be set up.
16
+
17
+ Subscriptions will be written to `public_subscriptions` while events generated from the subscriptions will be written to
18
+ `public_subscription_events`. Debezium then reads this table from the PG replication log and pushes them into Kafka.
19
+
20
+ ## Configuration
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 |
26
+
27
+ ## Initializer
28
+
29
+ There is an initializer (see `engine.rb`) that validates if the current `public_subscriptions.query` queries are valid
30
+ against their appropriate GraphQL schema.
31
+
32
+ ## Rake Tasks
33
+
34
+ `rake spec` - Run the specs
35
+ `rake rubocop` - Run RuboCop
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require "bundler/setup"
4
+ rescue LoadError
5
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
6
+ end
7
+
8
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
9
+ load "rails/tasks/engine.rake"
10
+
11
+ Bundler::GemHelper.install_tasks
12
+
13
+ require "rspec/core"
14
+ require "rspec/core/rake_task"
15
+
16
+ RSpec::Core::RakeTask.new(:spec)
17
+
18
+ require "rubocop/rake_task"
19
+
20
+ RuboCop::RakeTask.new
21
+
22
+ task default: [:spec, :rubocop]
@@ -0,0 +1,25 @@
1
+ class CreatePublicSubscriptionsAndEventsTables < ActiveRecord::Migration[5.2]
2
+ def change
3
+ # TODO: You may need to tweak this setup to suit your app's database
4
+ enable_extension "uuid-ossp"
5
+
6
+ create_table NulogyMessageBusProducer.subscriptions_table_name, id: :uuid, default: nil do |t|
7
+ t.uuid :subscription_group_id, null: false
8
+ t.string :event_type, null: false, index: { name: "index_nulogy_mb_producer_subscriptions_on_event_type" }
9
+ t.string :topic_name, null: false
10
+ t.string :query, null: false
11
+ t.text :schema_key, null: false
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ create_table NulogyMessageBusProducer.subscription_events_table_name, id: :uuid, default: nil do |t|
17
+ t.uuid :public_subscription_id, null: false
18
+ t.string :partition_key, null: false
19
+ t.string :topic_name, null: false
20
+ t.uuid :tenant_id, null: false
21
+ t.json :event_json, null: false
22
+ t.column :created_at, :datetime, index: { name: "index_nulogy_mb_producer_subscription_events_on_created_at" }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,112 @@
1
+ require "graphql"
2
+ require "strong_migrations"
3
+
4
+ require "nulogy_message_bus_producer/version"
5
+ require "nulogy_message_bus_producer/engine"
6
+
7
+ # The gem and its configuration
8
+ module NulogyMessageBusProducer
9
+ mattr_reader :registered_schemas, default: {}
10
+
11
+ def self.subscriptions_table_name=(table_name)
12
+ NulogyMessageBusProducer::PublicSubscription.table_name = table_name
13
+ end
14
+
15
+ def self.subscriptions_table_name
16
+ NulogyMessageBusProducer::PublicSubscription.table_name
17
+ end
18
+
19
+ def self.subscription_events_table_name=(table_name)
20
+ NulogyMessageBusProducer::PublicSubscriptionEvent.table_name = table_name
21
+ end
22
+
23
+ def self.subscription_events_table_name
24
+ NulogyMessageBusProducer::PublicSubscriptionEvent.table_name
25
+ end
26
+
27
+ def self.register_schema(schema_key, schema_name)
28
+ registered_schemas[schema_key] = schema_name
29
+ end
30
+
31
+ def self.resolve_schema(schema_key)
32
+ registered_schemas.fetch(schema_key) do
33
+ raise KeyError, <<~MESSAGE unless block_given?
34
+ The schema registry did not contain an entry for the schema key '#{schema_key}'.
35
+
36
+ Please register the schema first with `NulogyMessageBusProducer.schemas.register(schema_key, schema)`
37
+ MESSAGE
38
+
39
+ yield
40
+ end.constantize
41
+ end
42
+
43
+ def self.resolve_schema_key(schema)
44
+ registered_schemas.invert.fetch(schema.class.name) do
45
+ raise KeyError, <<~MESSAGE
46
+ The schema registry did not contain an entry for the schema '#{schema.class.name}'.
47
+
48
+ Please register the schema first with `NulogyMessageBusProducer.schemas.register(schema_key, schema)`
49
+ MESSAGE
50
+ end
51
+ end
52
+
53
+ def self.validate_existing!
54
+ return unless Db.exists? && Db.subscriptions_exist?
55
+
56
+ validator = NulogyMessageBusProducer::SubscriberGraphqlSchemaValidator.new
57
+
58
+ return if validator.validate
59
+
60
+ raise <<~MESSAGE
61
+ #{'#' * 80}
62
+
63
+ The GraphQL queries stored in public_subscriptions.query must always remain valid against the current schema.
64
+ If one is not valid, a domain event firing could fail and users would not be able to perform critical duties
65
+ (e.g. adding production).
66
+
67
+ Note that you may experience errors in different environments, e.g. NA fails but EU passes.
68
+ If you can quickly fix the data, it is recommended you fix the data and roll forward for the failing
69
+ environment.
70
+
71
+ If you need more time to plan or execute an action, it is advised that you rollback the deployment
72
+ for all environments so that they are consistent.
73
+
74
+ The following errors were found while validating those queries against its respective schema:
75
+
76
+ #{validator.errors.join("\n")}
77
+
78
+ #{'#' * 80}
79
+ MESSAGE
80
+ end
81
+
82
+ # A private helper module to check database connectivity and setup for the above initializer
83
+ module Db
84
+ module_function
85
+
86
+ def exists?
87
+ ActiveRecord::Base.connection
88
+ rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad
89
+ false
90
+ else
91
+ true
92
+ end
93
+
94
+ def subscriptions_exist?
95
+ ActiveRecord::Base
96
+ .connection
97
+ .exec_query(
98
+ "SELECT tablename FROM pg_tables WHERE tablename = '#{NulogyMessageBusProducer.subscriptions_table_name}' AND schemaname = 'public';"
99
+ ).present?
100
+ rescue # rubocop:disable Style/RescueStandardError
101
+ false
102
+ end
103
+ end
104
+ private_constant :Db
105
+ end
106
+
107
+ require "nulogy_message_bus_producer/application_record"
108
+ require "nulogy_message_bus_producer/base_public_subscription"
109
+ require "nulogy_message_bus_producer/postgres_public_subscriptions"
110
+ require "nulogy_message_bus_producer/public_subscription"
111
+ require "nulogy_message_bus_producer/public_subscription_event"
112
+ require "nulogy_message_bus_producer/subscriber_graphql_schema_validator"
@@ -0,0 +1,6 @@
1
+ module NulogyMessageBusProducer
2
+ # ApplicationRecord is a super-class of all your models to add shared behaviour
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
@@ -0,0 +1,13 @@
1
+ module NulogyMessageBusProducer
2
+ # This base class contains the fields required to create a subscription.
3
+ # For example, for a subscription to a model called AggregateRoot:
4
+ #
5
+ # class AggregateRootCreated < NulogyMessageBusProducer::BasePublicSubscription
6
+ # field :aggregate_root, Domain::Public::Types::AggregateRootType, null: true
7
+ # end
8
+ class BasePublicSubscription < GraphQL::Schema::Subscription
9
+ argument :subscription_id, ID, required: true
10
+ argument :subscription_group_id, ID, required: true
11
+ argument :topic_name, String, required: true
12
+ end
13
+ end
@@ -0,0 +1,6 @@
1
+ module NulogyMessageBusProducer
2
+ # The Rails engine
3
+ class Engine < Rails::Engine
4
+ isolate_namespace NulogyMessageBusProducer
5
+ end
6
+ end
@@ -0,0 +1,102 @@
1
+ module NulogyMessageBusProducer
2
+ # Subscription class to `use` when developing Message Bus-backed subscriptions
3
+ # For example,
4
+ #
5
+ # class SomeSchema < GraphQL::Schema
6
+ # ...
7
+ # use NulogyMessageBusProducer::PostgresPublicSubscriptions
8
+ # end
9
+ #
10
+ # It expects that schema to already be registered, or will raise an error.
11
+ #
12
+ # NulogyMessageBusProducer.register_schema("some_schema", "SomeSchema")
13
+ class PostgresPublicSubscriptions < GraphQL::Subscriptions
14
+ def initialize(options = {})
15
+ super
16
+
17
+ @schema_key = NulogyMessageBusProducer.resolve_schema_key(options.fetch(:schema))
18
+ end
19
+
20
+ # This method is copied from GraphQL::Subscriptions and customized.
21
+ # Check for changes in the superclass when upgrading the graphql gem.
22
+ def execute(subscription_id, event, object) # rubocop:disable Metrics/MethodLength
23
+ query_data = read_subscription(subscription_id)
24
+ query_string = query_data.fetch(:query_string)
25
+ variables = { id: object[:uuid] }
26
+ context = object[:context]
27
+ operation_name = query_data.fetch(:operation_name)
28
+ result = @schema.execute(
29
+ query: query_string,
30
+ context: context,
31
+ subscription_topic: event.topic,
32
+ operation_name: operation_name,
33
+ variables: variables,
34
+ root_value: object
35
+ )
36
+ deliver(subscription_id, result)
37
+ rescue GraphQL::Schema::Subscription::NoUpdateError
38
+ # This update was skipped in user code; do nothing.
39
+ rescue GraphQL::Schema::Subscription::UnsubscribedError
40
+ delete_subscription(subscription_id)
41
+ end
42
+
43
+ def each_subscription_id(event)
44
+ Models::PublicSubscription.where(event_type: event.name, schema_key: @schema_key).each do |subscription|
45
+ yield subscription.id
46
+ end
47
+ end
48
+
49
+ def read_subscription(subscription_id)
50
+ query_string = Models::PublicSubscription.find_by(id: subscription_id).query
51
+
52
+ {
53
+ query_string: query_string,
54
+ operation_name: nil
55
+ }
56
+ end
57
+
58
+ def deliver(subscription_id, result)
59
+ tenant_id = result.query.context.object[:tenant_id]
60
+ subscription = Models::PublicSubscription.find_by(id: subscription_id)
61
+
62
+ Models::PublicSubscriptionEvent.create_or_update(
63
+ id: SecureRandom.uuid,
64
+ public_subscription_id: subscription_id,
65
+ partition_key: "#{subscription.subscription_group_id},#{tenant_id}",
66
+ tenant_id: tenant_id,
67
+ event_json: result.to_h["data"],
68
+ topic_name: subscription.topic_name
69
+ )
70
+ end
71
+
72
+ def write_subscription(query, events)
73
+ events.each do |event|
74
+ Models::PublicSubscription.create_or_update(
75
+ id: event.arguments[:subscription_id],
76
+ subscription_group_id: event.arguments[:subscription_group_id],
77
+ event_type: event.name,
78
+ schema_key: @schema_key,
79
+ query: convert_subscription_result_query_to_regular_query(query, event),
80
+ topic_name: event.arguments[:topic_name]
81
+ )
82
+ end
83
+ end
84
+
85
+ def delete_subscription(subscription_id)
86
+ Models::PublicSubscription.find_by(id: subscription_id).destroy
87
+ end
88
+
89
+ private
90
+
91
+ def convert_subscription_result_query_to_regular_query(query, event)
92
+ selections = query.document.definitions.first.selections
93
+
94
+ event_selection = selections.find { |e| e.name == event.name }
95
+
96
+ # TODO: This assumes the query_type.rb takes an argument called 'id'. This should check the query to lookup the argument name
97
+ # TODO: Can we do this by manipulating GQL objects?
98
+ inner_query = event_selection.selections.first.to_query_string.sub("{", "(id: $id) {")
99
+ "query ($id: UUID!) { #{inner_query} }"
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,27 @@
1
+ module NulogyMessageBusProducer
2
+ # This model saves all subscriptions to external systems.
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
5
+ self.table_name = :message_bus_subscriptions
6
+
7
+ # Run our validator with familar syntax in this model
8
+ class ValidForSchemaValidator < ActiveModel::EachValidator
9
+ def validate_each(record, attribute, _value)
10
+ validator = NulogyMessageBusProducer::SubscriberGraphqlSchemaValidator.new
11
+
12
+ validator.validate(record)
13
+
14
+ validator.errors.each { |e| record.errors.add(attribute, e) }
15
+ end
16
+ end
17
+
18
+ validates :query, presence: true, valid_for_schema: true
19
+ validates :schema_key, presence: true
20
+
21
+ def self.create_or_update(attrs)
22
+ find_or_initialize_by(id: attrs[:id]).tap do |model|
23
+ model.update!(attrs)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,13 @@
1
+ module NulogyMessageBusProducer
2
+ # A model that contains the event data for a particular subscription
3
+ # It is simply saved in the database and shipped to Kafka by Debezium
4
+ class PublicSubscriptionEvent < ApplicationRecord
5
+ self.table_name = :message_bus_subscription_events
6
+
7
+ def self.create_or_update(attrs)
8
+ find_or_initialize_by(id: attrs[:id]).tap do |model|
9
+ model.update!(attrs)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,45 @@
1
+ module NulogyMessageBusProducer
2
+ # A custom validator that checks that the provided (or all) subscriptions have a query that is valid for its
3
+ # configured schema. Schemas must be registered with a `schema_key`, that is persisted in the database.
4
+ # It ties the subscription to a particular schema.
5
+ #
6
+ # This validator is run as part of an initializer as a last ditch effort to verify that the stored queries in the
7
+ # database are valid against the deployed schema, so that when events are generated in the system, they are always
8
+ # sucessfully created.
9
+ class SubscriberGraphqlSchemaValidator
10
+ attr_reader :errors
11
+
12
+ def initialize
13
+ @errors = []
14
+ end
15
+
16
+ def validate(subscription_or_subscriptions = NulogyMessageBusProducer::PublicSubscription.all)
17
+ Array(subscription_or_subscriptions).each do |subscription|
18
+ schema = find_schema(subscription)
19
+ next unless schema
20
+
21
+ gql_errors = schema.validate(subscription.query)
22
+ errors = gql_errors.map { |e| "#{e.message} #{display_id(subscription.id)}" }
23
+
24
+ @errors.concat(errors)
25
+ end
26
+
27
+ @errors.empty?
28
+ end
29
+
30
+ private
31
+
32
+ def find_schema(subscription)
33
+ NulogyMessageBusProducer.resolve_schema(subscription.schema_key) do
34
+ @errors << "Could not find a schema for schema_key '#{subscription.schema_key}' #{display_id(subscription.id)}"
35
+ nil
36
+ end
37
+ end
38
+
39
+ def display_id(id)
40
+ normalized = id.presence || "<new_record>"
41
+
42
+ "(id: #{normalized})"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,3 @@
1
+ module NulogyMessageBusProducer
2
+ VERSION = "1.0.2".freeze
3
+ end
@@ -0,0 +1,6 @@
1
+ # Add your own tasks in files placed in lib/tasks ending in .rake,
2
+ # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3
+
4
+ require_relative 'config/application'
5
+
6
+ Rails.application.load_tasks
@@ -0,0 +1,4 @@
1
+
2
+ //= link_tree ../images
3
+ //= link_directory ../javascripts .js
4
+ //= link_directory ../stylesheets .css