nulogy_message_bus_producer 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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