hub_kernel 0.10.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: 269ad669e7c014af9175190d416dbf7be82bfb8d797eb3a25107bf87b99b1344
4
- data.tar.gz: 808639ddaca46f8d8cbe9f187a7d59ae9d50b704f773bf4f255bae731d4339a7
3
+ metadata.gz: 582093847b3b8cf75dbadd831d8a68df615bebccce57eeb89be12d3496fc89f0
4
+ data.tar.gz: 4544cb83a72d80f920abbf78f6eee6984b4ab8d28e330d568eb86bd21ef97dc8
5
5
  SHA512:
6
- metadata.gz: 3c94024970cd0ae83bf695824439a959b6b03061eb39f92268f483ca769fbf323d4210710f805f052028590e65b87a84b43b8801cac1a9b927a9402205f1fd55
7
- data.tar.gz: 307661997442f598ee6d2e931540336986a567794113298d496864f86d3ac19e2788ee091e75b8bc9434bd1c61bd3aa1df095b71af29d2479b7898869dfd416b
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
@@ -163,6 +193,9 @@ end
163
193
  record its folder is named after, and otherwise to the `host_layer`.
164
194
  - The host's layer may name any hub's interface module, and a hub naming another hub's
165
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.
166
199
 
167
200
  ## Installation
168
201
  Add this line to your application's Gemfile:
@@ -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)
@@ -17,10 +17,13 @@ module HubKernel
17
17
 
18
18
  included do
19
19
  test "no file names a class another hub owns" do
20
- found = (Dir.glob(crossing_files, base: crossing_root) - crossing_exceptions).sort.flat_map do |path|
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|
21
24
  the_crossings.in(path, File.read(File.join(crossing_root, path))).map { |name| "#{path} names #{name}" }
22
25
  end
23
- flunk found.join("\n") if found.any?
26
+ assert found.empty?, found.join("\n")
24
27
  end
25
28
  end
26
29
  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
@@ -14,6 +14,8 @@ module HubKernel
14
14
  source.scan(/[A-Z]\w*(?:::[A-Z]\w*)*/).filter_map { |name| owned_class_in(name) }.uniq.select { |name| crossing?(name, hub) }
15
15
  end
16
16
 
17
+ def belongs?(path) = !owner_of_file(path).nil?
18
+
17
19
  private
18
20
 
19
21
  def crossing?(name, hub)
@@ -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.10.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.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider