nexus_cqrs 0.2.2 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24bafc3e27cbf1ef65d1e507bdb2798b67283c92c64b274bbf8818cfffa9618a
4
- data.tar.gz: e653d3e7b76bc2173a780cf6128974afaecaba8278c0f39e56527ae442395cff
3
+ metadata.gz: 967dfb16241304d1d0f2233b23e3410b87abff47ec4a916569101a53e4b963c5
4
+ data.tar.gz: d528d7da45c96cae5fe8a403116e3f9c8abc83c2842dbd165dfa5c3267a81e1d
5
5
  SHA512:
6
- metadata.gz: 38a848d5da2a2a61116f1bb2f04df2fae1c0cb368e6482854e0f7896a9bfcd0b2baab39e58c223c2b1f5a1a0c62bc028c0dddf1d2daccebc7268010e9506559c
7
- data.tar.gz: 6f7b0c32343f39b5b3eb61b9c4afb5cc338bbce81eeb5d998b0d0e1293fe1fa35984264f86ed8fbc8d1a0a29106602566a0ee00096951b4d54fc63ddb3d9f3d7
6
+ metadata.gz: 1f407d669371be6e3fd4f9ad4e55f92678b6fc82825b1dba6f5bf4a1dabb070c79005bdf37a114dd2461f6b99d7213daf777894d04f3337daf4a8255a74fa2f6
7
+ data.tar.gz: 6d31c90bff990bd92f7aeb9ffcf289d1fe47bb5fd987d47f14fb12e7a7d9baa0571f731a466dff569999976a370c7028a6380c8f4423a5798ecc1063f8847029
data/.gitignore CHANGED
@@ -9,3 +9,8 @@
9
9
  /.gem
10
10
  /spec/lib/tmp
11
11
  *.gem
12
+
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
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  source 'https://rubygems.org'
2
3
 
3
4
  # Specify your gem's dependencies in cqrs-core.gemspec
data/Gemfile.lock CHANGED
@@ -4,104 +4,111 @@ PATH
4
4
  nexus_cqrs (0.1.0)
5
5
  generator_spec
6
6
  ibsciss-middleware
7
+ pundit
8
+ strings-case
7
9
  thread_safe
8
10
 
9
11
  GEM
10
12
  remote: https://rubygems.org/
11
13
  specs:
12
- actionpack (6.0.3.2)
13
- actionview (= 6.0.3.2)
14
- activesupport (= 6.0.3.2)
15
- rack (~> 2.0, >= 2.0.8)
14
+ actionpack (6.1.4.1)
15
+ actionview (= 6.1.4.1)
16
+ activesupport (= 6.1.4.1)
17
+ rack (~> 2.0, >= 2.0.9)
16
18
  rack-test (>= 0.6.3)
17
19
  rails-dom-testing (~> 2.0)
18
20
  rails-html-sanitizer (~> 1.0, >= 1.2.0)
19
- actionview (6.0.3.2)
20
- activesupport (= 6.0.3.2)
21
+ actionview (6.1.4.1)
22
+ activesupport (= 6.1.4.1)
21
23
  builder (~> 3.1)
22
24
  erubi (~> 1.4)
23
25
  rails-dom-testing (~> 2.0)
24
26
  rails-html-sanitizer (~> 1.1, >= 1.2.0)
25
- activesupport (6.0.3.2)
27
+ activesupport (6.1.4.1)
26
28
  concurrent-ruby (~> 1.0, >= 1.0.2)
27
- i18n (>= 0.7, < 2)
28
- minitest (~> 5.1)
29
- tzinfo (~> 1.1)
30
- zeitwerk (~> 2.2, >= 2.2.2)
31
- ast (2.4.1)
29
+ i18n (>= 1.6, < 2)
30
+ minitest (>= 5.1)
31
+ tzinfo (~> 2.0)
32
+ zeitwerk (~> 2.3)
33
+ ast (2.4.2)
32
34
  builder (3.2.4)
33
- concurrent-ruby (1.1.7)
35
+ concurrent-ruby (1.1.9)
34
36
  crass (1.0.6)
35
37
  diff-lcs (1.4.4)
36
- erubi (1.9.0)
38
+ erubi (1.10.0)
37
39
  generator_spec (0.9.4)
38
40
  activesupport (>= 3.0.0)
39
41
  railties (>= 3.0.0)
40
- i18n (1.8.5)
42
+ i18n (1.8.11)
41
43
  concurrent-ruby (~> 1.0)
42
44
  ibsciss-middleware (0.4.2)
43
- loofah (2.6.0)
45
+ loofah (2.12.0)
44
46
  crass (~> 1.0.2)
45
47
  nokogiri (>= 1.5.9)
46
48
  method_source (1.0.0)
47
- mini_portile2 (2.4.0)
48
- minitest (5.14.1)
49
- nokogiri (1.10.10)
50
- mini_portile2 (~> 2.4.0)
51
- parallel (1.19.2)
52
- parser (2.7.1.4)
49
+ mini_portile2 (2.6.1)
50
+ minitest (5.14.4)
51
+ nokogiri (1.12.5)
52
+ mini_portile2 (~> 2.6.1)
53
+ racc (~> 1.4)
54
+ parallel (1.21.0)
55
+ parser (3.0.2.0)
53
56
  ast (~> 2.4.1)
57
+ pundit (2.1.1)
58
+ activesupport (>= 3.0.0)
59
+ racc (1.6.0)
54
60
  rack (2.2.3)
55
61
  rack-test (1.1.0)
56
62
  rack (>= 1.0, < 3)
57
63
  rails-dom-testing (2.0.3)
58
64
  activesupport (>= 4.2.0)
59
65
  nokogiri (>= 1.6)
60
- rails-html-sanitizer (1.3.0)
66
+ rails-html-sanitizer (1.4.2)
61
67
  loofah (~> 2.3)
62
- railties (6.0.3.2)
63
- actionpack (= 6.0.3.2)
64
- activesupport (= 6.0.3.2)
68
+ railties (6.1.4.1)
69
+ actionpack (= 6.1.4.1)
70
+ activesupport (= 6.1.4.1)
65
71
  method_source
66
- rake (>= 0.8.7)
67
- thor (>= 0.20.3, < 2.0)
72
+ rake (>= 0.13)
73
+ thor (~> 1.0)
68
74
  rainbow (3.0.0)
69
- rake (13.0.1)
70
- regexp_parser (1.7.1)
71
- rexml (3.2.4)
72
- rspec (3.9.0)
73
- rspec-core (~> 3.9.0)
74
- rspec-expectations (~> 3.9.0)
75
- rspec-mocks (~> 3.9.0)
76
- rspec-core (3.9.2)
77
- rspec-support (~> 3.9.3)
78
- rspec-expectations (3.9.2)
75
+ rake (13.0.6)
76
+ regexp_parser (2.1.1)
77
+ rexml (3.2.5)
78
+ rspec (3.10.0)
79
+ rspec-core (~> 3.10.0)
80
+ rspec-expectations (~> 3.10.0)
81
+ rspec-mocks (~> 3.10.0)
82
+ rspec-core (3.10.1)
83
+ rspec-support (~> 3.10.0)
84
+ rspec-expectations (3.10.1)
79
85
  diff-lcs (>= 1.2.0, < 2.0)
80
- rspec-support (~> 3.9.0)
81
- rspec-mocks (3.9.1)
86
+ rspec-support (~> 3.10.0)
87
+ rspec-mocks (3.10.2)
82
88
  diff-lcs (>= 1.2.0, < 2.0)
83
- rspec-support (~> 3.9.0)
84
- rspec-support (3.9.3)
85
- rubocop (0.86.0)
89
+ rspec-support (~> 3.10.0)
90
+ rspec-support (3.10.3)
91
+ rubocop (1.22.3)
86
92
  parallel (~> 1.10)
87
- parser (>= 2.7.0.1)
93
+ parser (>= 3.0.0.0)
88
94
  rainbow (>= 2.2.2, < 4.0)
89
- regexp_parser (>= 1.7)
95
+ regexp_parser (>= 1.8, < 3.0)
90
96
  rexml
91
- rubocop-ast (>= 0.0.3, < 1.0)
97
+ rubocop-ast (>= 1.12.0, < 2.0)
92
98
  ruby-progressbar (~> 1.7)
93
- unicode-display_width (>= 1.4.0, < 2.0)
94
- rubocop-ast (0.3.0)
95
- parser (>= 2.7.1.4)
96
- rubocop-shopify (1.0.4)
97
- rubocop (>= 0.85, < 0.87)
98
- ruby-progressbar (1.10.1)
99
- thor (1.0.1)
99
+ unicode-display_width (>= 1.4.0, < 3.0)
100
+ rubocop-ast (1.13.0)
101
+ parser (>= 3.0.1.1)
102
+ rubocop-shopify (1.0.7)
103
+ rubocop (~> 1.4)
104
+ ruby-progressbar (1.11.0)
105
+ strings-case (0.3.0)
106
+ thor (1.1.0)
100
107
  thread_safe (0.3.6)
101
- tzinfo (1.2.7)
102
- thread_safe (~> 0.1)
103
- unicode-display_width (1.7.0)
104
- zeitwerk (2.4.0)
108
+ tzinfo (2.0.4)
109
+ concurrent-ruby (~> 1.0)
110
+ unicode-display_width (2.1.0)
111
+ zeitwerk (2.5.1)
105
112
 
106
113
  PLATFORMS
107
114
  ruby
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.
data/Rakefile CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "bundler/gem_tasks"
2
3
  require 'bundler/gem_tasks'
3
4
  task(default: :spec)
data/bin/console CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'bundler/setup'
4
5
  require 'cqrs/core'
@@ -2,7 +2,7 @@ Description:
2
2
  Generates a new Query object, along with the QueryHandler
3
3
 
4
4
  Example:
5
- rails generate cqrs:query GetUserById
5
+ rails generate cqrs:query GetUserById --permission
6
6
 
7
7
  This will create:
8
8
  app/domain/queries/get_user_by_id.rb
@@ -1,30 +1,30 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators/base'
2
3
 
3
4
  module NexusCqrs
4
5
  class CommandGenerator < Rails::Generators::NamedBase
5
6
  source_root File.expand_path('templates', __dir__)
7
+ class_option :permission, type: :string, default: nil
6
8
 
7
9
  def copy_command_file
8
10
  file_path = class_name.underscore
9
11
 
12
+ @permission = options['permission']
13
+
10
14
  template('command.rb', "app/domain/commands/#{file_path}.rb")
11
15
  template('command_handler.rb', "app/domain/commands/#{file_path}_handler.rb")
12
16
 
13
- register_command(class_name)
17
+ register_command
14
18
  end
15
19
 
16
20
  private
17
21
 
18
- def register_command(full_name)
22
+ def register_command
19
23
  handler_config = 'config/initializers/register_cqrs_handlers.rb'
20
24
 
21
25
  unless File.exist?('config/initializers/register_cqrs_handlers.rb')
22
26
  template('register_cqrs_handlers.rb', handler_config)
23
27
  end
24
-
25
- code_to_inject = "$COMMAND_EXECUTOR.register_command(#{full_name}, #{full_name}Handler.new)\n"
26
-
27
- inject_into_file(handler_config, code_to_inject, after: "# Register Commands\n")
28
28
  end
29
29
  end
30
30
  end
@@ -1,30 +1,30 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails/generators/base'
2
3
 
3
4
  module NexusCqrs
4
5
  class QueryGenerator < Rails::Generators::NamedBase
5
6
  source_root File.expand_path('templates', __dir__)
7
+ class_option :permission, type: :string, default: nil
6
8
 
7
9
  def copy_query_file
8
10
  file_path = class_name.underscore
9
11
 
12
+ @permission = options['permission']
13
+
10
14
  template('query.rb', "app/domain/queries/#{file_path}.rb")
11
15
  template('query_handler.rb', "app/domain/queries/#{file_path}_handler.rb")
12
16
 
13
- register_query(class_name)
17
+ register_query
14
18
  end
15
19
 
16
20
  private
17
21
 
18
- def register_query(full_name)
22
+ def register_query
19
23
  handler_config = 'config/initializers/register_cqrs_handlers.rb'
20
24
 
21
25
  unless File.exist?('config/initializers/register_cqrs_handlers.rb')
22
26
  template('register_cqrs_handlers.rb', handler_config)
23
27
  end
24
-
25
- code_to_inject = "$QUERY_EXECUTOR.register_command(#{full_name}, #{full_name}Handler.new)\n"
26
-
27
- inject_into_file(handler_config, code_to_inject, after: "# Register Queries\n")
28
28
  end
29
29
  end
30
30
  end
@@ -1,9 +1,26 @@
1
+ # frozen_string_literal: true
1
2
  module Commands
2
- class <%= class_name %>Handler < NexusCqrs::BaseCommandHandler
3
+ # Command handler
4
+ class <%= class_name %>Handler < BaseCommandHandler
5
+ <% if @permission %>include NexusCqrs::Helpers<% end %>
3
6
 
4
- # call is where the Command is executed
7
+ # @param [<%= class_name %>] command
5
8
  def call(command)
9
+ <% if @permission %>authorize(command, Model)<% end %>
6
10
 
11
+ domain_event(command)
7
12
  end
13
+
14
+ private
15
+
16
+ def domain_event(command)
17
+ NexusDomainEvents::Event.new(
18
+ message_name: :ENTITY_WAS_ACTIONED,
19
+ actor: command.metadata[:current_user].member_id,
20
+ payload: {
21
+ object_id: command.id,
22
+ }
23
+ )
24
+ end
8
25
  end
9
26
  end
@@ -1,9 +1,10 @@
1
1
  module Queries
2
2
  class <%= class_name %>Handler < NexusCqrs::BaseQueryHandler
3
+ <% if @permission %>include NexusCqrs::Helpers<% end %>
3
4
 
4
- # call is where the Query is executed
5
+ # @param [<%= class_name %>] command
5
6
  def call(command)
6
-
7
+ <% if @permission %>authorize(command, Model)<% end %>
7
8
  end
8
9
  end
9
10
  end
@@ -1,9 +1,24 @@
1
- command_bus = NexusCqrs::CommandBus.new
2
- query_bus = NexusCqrs::CommandBus.new
1
+ # frozen_string_literal: true
2
+ Rails.configuration.to_prepare do
3
+ middleware_stack = Middleware::Builder.new do |b|
4
+ end
3
5
 
4
- $COMMAND_EXECUTOR = NexusCqrs::CommandExecutor.new command_bus
5
- $QUERY_EXECUTOR = NexusCqrs::CommandExecutor.new query_bus
6
+ command_bus = NexusCqrs::CommandBus.new(middleware: middleware_stack)
7
+ query_bus = NexusCqrs::CommandBus.new(middleware: middleware_stack)
6
8
 
7
- # Register Queries
9
+ $COMMAND_EXECUTOR = NexusCqrs::CommandExecutor.new(command_bus)
10
+ $QUERY_EXECUTOR = NexusCqrs::CommandExecutor.new(query_bus)
8
11
 
9
- # Register Commands
12
+ # Register Commands
13
+ $QUERY_EXECUTOR.configure_handlers do |executor|
14
+ # Manually registered queries should always go above the autoregister
15
+
16
+ executor.autoregister(NexusCqrs::BaseQuery, 'app/domain/queries')
17
+ end
18
+
19
+ $COMMAND_EXECUTOR.configure_handlers do |executor|
20
+ # Manually registered commands should always go above the autoregister
21
+
22
+ executor.autoregister(NexusCqrs::BaseCommand, 'app/domain/commands')
23
+ end
24
+ end
@@ -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,76 @@
1
+ # frozen_string_literal: true
2
+ module NexusCqrs
3
+ module Auth
4
+ class PermissionProvider
5
+ def initialize(user_id, global_permissions)
6
+ @user_id = user_id
7
+ @global_permissions = parse_permissions_array(global_permissions)
8
+ end
9
+
10
+ # Returns true if the current user has the requested permission on the requested entity (if passed), or globally
11
+ #
12
+ # @param [String] permission_key Permission key to check against
13
+ # @param [ApplicationRecord] permission_model Permission model
14
+ # @param [Integer] entity_id ID of the entity
15
+ # @return [Boolean] Returns true if the current user has this permission on this entity
16
+ # @example Check for permission
17
+ # permissions.has_permission?('collection:publish', CollectionPermissions, collection.id) #=> true
18
+ def has_permission?(permission_key, permission_model = nil, entity_id = nil)
19
+ return false if @user_id.nil?
20
+
21
+ return true if @global_permissions.include?(permission_key)
22
+
23
+ # check entity-specific permissions
24
+ unless permission_model.nil?
25
+ return true if permission_model.where(permission: permission_key, entity_id: entity_id,
26
+ user_id: @user_id.id).exists?
27
+ end
28
+
29
+ false
30
+ end
31
+
32
+ # Retrieves a list of permissions assigned to a user for a specific entity
33
+ #
34
+ # @param [ApplicationRecord] permission_model Permission model
35
+ # @param [Integer] entity_id ID of the entity
36
+ # @return [Array] Returns an array of hashes representing permission keys, along with their global status
37
+ # @example Get a list of permissions
38
+ # permissions.for_user(CollectionPermissions, collection.id) #=>
39
+ # [
40
+ # {:global=>false, :key=>"collection:discard"},
41
+ # {:global=>false, :key=>"collection:publish"},
42
+ # {:global=>false, :key=>"collection:view_under_moderation"},
43
+ # {:global=>false, :key=>"collection:set_status"}
44
+ # ]
45
+ def for_user_on_entity(permission_model, entity_id)
46
+ return [] if @user_id.nil?
47
+
48
+ # retrieve entity-specific permissions from DB and map to hash
49
+ entity_permissions = permission_model.where(user_id: @user_id, entity_id: entity_id)
50
+ .map { |p| { global: false, key: p.permission } }
51
+
52
+ # Map global permissions to hash
53
+ global_permissions = @global_permissions.map { |p| { global: true, key: p } }
54
+
55
+ # Combine hashes and ensure global permissions take priority
56
+ (global_permissions + entity_permissions).uniq { |p| p[:key] }
57
+ end
58
+
59
+ private
60
+
61
+ def parse_permissions_array(permissions_array)
62
+ return [] if permissions_array.nil?
63
+
64
+ permissions = []
65
+
66
+ permissions_array.each do |entity, action_array|
67
+ action_array.each do |action|
68
+ permissions << entity + ":" + action
69
+ end
70
+ end
71
+
72
+ permissions
73
+ end
74
+ end
75
+ end
76
+ 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,4 +1,8 @@
1
+ # frozen_string_literal: true
1
2
  module NexusCqrs
3
+ # Base class for all commands. Should declare value types for passing to handlers
4
+ #
5
+ # @since 0.1.0
2
6
  class BaseCommand < BaseMessage
3
7
  end
4
8
  end
@@ -1,5 +1,14 @@
1
+ # frozen_string_literal: true
1
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
2
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
3
12
  def call(command)
4
13
  end
5
14
  end
@@ -1,26 +1,52 @@
1
+ # frozen_string_literal: true
1
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
2
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
3
16
  def set_metadata(key, value)
4
17
  @metadata = {} unless @metadata
5
18
  @metadata[key.to_sym] = value
6
19
  end
7
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
8
25
  def metadata
9
26
  @metadata || {}
10
27
  end
11
28
 
12
- def to_h
13
- hash = instance_variables.each_with_object({}) do |var, acc|
14
- acc[var.to_s[1..-1].to_sym] = instance_variable_get(var)
15
- end
16
- hash[:metadata] = @metadata || {}
17
- hash
18
- end
19
-
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
20
39
  def policy_class
21
40
  demodularised_class_name + 'Policy'
22
41
  end
23
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
24
50
  def demodularised_class_name
25
51
  self.class.name.split('::').last
26
52
  end
@@ -1,10 +1,17 @@
1
+ # frozen_string_literal: true
1
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
2
7
  class BaseMiddleware
3
8
  def initialize(next_)
4
9
  @next = next_
5
10
  end
6
11
 
7
- # @param [BaseMessage] message
12
+ # Invoke middleware
13
+ #
14
+ # @param [BaseMessage] message Message object being passed to the bus
8
15
  def call(message)
9
16
  end
10
17
  end
@@ -1,4 +1,8 @@
1
+ # frozen_string_literal: true
1
2
  module NexusCqrs
3
+ # Base class for all queries. Should declare value types for passing to handlers
4
+ #
5
+ # @since 0.1.0
2
6
  class BaseQuery < BaseMessage
3
7
  end
4
8
  end
@@ -1,5 +1,14 @@
1
+ # frozen_string_literal: true
1
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
2
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
3
12
  def call(query)
4
13
  end
5
14
  end
@@ -1,7 +1,13 @@
1
+ # frozen_string_literal: true
1
2
  require 'thread_safe'
2
3
  require 'middleware'
3
4
 
4
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
5
11
  class CommandBus
6
12
  UnregisteredHandler = Class.new(StandardError)
7
13
  MultipleHandlers = Class.new(StandardError)
@@ -11,23 +17,44 @@ module NexusCqrs
11
17
  @middleware = middleware || Middleware::Builder.new
12
18
  end
13
19
 
14
- def register(klass, handler)
15
- 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]
16
29
 
17
- handlers[klass] = handler
30
+ handlers[message] = handler
18
31
  end
19
32
 
20
- 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)
21
39
  runner = Middleware::Builder.new
22
40
  runner.use(@middleware)
23
- runner.use(->(command_) { handler_for_command(command_).call(command_) })
24
- runner.call(command)
41
+ runner.use(->(message_) { handler_for_command(message_).call(message_) })
42
+ runner.call(message)
25
43
  end
26
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
27
50
  def registered_handlers
28
51
  handlers
29
52
  end
30
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
31
58
  def clear_handlers
32
59
  @handlers = ThreadSafe::Cache.new
33
60
  end
@@ -36,6 +63,9 @@ module NexusCqrs
36
63
 
37
64
  private
38
65
 
66
+ # Looks up the handler for a specific command
67
+ #
68
+ # @since 0.1.0
39
69
  def handler_for_command(command)
40
70
  unregistered_handler = proc { raise UnregisteredHandler, "Missing handler for #{command.class}" }
41
71
  handlers.fetch(command.class, &unregistered_handler)
@@ -1,20 +1,78 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module NexusCqrs
2
4
  class CommandExecutor
3
5
  # @param [NexusCqrs::CommandBus] command_bus
4
6
  def initialize(command_bus)
5
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)
14
+ end
15
+
16
+ def autoregister(base_class, dir = 'app/domain/commands', ignore_strings = ['.rb', 'app/domain/'])
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}" }
27
+ file.camelize.constantize
28
+ rescue NameError => e
29
+ @logger.warn { "Failed autoregister #{file.camelize}, received NameError: #{e}" }
30
+ end
31
+ end
32
+
33
+ ObjectSpace.each_object(Class).select { |klass| klass < base_class }.each do |c|
34
+ @logger.debug { "Attempting auto registration of #{c}" }
35
+ handler_name = (c.to_s + "Handler")
36
+ begin
37
+ handler = handler_name.constantize.new
38
+ rescue NameError
39
+ @logger.warn { "Command `#{c}` tried to autoregister `#{handler_name}` but the class was not found" }
40
+ next
41
+ end
42
+
43
+ if handler.respond_to?(:call)
44
+ register_command(c, handler)
45
+ else
46
+ @logger.warn { "Command `#{c}` tried to autoregister `#{handler_name}` but `call` method did not exist" }
47
+ end
48
+ end
49
+ end
50
+
51
+ # Returns the bus used by this executor
52
+ #
53
+ # @return [NexusCqrs::CommandBus] command_bus
54
+ def command_bus
55
+ @bus
6
56
  end
7
57
 
8
- # @param [NexusCqrs::BaseCommand] command
9
- def execute(command)
10
- @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)
11
63
  end
12
64
 
65
+ # Helper method for registering a handler on the bus
66
+ #
67
+ # @see CommandBus#register
68
+ # @param [NexusCqrs::BaseMessage] message
13
69
  # @param [NexusCqrs::BaseCommandHandler] handler
14
- def register_command(klass, handler)
15
- @bus.register(klass, handler)
70
+ def register_command(message, handler)
71
+ @bus.register(message, handler)
16
72
  end
17
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
18
76
  def reregister_handlers
19
77
  return if @register_handlers.nil?
20
78
 
@@ -22,6 +80,18 @@ module NexusCqrs
22
80
  @register_handlers.call(self)
23
81
  end
24
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
25
95
  def configure_handlers(&block)
26
96
  @register_handlers = block
27
97
  @register_handlers.call(self)
@@ -1,20 +1,36 @@
1
+ # frozen_string_literal: true
1
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.
2
5
  module Helpers
3
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))
4
11
  def execute(command)
5
12
  command_executor.execute(command)
6
13
  end
7
14
 
8
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))
9
20
  def query(query)
10
21
  query_executor.execute(query)
11
22
  end
12
23
 
13
- # 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
14
27
  def command_executor
15
28
  @command_executor ||= $COMMAND_EXECUTOR
16
29
  end
17
30
 
31
+ # Provide access to the CQRS query executor
32
+ #
33
+ # @return [NexusCqrs::CommandExecutor] Returns the executor used for queries
18
34
  def query_executor
19
35
  @query_executor ||= $QUERY_EXECUTOR
20
36
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
1
2
  module NexusCqrs
2
- VERSION = '0.2.2'
3
+ # Leave this as 0.4.2 in order for CI process to replace with the tagged version.
4
+ VERSION = '0.4.2'
3
5
  end
data/lib/nexus_cqrs.rb CHANGED
@@ -1,5 +1,10 @@
1
+ # frozen_string_literal: true
1
2
  require 'nexus_cqrs/base_message'
2
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'
3
8
  require 'nexus_cqrs/base_command'
4
9
  require 'nexus_cqrs/base_command_handler'
5
10
  require 'nexus_cqrs/base_query'
data/nexus_cqrs.gemspec CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require_relative 'lib/nexus_cqrs/version'
2
3
 
3
4
  Gem::Specification.new do |spec|
@@ -20,4 +21,6 @@ Gem::Specification.new do |spec|
20
21
  spec.add_dependency('generator_spec')
21
22
  spec.add_dependency('thread_safe')
22
23
  spec.add_dependency('ibsciss-middleware')
24
+ spec.add_dependency('pundit')
25
+ spec.add_dependency('strings-case')
23
26
  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.2.2
4
+ version: 0.4.2
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-08-25 00:00:00.000000000 Z
11
+ date: 2021-11-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: generator_spec
@@ -52,6 +52,34 @@ 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'
55
83
  description:
56
84
  email:
57
85
  - dean.lovett@nexusmods.com
@@ -79,6 +107,10 @@ files:
79
107
  - lib/generators/nexus_cqrs/templates/query_handler.rb
80
108
  - lib/generators/nexus_cqrs/templates/register_cqrs_handlers.rb
81
109
  - lib/nexus_cqrs.rb
110
+ - lib/nexus_cqrs/auth/auth.rb
111
+ - lib/nexus_cqrs/auth/ownable.rb
112
+ - lib/nexus_cqrs/auth/permission_provider.rb
113
+ - lib/nexus_cqrs/auth/user_context.rb
82
114
  - lib/nexus_cqrs/base_command.rb
83
115
  - lib/nexus_cqrs/base_command_handler.rb
84
116
  - lib/nexus_cqrs/base_message.rb
@@ -108,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
140
  - !ruby/object:Gem::Version
109
141
  version: '0'
110
142
  requirements: []
111
- rubygems_version: 3.2.26
143
+ rubygems_version: 3.2.31
112
144
  signing_key:
113
145
  specification_version: 4
114
146
  summary: Core package for the nexus cqrs gem