nexus_cqrs 0.3.1 → 0.4.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22851e4cf72bdb9d4895163003f9ccaef81182f71ba64de7b9729c80f06ea40b
4
- data.tar.gz: a39184dedf35d590ad9b7f3dab51facbd233fd5e9ec7ddeab27c714604c47018
3
+ metadata.gz: 9be679b93bcb87378a6a0bd75dc5925b05e6f9dffef3fea2ec74d3482e883dda
4
+ data.tar.gz: 2749a963dbcd1a7b8d48cf18844b1fe106774b976d76b19f5fd6255211bece01
5
5
  SHA512:
6
- metadata.gz: 5dd0e3cf9cb2014329a289bb94a9659144109988775709566996eb69aeaf663cc504ceb9cc83fb33b9e99fe2fc3e424c2584d0269d0b57564cecb148c1e0006c
7
- data.tar.gz: 92497f3e118eb6bfad46e842fc0713fb64a8785ac0251296215003449593515c445de351c0afe9ff5f30efb9e5df1e0c4cf72c9c5c486ec253293c0e1e3094d4
6
+ metadata.gz: 7b4e7ec0a0b1fb59524d835d4fe181aff5d1bb94e5f0ea8b27497dd7fc8daf44984b0329d898b290fce84ed53ef013f74f0fe14a3fd4fbddda1b63f2aac18191
7
+ data.tar.gz: ce8df945c033cac10d106581adf226a55e9615594335606f1ea9ecbd19f00eb378dc1e7cf88fb7fab632d95281a77f35cbae32938cb697fb2c12968f6d69d95f
data/.gitignore CHANGED
@@ -11,3 +11,6 @@
11
11
  *.gem
12
12
 
13
13
  results/rubocop.html
14
+
15
+ # Ignore IDE configuration
16
+ nexus_cqrs.iml
data/.rubocop.yml CHANGED
@@ -3,9 +3,14 @@ inherit_gem:
3
3
 
4
4
  AllCops:
5
5
  Exclude:
6
- - 'lib/generators/nexus_cqrs/templates/**/*.rb'
6
+ - db/schema.rb
7
+ - db/seeds.rb
8
+ - db/migrate/*.rb
9
+ - config/initializers/devise.rb
10
+ - spec/**/*
11
+
12
+ Style/Documentation:
13
+ Enabled: false
7
14
 
8
15
  Style/GlobalVars:
9
16
  Enabled: false
10
- Style/FrozenStringLiteralComment:
11
- Enabled: false
data/Gemfile CHANGED
@@ -11,3 +11,4 @@ gem 'rspec'
11
11
  gem "generator_spec"
12
12
  gem 'thread_safe'
13
13
  gem 'ibsciss-middleware'
14
+ gem 'request_store'
data/Gemfile.lock CHANGED
@@ -1,9 +1,12 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- nexus_cqrs (0.3.1)
4
+ nexus_cqrs (0.1.0)
5
5
  generator_spec
6
6
  ibsciss-middleware
7
+ pundit
8
+ request_store
9
+ strings-case
7
10
  thread_safe
8
11
 
9
12
  GEM
@@ -52,6 +55,8 @@ GEM
52
55
  parallel (1.21.0)
53
56
  parser (3.0.2.0)
54
57
  ast (~> 2.4.1)
58
+ pundit (2.1.1)
59
+ activesupport (>= 3.0.0)
55
60
  racc (1.6.0)
56
61
  rack (2.2.3)
57
62
  rack-test (1.1.0)
@@ -70,6 +75,8 @@ GEM
70
75
  rainbow (3.0.0)
71
76
  rake (13.0.6)
72
77
  regexp_parser (2.1.1)
78
+ request_store (1.5.0)
79
+ rack (>= 1.4)
73
80
  rexml (3.2.5)
74
81
  rspec (3.10.0)
75
82
  rspec-core (~> 3.10.0)
@@ -98,6 +105,7 @@ GEM
98
105
  rubocop-shopify (1.0.7)
99
106
  rubocop (~> 1.4)
100
107
  ruby-progressbar (1.11.0)
108
+ strings-case (0.3.0)
101
109
  thor (1.1.0)
102
110
  thread_safe (0.3.6)
103
111
  tzinfo (2.0.4)
@@ -112,6 +120,7 @@ DEPENDENCIES
112
120
  generator_spec
113
121
  ibsciss-middleware
114
122
  nexus_cqrs!
123
+ request_store
115
124
  rspec
116
125
  rubocop
117
126
  rubocop-shopify (~> 1.0.4)
data/README.md CHANGED
@@ -1,8 +1,11 @@
1
- # Cqrs::Core
1
+ # Nexus CQRS
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/cqrs/core`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Built by the NexusMods team for providing a universal way of organising CQRS logic (Command and Queries) and permission
4
+ management. Used by most of NexusMod's rails applications.
4
5
 
5
- TODO: Delete this and the text above, and describe your gem
6
+ The core concept of this gem is to provide a MessageBus that can invoked handlers registered to certain commands.
7
+ This allows developers to separate business and application logic, whilst also seperating read operations from write
8
+ operations (Queries and Commands).
6
9
 
7
10
  ## Installation
8
11
 
@@ -20,39 +23,119 @@ Or install it yourself as:
20
23
 
21
24
  $ gem install nexus_cqrs
22
25
 
23
- ## Usage
26
+ ## Getting Started
24
27
 
25
- Generators can be used to aide in the creation of Commands and Queries:
28
+ ### Generators
29
+
30
+ Generators can be used to aid in the creation of Commands and Queries:
26
31
 
27
32
  rails g nexus_cqrs:command CommandName
28
33
  rails g nexus_cqrs:query QueryName
29
34
 
30
- Once installed, a CommandBus is required to control the flow of Commands and/or Queries:
35
+ Optionally, a command can be scaffolded with basic authorisation logic with `--permission`
36
+
37
+ rails g nexus_cqrs:command CommandName --permission
38
+ rails g nexus_cqrs:query QueryName --permission
39
+
40
+ Once a command has been created, two new files will be created under `/app/domain/command` or `/app/domain/query`
41
+ depending on which generator was used. An initializer will also be created - `register_cqrs_handlers.rb`
42
+
43
+ ### Command Bus and Executor
44
+
45
+ In `register_cqrs_handlers.rb`, the command bus and executor will be created and used to register queries and commands:
46
+
47
+ ```ruby
48
+ middleware_stack = Middleware::Builder.new do |b|
49
+ # Configure additional middleware for the CQRS stack here
50
+ end
51
+
52
+ command_bus = NexusCqrs::CommandBus.new(middleware: middleware_stack)
53
+ query_bus = NexusCqrs::CommandBus.new(middleware: middleware_stack)
54
+
55
+ $COMMAND_EXECUTOR = NexusCqrs::CommandExecutor.new(command_bus)
56
+ $QUERY_EXECUTOR = NexusCqrs::CommandExecutor.new(query_bus)
57
+ ```
58
+
59
+ *NOTE: By default, all classes extending `BaseCommand` and `BaseQuery` in the `app/domain` directory will be
60
+ registered automatically.*
31
61
 
32
62
  ### Middleware
33
63
 
34
- Middleware can be created by extending the base middleware:
64
+ Middleware can be created by extending the base middleware and injecting into the middleware stack:
35
65
 
36
66
  ```ruby
67
+ # my_middleware.rb
37
68
  class MyMiddleware < NexusCqrs::BaseMiddleware
38
69
  def call(command)
39
70
  @next.call(command)
40
71
  end
41
72
  end
73
+
74
+ # register_cqrs_handlers.rb
75
+ middleware_stack = Middleware::Builder.new do |b|
76
+ b.use MyMiddleware
77
+ end
78
+
79
+ command_bus = Bus.new(middleware: middleware_stack)
42
80
  ```
43
81
 
44
- The above middleware will pass responsibility for execution to the next responder in the chain.
82
+ The above middleware will pass responsibility for execution to the next responder in the chain and will be ran BEFORE
83
+ the handler is invoked for every message executed through the `CommandExecutor`
45
84
 
46
85
  For more information on writing middleware see: https://github.com/Ibsciss/ruby-middleware
47
86
 
48
- When creating a bus, middleware can be attached like so:
87
+ ### Metadata
88
+
89
+ Commands/Queries can contain data, but data can also be injected into the message via metadata before the message is
90
+ executed:
49
91
 
50
92
  ```ruby
51
- middleware_stack = Middleware::Builder.new do |b|
52
- b.use MyMiddleware
53
- end
93
+ command.set_metadata(:current_user, user)
94
+ execute(command)
95
+ ```
54
96
 
55
- command_bus = Bus.new(middleware: middleware_stack)
97
+ ### Authorisation
98
+
99
+ There are various tools and helpers to aid with authorisation in this gem. Firstly, the system must be aware of the
100
+ user that is calling the command, this can be done by providing the current user and global permissions as metadata:
101
+
102
+ ```ruby
103
+ message.set_metadata(:current_user, current_user)
104
+ message.set_metadata(:global_permissions, @access_token[:global_permissions])
105
+ execute(message)
106
+ ```
107
+ Once the metadata is set, the handler can than access the metadata - which in turn can invoke the `authorize` method:
108
+
109
+ ```ruby
110
+ class ModerateHandler < BaseCommandHandler
111
+ include NexusCqrs::Helpers
112
+
113
+ # @param [Commands::Moderate] command
114
+ def call(command)
115
+ mod = Mod.kept.find(command.mod_id)
116
+
117
+ authorize(command, mod)
118
+ ...
119
+ ```
120
+
121
+ This will look up the correct Policy and automatically pass the metadata from the command, converting it into a
122
+ `UserContext` object:
123
+
124
+ ```ruby
125
+ # Pull context variables from command
126
+ user = message.metadata[:current_user]
127
+ global_permissions = message.metadata[:global_permissions]
128
+
129
+ # Instantiate new policy class, with context
130
+ policy = policy_class.new(UserContext.new(user, global_permissions), record)
131
+ ```
132
+
133
+ This will allow the policy class to access the `PermissionProvider` and retrieve any permission:
134
+
135
+ ```ruby
136
+ def moderate?
137
+ permissions.has_permission?('mod:moderate', ModPermission, record.id)
138
+ end
56
139
  ```
57
140
 
58
141
  ## Development
@@ -66,4 +149,4 @@ To contribute to this gem, simple pull the repository, run `bundle install` and
66
149
 
67
150
  ## Releasing
68
151
 
69
- The release process is tied to the git tags. Simply creating a new tag and pushing will trigger a new release to reubygems.
152
+ The release process is tied to the git tags. Simply creating a new tag and pushing will trigger a new release to rubygems.
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  Rails.configuration.to_prepare do
3
3
  middleware_stack = Middleware::Builder.new do |b|
4
- b.use(NexusCqrsAuth::AuthMiddleware)
5
4
  end
6
5
 
7
6
  command_bus = NexusCqrs::CommandBus.new(middleware: middleware_stack)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require 'pundit'
3
+ require 'strings-case'
4
+
5
+ module NexusCqrs
6
+ # Concern used to provide authorisation abilities to handlers and other classes. Overrides pundit's `authorize` method
7
+ # and creates helpers for the permission_provider
8
+ module Auth
9
+ include Pundit
10
+
11
+ # Overrides pundit's `authorize` method, allowing the message to be passed
12
+ #
13
+ # @see Pundit#authorize
14
+ # @param [NexusCqrs::BaseMessage] message Message to authorise against
15
+ def authorize(message, record, query = nil, policy_class: nil)
16
+ # Populate the query from the command, or the params if it's being overriden
17
+ query ||= Strings::Case.snakecase(message.demodularised_class_name) + '?'
18
+
19
+ # Retreive the policy class object from the type of record we are passing in
20
+ policy_class ||= PolicyFinder.new(record).policy
21
+
22
+ # Pull context variables from command
23
+ user = message.metadata[:current_user]
24
+ global_permissions = message.metadata[:global_permissions]
25
+
26
+ # Instantiate new policy class, with context
27
+ policy = policy_class.new(UserContext.new(user, global_permissions), record)
28
+ raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)
29
+
30
+ record.is_a?(Array) ? record.last : record
31
+ end
32
+
33
+ # Helper method for creating a permissions provider object from a query object. This allows certain permissions
34
+ # to be checked inside the command handler, as opposed to inside the policy
35
+ #
36
+ # @param [NexusCqrs::BaseMessage] message Create the PermissionProvider using the message metadata
37
+ # @return [PermissionProvider] A new instance of the permission provider
38
+ def permission_provider(message)
39
+ PermissionProvider.new(message.metadata[:current_user], message.metadata[:global_permissions])
40
+ end
41
+
42
+ def pundit_user
43
+ nil
44
+ end
45
+
46
+ def current_user
47
+ return super if defined?(super)
48
+
49
+ nil
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ module NexusCqrs
3
+ module Auth
4
+ # Concern used to integrate models to the permissions system. Including this module to a model will assume the
5
+ # model can be "owned" by a user. When the model is created, permissions will automatically be assigned to the user
6
+ # and permissions can be validated and "repaired" retroactively.
7
+ module Ownable
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :granted_permissions, default: {}
12
+
13
+ # Default relationship to <class>_permissions
14
+ class_attribute :permissions_relation, default: top_ancestor_class.name.downcase + "_permissions"
15
+ class_attribute :owner_column, default: :user_id
16
+
17
+ before_create :assign_permissions
18
+
19
+ define_model_callbacks :create_permissions
20
+ end
21
+
22
+ module ClassMethods
23
+ # Gets the class that this module is included in
24
+ def top_ancestor_class
25
+ ancestors.first
26
+ end
27
+ end
28
+
29
+ def assign_permissions
30
+ # As we are doing this on before_create, we must "build" this association, as opposed to creating it. This
31
+ # ensures validation is passed before saving this parent Collection.
32
+ granted_permissions.each do |p|
33
+ unless permissions.where(permission: p, entity_id: id, user_id: owner_id).exists?
34
+ permissions.build(user_id: owner_id, permission: p)
35
+ end
36
+ end
37
+ end
38
+
39
+ def owner_id
40
+ send(owner_column)
41
+ end
42
+
43
+ def permissions
44
+ if respond_to?(permissions_relation)
45
+ return send(permissions_relation)
46
+ end
47
+
48
+ raise OwnableRelationshipNotSet, "Permissions relation not set.
49
+ Set it on your model with `self.permissions_relation = :xxx_permissions`, and ensure relationship has been created"
50
+ end
51
+ end
52
+
53
+ class OwnableRelationshipNotSet < StandardError
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+ require 'request_store'
3
+
4
+ module NexusCqrs
5
+ module Auth
6
+ class PermissionProvider
7
+ def initialize(user_id, global_permissions)
8
+ @user_id = user_id
9
+ @global_permissions = parse_permissions_array(global_permissions)
10
+ end
11
+
12
+ # Returns true if the current user has the requested permission on the requested entity (if passed), or globally
13
+ #
14
+ # @param [String] permission_key Permission key to check against
15
+ # @param [ApplicationRecord] permission_model Permission model
16
+ # @param [Integer] entity_id ID of the entity
17
+ # @return [Boolean] Returns true if the current user has this permission on this entity
18
+ # @example Check for permission
19
+ # permissions.has_permission?('collection:publish', CollectionPermissions, collection.id) #=> true
20
+ def has_permission?(permission_key, permission_model = nil, entity_id = nil)
21
+ return false if @user_id.nil?
22
+
23
+ return true if @global_permissions.include?(permission_key)
24
+
25
+ # check entity-specific permissions
26
+ unless permission_model.nil?
27
+
28
+ # get all permissions for this entity. NOTE: This will be cached per-request.
29
+ permissions = cached_permissions(permission_model)
30
+
31
+ # if there are no permissions for this entity and user, return false
32
+ return false if permissions[entity_id].nil?
33
+
34
+ # if the permission key is in the user's permissions for this entity, return true
35
+ return true if permissions[entity_id].include?(permission_key)
36
+ end
37
+
38
+ false
39
+ end
40
+
41
+ # Retrieves a list of permissions assigned to a user for a specific entity
42
+ #
43
+ # @param [ApplicationRecord] permission_model Permission model
44
+ # @param [Integer] entity_id ID of the entity
45
+ # @return [Array] Returns an array of hashes representing permission keys, along with their global status
46
+ # @example Get a list of permissions
47
+ # permissions.for_user_on_entity(CollectionPermissions, collection.id) #=>
48
+ # [
49
+ # {:global=>false, :key=>"collection:discard"},
50
+ # {:global=>false, :key=>"collection:publish"},
51
+ # {:global=>false, :key=>"collection:view_under_moderation"},
52
+ # {:global=>false, :key=>"collection:set_status"}
53
+ # ]
54
+ def for_user_on_entity(permission_model, entity_id)
55
+ return [] if @user_id.nil?
56
+
57
+ # retrieve entity-specific permissions from DB and map to hash
58
+ entity_permissions = permission_model.where(user_id: @user_id, entity_id: entity_id)
59
+ .map { |p| { global: false, key: p.permission } }
60
+
61
+ # Map global permissions to hash
62
+ global_permissions = @global_permissions.map { |p| { global: true, key: p } }
63
+
64
+ # Combine hashes and ensure global permissions take priority
65
+ (global_permissions + entity_permissions).uniq { |p| p[:key] }
66
+ end
67
+
68
+ # Retrieves a list of permissions assigned to a user for ANY entity ID
69
+ #
70
+ # @param [ApplicationRecord] permission_model Permission model
71
+ # @return [Hash] Returns a hash representing each entity and the user's permissions
72
+ # @example Get a list of permissions
73
+ # permissions.for_user(CollectionPermissions) #=>
74
+ # {
75
+ # 1 => ["collection:discard"],
76
+ # 2 => ["collection:discard"],
77
+ # 3 => ["collection:discard", "collection:edit"],
78
+ # 4 => ["collection:discard"],
79
+ # }
80
+ def for_user(permission_model)
81
+ return {} if @user_id.nil?
82
+
83
+ permissions = {}
84
+
85
+ # retrieve entity-specific permissions from DB and map to hash
86
+ permission_model.where(user_id: @user_id).each do |p|
87
+ if permissions[p.entity_id].nil?
88
+ permissions[p.entity_id] = [p.permission]
89
+ else
90
+ permissions[p.entity_id] << p.permission
91
+ end
92
+ end
93
+
94
+ permissions
95
+ end
96
+
97
+ private
98
+
99
+ def parse_permissions_array(permissions_array)
100
+ return [] if permissions_array.nil?
101
+
102
+ permissions = []
103
+
104
+ permissions_array.each do |entity, action_array|
105
+ action_array.each do |action|
106
+ permissions << entity + ":" + action
107
+ end
108
+ end
109
+
110
+ permissions
111
+ end
112
+
113
+ # Retrieve all the permissions for this user for a specific model and cache it for this request (as all calls to
114
+ # a PermissionProvider in the same request will all have the same permissions, this saves on many SQL calls)
115
+ #
116
+ # @param [ApplicationRecord] permission_model Permission model
117
+ # @see PermissionProvider#for_user
118
+ def cached_permissions(permission_model)
119
+ ::RequestStore.store[:permissions] ||= for_user(permission_model)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ module NexusCqrs
3
+ module Auth
4
+ # Class used to provide additional context into pundit. This enables us to not only pass the user model, but also
5
+ # the global permissions for that user - as those are pulled from the user's request, not the model.
6
+ class UserContext
7
+ attr_reader :user, :global_permissions
8
+
9
+ def initialize(user, global_permissions)
10
+ @user = user
11
+ @global_permissions = global_permissions
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base class for all commands. Should declare value types for passing to handlers
4
+ #
5
+ # @since 0.1.0
3
6
  class BaseCommand < BaseMessage
4
7
  end
5
8
  end
@@ -1,6 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base class for all command handlers. Should always declare a `call` method for invoking the handler.
4
+ #
5
+ # @since 0.1.0
3
6
  class BaseCommandHandler
7
+ include NexusCqrs::Auth
8
+
9
+ # Method for invoking this handler - all command logic should be contained in this method.
10
+ #
11
+ # @param [NexusCqrs::BaseCommand] command Command to be executed by this handler
4
12
  def call(command)
5
13
  end
6
14
  end
@@ -1,27 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # All messages passed through the message bus will extend this class. Commands and Queries are both extended types of
4
+ # `BaseMessage`. This is mainly used to store metadata for Commands/Queries (such as the user context), as well
5
+ # as helper methods for retrieving the policy method for this Command/Query/Message
6
+ #
7
+ # @since 0.1.0
3
8
  class BaseMessage
9
+ # Sets metadata on this message.
10
+ #
11
+ # @param [Symbol] key Metadata key
12
+ # @param [Object] value Metadata value
13
+ # @example Set the current user on a command/query
14
+ # command.set_metadata(:current_user, user)
15
+ # @since 0.1.0
4
16
  def set_metadata(key, value)
5
17
  @metadata = {} unless @metadata
6
18
  @metadata[key.to_sym] = value
7
19
  end
8
20
 
21
+ # Getter for retrieving the metadata on this message
22
+ #
23
+ # @return [Hash] Any metadata attached to this message
24
+ # @since 0.1.0
9
25
  def metadata
10
26
  @metadata || {}
11
27
  end
12
28
 
13
- def to_h
14
- hash = instance_variables.each_with_object({}) do |var, acc|
15
- acc[var.to_s[1..-1].to_sym] = instance_variable_get(var)
16
- end
17
- hash[:metadata] = @metadata || {}
18
- hash
19
- end
20
-
29
+ # Helper method for retrieving the name of the class used to define the auth policy for this message
30
+ #
31
+ # @example Get the policy_class on a `Commands::Mods::DeleteMod` command
32
+ # command.policy_class #=> "DeleteModPolicy"
33
+ #
34
+ # @return [String] Name of the policy class
35
+ # @deprecated This used to be used to authorise policies before they hit the command bus - requiring a new policy
36
+ # for every message. E.g. `DeleteModPolicy`, `UpdateModPolicy`, `CreateModPolicy` etc. It is now recommended to
37
+ # simple call `authorise` within the handler as it is far more flexible
38
+ # @since 0.1.0
21
39
  def policy_class
22
40
  demodularised_class_name + 'Policy'
23
41
  end
24
42
 
43
+ # Helper method for retrieving the demodularised name of the class used to define the auth policy for this message
44
+ #
45
+ # @example Get the demodularised_class_name on a `Commands::Mods::DeleteMod` command
46
+ # command.policy_class #=> "DeleteMod"
47
+ #
48
+ # @return [String] Name of the policy class
49
+ # @since 0.1.0
25
50
  def demodularised_class_name
26
51
  self.class.name.split('::').last
27
52
  end
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base middleware class to allow custom middleware to be injected into the command bus
4
+ #
5
+ # @abstract
6
+ # @since 0.1.0
3
7
  class BaseMiddleware
4
8
  def initialize(next_)
5
9
  @next = next_
6
10
  end
7
11
 
8
- # @param [BaseMessage] message
12
+ # Invoke middleware
13
+ #
14
+ # @param [BaseMessage] message Message object being passed to the bus
9
15
  def call(message)
10
16
  end
11
17
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base class for all queries. Should declare value types for passing to handlers
4
+ #
5
+ # @since 0.1.0
3
6
  class BaseQuery < BaseMessage
4
7
  end
5
8
  end
@@ -1,6 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base class for all query handlers. Should always declare a `call` method for invoking the handler.
4
+ #
5
+ # @since 0.1.0
3
6
  class BaseQueryHandler
7
+ include NexusCqrs::Auth
8
+
9
+ # Method for invoking this handler - all query logic should be contained in this method.
10
+ #
11
+ # @param [NexusCqrs::BaseQuery] query Query to be executed by this handler
4
12
  def call(query)
5
13
  end
6
14
  end
@@ -3,6 +3,11 @@ require 'thread_safe'
3
3
  require 'middleware'
4
4
 
5
5
  module NexusCqrs
6
+ # The command bus is responsible for registering and invoking the handlers. It stores a simple list of all registered
7
+ # commands, along with their handlers. When a message is passed to `call`, the middleware is triggered for the message
8
+ # then the handler is invoked.
9
+ #
10
+ # @since 0.1.0
6
11
  class CommandBus
7
12
  UnregisteredHandler = Class.new(StandardError)
8
13
  MultipleHandlers = Class.new(StandardError)
@@ -12,23 +17,44 @@ module NexusCqrs
12
17
  @middleware = middleware || Middleware::Builder.new
13
18
  end
14
19
 
15
- def register(klass, handler)
16
- raise MultipleHandlers, "Multiple handlers not allowed for #{klass}" if handlers[klass]
20
+ # Registers a handler for a command.
21
+ #
22
+ # @param [NexusCqrs::BaseMessage] message Message to register a handler for
23
+ # @param [NexusCqrs::BaseCommandHandler] handler CommandHandler/QueryHandler to be invoked for this message
24
+ # @raise [MultipleHandlers] If the message already has a handler registered, an error will be raised
25
+ #
26
+ # @since 0.1.0
27
+ def register(message, handler)
28
+ raise MultipleHandlers, "Multiple handlers not allowed for #{message}" if handlers[message]
17
29
 
18
- handlers[klass] = handler
30
+ handlers[message] = handler
19
31
  end
20
32
 
21
- def call(command)
33
+ # Invokes a handler from a message
34
+ #
35
+ # @param [NexusCqrs::BaseMessage] message Message used to determine which handler to invoke
36
+ #
37
+ # @since 0.1.0
38
+ def call(message)
22
39
  runner = Middleware::Builder.new
23
40
  runner.use(@middleware)
24
- runner.use(->(command_) { handler_for_command(command_).call(command_) })
25
- runner.call(command)
41
+ runner.use(->(message_) { handler_for_command(message_).call(message_) })
42
+ runner.call(message)
26
43
  end
27
44
 
45
+ # Return a list of registered handlers
46
+ #
47
+ # @return [ThreadSafe::Cache] ThreadSafe::Cache object containing a list of all the registered handlers
48
+ #
49
+ # @since 0.1.0
28
50
  def registered_handlers
29
51
  handlers
30
52
  end
31
53
 
54
+ # Removes all registered handlers from the bus. This can be useful in tests - as we don't want state to persist
55
+ # across handlers
56
+ #
57
+ # @since 0.1.0
32
58
  def clear_handlers
33
59
  @handlers = ThreadSafe::Cache.new
34
60
  end
@@ -37,6 +63,9 @@ module NexusCqrs
37
63
 
38
64
  private
39
65
 
66
+ # Looks up the handler for a specific command
67
+ #
68
+ # @since 0.1.0
40
69
  def handler_for_command(command)
41
70
  unregistered_handler = proc { raise UnregisteredHandler, "Missing handler for #{command.class}" }
42
71
  handlers.fetch(command.class, &unregistered_handler)
@@ -1,42 +1,49 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module NexusCqrs
3
4
  class CommandExecutor
4
5
  # @param [NexusCqrs::CommandBus] command_bus
5
6
  def initialize(command_bus)
6
7
  @bus = command_bus
8
+ @logger = logger
9
+ end
10
+
11
+ # Get logger instance depending on runtime environment.
12
+ def logger
13
+ defined?(Rails) && defined?(Rails.logger) ? Rails.logger : Logger.new(STDOUT)
7
14
  end
8
15
 
9
16
  def autoregister(base_class, dir = 'app/domain/commands', ignore_strings = ['.rb', 'app/domain/'])
10
- # Iterate over the director passed and find all ruby files, removing unwanted parts of the string.
11
- Dir["#{dir}/*.rb"].each do |file|
12
- ignore_strings.each do |i|
13
- file.slice!(i)
14
- end
15
- begin
16
- # Constantize class name to constant to force rails to autoload this class
17
+ if defined?(Rails)
18
+ # Iterate over the directory passed and find all ruby files.
19
+ Dir["#{dir}/**/*.rb"].each do |file|
20
+ # Constantize class name to constant to force rails to autoload this class.
21
+ # Note that autoloading won't work when testing this gem standalone, but this does trigger the necessary
22
+ # loading when in a rails environment.
23
+ ignore_strings.each do |i|
24
+ file.slice!(i)
25
+ end
26
+ @logger.debug { "Attempting constantize of #{file}" }
17
27
  file.camelize.constantize
18
- rescue NameError
19
- puts "WARN: Tried to autoregister #{file.camelize} but class could not be found"
28
+ rescue NameError => e
29
+ @logger.warn { "Failed autoregister #{file.camelize}, received NameError: #{e}" }
20
30
  end
21
31
  end
22
32
 
23
33
  ObjectSpace.each_object(Class).select { |klass| klass < base_class }.each do |c|
34
+ @logger.debug { "Attempting auto registration of #{c}" }
24
35
  handler_name = (c.to_s + "Handler")
25
36
  begin
26
37
  handler = handler_name.constantize.new
27
38
  rescue NameError
28
- Rails.logger.error(
29
- "WARN: A command `#{c}` tried to autoregister `#{handler_name}` but the class was not found"
30
- )
39
+ @logger.warn { "Command `#{c}` tried to autoregister `#{handler_name}` but the class was not found" }
31
40
  next
32
41
  end
33
42
 
34
43
  if handler.respond_to?(:call)
35
44
  register_command(c, handler)
36
45
  else
37
- Rails.logger.error(
38
- "WARN: A command `#{c}` tried to autoregister `#{handler_name}` but `call` method did not exist"
39
- )
46
+ @logger.warn { "Command `#{c}` tried to autoregister `#{handler_name}` but `call` method did not exist" }
40
47
  end
41
48
  end
42
49
  end
@@ -48,16 +55,24 @@ module NexusCqrs
48
55
  @bus
49
56
  end
50
57
 
51
- # @param [NexusCqrs::BaseCommand] command
52
- def execute(command)
53
- @bus.call(command)
58
+ # Executes a specific handler on the bus, passing in the provided message (Query/Command)
59
+ #
60
+ # @param [NexusCqrs::BaseMessage] message
61
+ def execute(message)
62
+ @bus.call(message)
54
63
  end
55
64
 
65
+ # Helper method for registering a handler on the bus
66
+ #
67
+ # @see CommandBus#register
68
+ # @param [NexusCqrs::BaseMessage] message
56
69
  # @param [NexusCqrs::BaseCommandHandler] handler
57
- def register_command(klass, handler)
58
- @bus.register(klass, handler)
70
+ def register_command(message, handler)
71
+ @bus.register(message, handler)
59
72
  end
60
73
 
74
+ # Clears all handlers from the bus and invokes the block passed to `configure_handlers` to re-register all the
75
+ # handlers again. This can be useful in tests - as we don't want state to persist across handlers
61
76
  def reregister_handlers
62
77
  return if @register_handlers.nil?
63
78
 
@@ -65,6 +80,18 @@ module NexusCqrs
65
80
  @register_handlers.call(self)
66
81
  end
67
82
 
83
+ # Configuration method for allowing handlers to be registered in bulk
84
+ #
85
+ # @see CommandBus#register
86
+ # @param [Proc] block Block to execute to register handlers
87
+ # @example Registering all queries
88
+ # executor.configure_handlers do |executor|
89
+ # executor.autoregister(NexusCqrs::BaseQuery, 'app/domain/queries')
90
+ # end
91
+ # @example Registering a query manually
92
+ # executor.configure_handlers do |executor|
93
+ # executor.register_command(NexusCqrs::BaseQuery, NexusCqrs::BaseQueryHandler)
94
+ # end
68
95
  def configure_handlers(&block)
69
96
  @register_handlers = block
70
97
  @register_handlers.call(self)
@@ -1,21 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Simple module to inject execution methods into a class. Used to execute commands and queries from controllers and/or
4
+ # graphql resolvers.
3
5
  module Helpers
4
6
  # Executes a CQRS Command
7
+ #
8
+ # @param [NexusCqrs::BaseCommand] command Command to execute
9
+ # @example Execute a command
10
+ # execute(DeleteMod.new(mod.id))
5
11
  def execute(command)
6
12
  command_executor.execute(command)
7
13
  end
8
14
 
9
15
  # Executes a CQRS Query
16
+ #
17
+ # @param [NexusCqrs::BaseQuery] query Query to execute
18
+ # @example Execute a query
19
+ # execute(GetMod.new(mod.id))
10
20
  def query(query)
11
21
  query_executor.execute(query)
12
22
  end
13
23
 
14
- # Provide access to the CQRS executor
24
+ # Provide access to the CQRS command executor
25
+ #
26
+ # @return [NexusCqrs::CommandExecutor] Returns the executor used for commands
15
27
  def command_executor
16
28
  @command_executor ||= $COMMAND_EXECUTOR
17
29
  end
18
30
 
31
+ # Provide access to the CQRS query executor
32
+ #
33
+ # @return [NexusCqrs::CommandExecutor] Returns the executor used for queries
19
34
  def query_executor
20
35
  @query_executor ||= $QUERY_EXECUTOR
21
36
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
- VERSION = '0.3.1'
3
+ # Leave this as 0.4.4 in order for CI process to replace with the tagged version.
4
+ VERSION = '0.4.4'
4
5
  end
data/lib/nexus_cqrs.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
  require 'nexus_cqrs/base_message'
3
3
  require 'nexus_cqrs/base_middleware'
4
+ require 'nexus_cqrs/auth/auth'
5
+ require 'nexus_cqrs/auth/user_context'
6
+ require 'nexus_cqrs/auth/permission_provider'
7
+ require 'nexus_cqrs/auth/ownable'
4
8
  require 'nexus_cqrs/base_command'
5
9
  require 'nexus_cqrs/base_command_handler'
6
10
  require 'nexus_cqrs/base_query'
data/nexus_cqrs.gemspec CHANGED
@@ -21,4 +21,7 @@ Gem::Specification.new do |spec|
21
21
  spec.add_dependency('generator_spec')
22
22
  spec.add_dependency('thread_safe')
23
23
  spec.add_dependency('ibsciss-middleware')
24
+ spec.add_dependency('pundit')
25
+ spec.add_dependency('strings-case')
26
+ spec.add_dependency('request_store')
24
27
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nexus_cqrs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dean Lovett
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-11-11 00:00:00.000000000 Z
11
+ date: 2021-11-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: generator_spec
@@ -52,6 +52,48 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pundit
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: strings-case
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: request_store
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
55
97
  description:
56
98
  email:
57
99
  - dean.lovett@nexusmods.com
@@ -79,6 +121,10 @@ files:
79
121
  - lib/generators/nexus_cqrs/templates/query_handler.rb
80
122
  - lib/generators/nexus_cqrs/templates/register_cqrs_handlers.rb
81
123
  - lib/nexus_cqrs.rb
124
+ - lib/nexus_cqrs/auth/auth.rb
125
+ - lib/nexus_cqrs/auth/ownable.rb
126
+ - lib/nexus_cqrs/auth/permission_provider.rb
127
+ - lib/nexus_cqrs/auth/user_context.rb
82
128
  - lib/nexus_cqrs/base_command.rb
83
129
  - lib/nexus_cqrs/base_command_handler.rb
84
130
  - lib/nexus_cqrs/base_message.rb
@@ -108,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
154
  - !ruby/object:Gem::Version
109
155
  version: '0'
110
156
  requirements: []
111
- rubygems_version: 3.2.31
157
+ rubygems_version: 3.2.32
112
158
  signing_key:
113
159
  specification_version: 4
114
160
  summary: Core package for the nexus cqrs gem