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 +4 -4
- data/README.md +63 -0
- data/lib/hub_kernel/authz.rb +3 -0
- data/lib/hub_kernel/conformance/crossings.rb +31 -0
- data/lib/hub_kernel/context.rb +2 -0
- data/lib/hub_kernel/crossings.rb +42 -0
- data/lib/hub_kernel/exposes.rb +37 -0
- data/lib/hub_kernel/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 582093847b3b8cf75dbadd831d8a68df615bebccce57eeb89be12d3496fc89f0
|
|
4
|
+
data.tar.gz: 4544cb83a72d80f920abbf78f6eee6984b4ab8d28e330d568eb86bd21ef97dc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/hub_kernel/authz.rb
CHANGED
|
@@ -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
|
data/lib/hub_kernel/context.rb
CHANGED
|
@@ -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
|
data/lib/hub_kernel/exposes.rb
CHANGED
|
@@ -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)
|
data/lib/hub_kernel/version.rb
CHANGED
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.
|
|
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
|