hub_kernel 0.9.0 → 0.11.0

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: 6c55c177c5ca371059cf35185392a5b283a314254dff6b18aa2d13f7c12793cd
4
- data.tar.gz: 23afef8f196447cecf97c1b2e0355aba897388784e2c1ddb80d6ff7ff9da7f98
3
+ metadata.gz: 582093847b3b8cf75dbadd831d8a68df615bebccce57eeb89be12d3496fc89f0
4
+ data.tar.gz: 4544cb83a72d80f920abbf78f6eee6984b4ab8d28e330d568eb86bd21ef97dc8
5
5
  SHA512:
6
- metadata.gz: 61caa0ec7d31d6fbd86dc005dae507895eb2a7e4a1f1c09b3be2976edbc8ff69b5f14108a007dffe38b501b4d773250544ed625457d87e8ed7cbdf7f811796ff
7
- data.tar.gz: 532c56d1800f02ab79faab68553fc75edd7f20bdcbc8677d2a4e7416dde7ec063ff79c94825db9bf99f10bd380b5874d52657e6beef5a03b8ef08532b6c71a93
6
+ metadata.gz: e5d72c62b32c671f49ae4babcc4755dd871fc4fcfaa35573c78689250ca6b27774bb9ce2db460405940774fc017d1abd742da4df1d646565f8df770d379deb00
7
+ data.tar.gz: 4d5dff84ef2be0d5ebc3a53ce43e22e42d5d73ad53b0601663139f3fc0a68876e8c874a1833c200d078b29684d0bcd4056f11dd84150c0181dc6462943e9262a
data/README.md CHANGED
@@ -116,6 +116,36 @@ Supplies.exposed("record_purchase").takes # => [:supplier_id, :bought_on, :lines
116
116
  Supplies.exposed("delete_everything") # => nil
117
117
  ```
118
118
 
119
+ ### Calling an exposed method by name
120
+ An interface such as a JSON API calls a hub through its exposed list, by name, with the
121
+ values a caller sent and the person and account the call is made for:
122
+
123
+ ```ruby
124
+ Supplies.call_exposed("record_purchase", values: { supplier_id: 4, bought_on: "2026-10-01", lines: [] }, person: current_person, account: current_account)
125
+ ```
126
+
127
+ It raises `HubKernel::UnexposedMethodError` for a name the hub does not expose,
128
+ `HubKernel::MissingArgumentError` for a call with no person, no account, or a value the
129
+ method requires, and passes on only the values the method is listed with. A hub raises
130
+ `HubKernel::Refused` with a reason when it will not do what was asked, and the reason
131
+ reaches the caller unchanged.
132
+
133
+ ### The host's permission check and account scope
134
+ Every call by name asks the host whether the person may take the action on the account,
135
+ and runs the method inside the host's scope for that account. The host sets both once
136
+ for the whole app:
137
+
138
+ ```ruby
139
+ HubKernel::Authz.check = ->(person, action, account) { Permissions.allow?(person, action, account) }
140
+ HubKernel::Context.scope = ->(account, &call) { Current.set(account: account, &call) }
141
+ ```
142
+
143
+ The action is named after the hub and the method, such as `supplies:record_purchase`. A
144
+ refused call raises `HubKernel::NotAllowed` before the method runs or its values are
145
+ checked, and a check that answers anything but true or false raises
146
+ `HubKernel::NonBooleanAnswerError`. A call made while either is unset raises
147
+ `HubKernel::UnwiredPortError` naming it.
148
+
119
149
  ### The exposed-list check
120
150
  A hub's own test can check its exposed list, so a listed method the hub lacks, or one
121
151
  listed with values it does not take, shows in the suite rather than on a caller's
@@ -134,6 +164,39 @@ end
134
164
  The test fails and names each problem. A method that takes `**` values accepts any
135
165
  values it is listed with, as long as it is listed with each value it requires.
136
166
 
167
+ ### The crossing check
168
+ A host's test can check that no hub's code names a class another hub owns. The host
169
+ passes a map of which classes each hub owns, and the test fails listing each file and
170
+ the class it names:
171
+
172
+ ```ruby
173
+ require "hub_kernel/conformance/crossings"
174
+
175
+ class HubCrossingsTest < ActiveSupport::TestCase
176
+ include HubKernel::Conformance::Crossings
177
+
178
+ crossings files: "app/{models,controllers,jobs,views}/shop/**/*.{rb,erb}", except: [ "app/models/shop/hubs.rb" ] do
179
+ HubKernel::Crossings.new(
180
+ owners: { supplies: %w[Shop::Purchase Shop::PurchasesController], finance: %w[Shop::Expense], shop: %w[Shop::Home] },
181
+ shared: %w[Shop::BaseController],
182
+ host_layer: :shop,
183
+ interfaces: { supplies: "Shop::Hubs::Supplies", finance: "Shop::Hubs::Finance" }
184
+ )
185
+ end
186
+ end
187
+ ```
188
+
189
+ - A constant inside another hub's class counts as naming that class.
190
+ - A name no hub owns, such as a namespace, is not a crossing, and neither is a class on
191
+ the `shared` list.
192
+ - A view belongs to the hub that owns its controller, then to the hub that owns the
193
+ record its folder is named after, and otherwise to the `host_layer`.
194
+ - The host's layer may name any hub's interface module, and a hub naming another hub's
195
+ interface module is a crossing.
196
+ - The test fails with "The crossing check found no files to read" when none of the files
197
+ it read belongs to a hub or the host's layer, so a wrong `files:` pattern or an empty
198
+ map does not pass unnoticed.
199
+
137
200
  ## Installation
138
201
  Add this line to your application's Gemfile:
139
202
 
@@ -1,7 +1,10 @@
1
1
  module HubKernel
2
2
  class NonBooleanAnswerError < StandardError; end
3
+ class NotAllowed < StandardError; end
3
4
 
4
5
  module Authz
6
+ singleton_class.attr_accessor :check
7
+
5
8
  module Vocabulary
6
9
  def gates(*actions)
7
10
  gated_actions.concat(actions)
@@ -0,0 +1,31 @@
1
+ require "active_support/concern"
2
+ require "hub_kernel/crossings"
3
+
4
+ module HubKernel
5
+ module Conformance
6
+ module Crossings
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def crossings(root: nil, files: "app/**/*.{rb,erb}", except: [], &block)
11
+ define_method(:the_crossings, &block)
12
+ define_method(:crossing_root) { root || Rails.root }
13
+ define_method(:crossing_files) { files }
14
+ define_method(:crossing_exceptions) { except }
15
+ end
16
+ end
17
+
18
+ included do
19
+ test "no file names a class another hub owns" do
20
+ paths = (Dir.glob(crossing_files, base: crossing_root) - crossing_exceptions).sort
21
+ flunk "The crossing check found no files to read" if paths.none? { |path| the_crossings.belongs?(path) }
22
+
23
+ found = paths.flat_map do |path|
24
+ the_crossings.in(path, File.read(File.join(crossing_root, path))).map { |name| "#{path} names #{name}" }
25
+ end
26
+ assert found.empty?, found.join("\n")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,5 +1,7 @@
1
1
  module HubKernel
2
2
  module Context
3
+ singleton_class.attr_accessor :scope
4
+
3
5
  class Null
4
6
  def actor
5
7
  :null_actor
@@ -0,0 +1,42 @@
1
+ require "active_support/core_ext/string/inflections"
2
+
3
+ module HubKernel
4
+ class Crossings
5
+ def initialize(owners:, shared: [], host_layer: nil, interfaces: {})
6
+ @owners = owners
7
+ @shared = shared
8
+ @host_layer = host_layer
9
+ @interfaces = interfaces
10
+ end
11
+
12
+ def in(path, source)
13
+ hub = owner_of_file(path)
14
+ source.scan(/[A-Z]\w*(?:::[A-Z]\w*)*/).filter_map { |name| owned_class_in(name) }.uniq.select { |name| crossing?(name, hub) }
15
+ end
16
+
17
+ def belongs?(path) = !owner_of_file(path).nil?
18
+
19
+ private
20
+
21
+ def crossing?(name, hub)
22
+ return false if @shared.include?(name) || owner_of(name) == hub
23
+
24
+ !(hub == @host_layer && @interfaces.value?(name))
25
+ end
26
+
27
+ def owned_class_in(name)
28
+ parts = name.split("::")
29
+ parts.size.downto(1).map { |size| parts.first(size).join("::") }.find { |candidate| owner_of(candidate) }
30
+ end
31
+
32
+ def owner_of(class_name) = @interfaces.key(class_name) || @owners.find { |_hub, classes| classes.include?(class_name) }&.first
33
+
34
+ def owner_of_file(path)
35
+ return owner_of_view_folder(File.dirname(path.delete_prefix("app/views/")).camelize) if path.start_with?("app/views/")
36
+
37
+ owner_of(path.sub(%r{\Aapp/[^/]+/}, "").delete_suffix(".rb").camelize)
38
+ end
39
+
40
+ def owner_of_view_folder(folder) = owner_of("#{folder}Controller") || owner_of(folder) || @host_layer
41
+ end
42
+ end
@@ -1,4 +1,12 @@
1
+ require "hub_kernel/action"
2
+ require "hub_kernel/authz"
3
+ require "hub_kernel/context"
4
+ require "hub_kernel/ports"
5
+
1
6
  module HubKernel
7
+ class UnexposedMethodError < StandardError; end
8
+ class Refused < StandardError; end
9
+
2
10
  module Exposes
3
11
  Exposed = Data.define(:name, :takes, :writes)
4
12
 
@@ -8,6 +16,16 @@ module HubKernel
8
16
 
9
17
  def exposed(name) = exposed_methods[name.to_s]
10
18
 
19
+ def call_exposed(name, values:, person:, account:)
20
+ raise MissingArgumentError, "A call by name needs a person" if person.nil?
21
+ raise MissingArgumentError, "A call by name needs an account" if account.nil?
22
+
23
+ exposure = exposed(name) || raise(UnexposedMethodError, "#{exposing_hub} does not expose #{name}")
24
+ refuse_unless_allowed(exposure, person, account)
25
+ refuse_missing_values(exposure, values)
26
+ within_account(account) { public_send(exposure.name, **values.slice(*exposure.takes)) }
27
+ end
28
+
11
29
  def exposure_problems
12
30
  exposed_methods.values.filter_map { |exposure| exposure_problem(exposure) }
13
31
  end
@@ -18,6 +36,25 @@ module HubKernel
18
36
 
19
37
  def exposing_hub = name.demodulize
20
38
 
39
+ def refuse_unless_allowed(exposure, person, account)
40
+ raise UnwiredPortError, "hub_kernel's permission check is not filled" unless Authz.check
41
+
42
+ answer = Authz.check.call(person, "#{exposing_hub.underscore}:#{exposure.name}", account)
43
+ raise NonBooleanAnswerError, "The permission check must answer true or false, got #{answer.inspect}" unless [ true, false ].include?(answer)
44
+ raise NotAllowed, "#{exposing_hub} #{exposure.name}" unless answer
45
+ end
46
+
47
+ def within_account(account, &call)
48
+ raise UnwiredPortError, "hub_kernel's account scope is not filled" unless Context.scope
49
+
50
+ Context.scope.call(account, &call)
51
+ end
52
+
53
+ def refuse_missing_values(exposure, values)
54
+ missing = keywords(exposure, :keyreq) - values.keys
55
+ raise MissingArgumentError, "Give #{missing.join(", ")}" if missing.any?
56
+ end
57
+
21
58
  def exposure_problem(exposure)
22
59
  return "#{exposing_hub} exposes #{exposure.name}, which it has no method for" unless respond_to?(exposure.name)
23
60
  return if takes_listed_values?(exposure)
@@ -1,3 +1,3 @@
1
1
  module HubKernel
2
- VERSION = "0.9.0"
2
+ VERSION = "0.11.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hub_kernel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -42,10 +42,12 @@ files:
42
42
  - lib/hub_kernel/answer.rb
43
43
  - lib/hub_kernel/authz.rb
44
44
  - lib/hub_kernel/conformance/action.rb
45
+ - lib/hub_kernel/conformance/crossings.rb
45
46
  - lib/hub_kernel/conformance/exposed.rb
46
47
  - lib/hub_kernel/conformance/hub.rb
47
48
  - lib/hub_kernel/conformance/partial.rb
48
49
  - lib/hub_kernel/context.rb
50
+ - lib/hub_kernel/crossings.rb
49
51
  - lib/hub_kernel/engine.rb
50
52
  - lib/hub_kernel/exposes.rb
51
53
  - lib/hub_kernel/follow_up.rb