nexus_cqrs 0.2.2 → 0.4.2

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: 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