ruby-object-framework 1.0.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 +7 -0
- data/API.md +78 -0
- data/README.md +250 -0
- data/THIRD_PARTY_NOTICES.md +45 -0
- data/examples/guides/01_plain_use_case.rb +13 -0
- data/examples/guides/02_composed_application.rb +25 -0
- data/examples/guides/03_order_domain.rb +14 -0
- data/examples/guides/04_scoped_resources.rb +38 -0
- data/examples/guides/05_mapping.rb +39 -0
- data/examples/guides/06_associations.rb +49 -0
- data/examples/guides/07_http.rb +17 -0
- data/examples/guides/08_errors.rb +20 -0
- data/examples/guides/09_sqlite.rb +38 -0
- data/examples/guides/10_grpc.rb +26 -0
- data/examples/guides/11_ractor.rb +15 -0
- data/examples/guides/12_box.rb +11 -0
- data/examples/guides/13_testing.rb +24 -0
- data/examples/guides/manifest.yml +62 -0
- data/examples/orders/application.rb +51 -0
- data/examples/orders/contracts.rb +19 -0
- data/examples/orders/domain.rb +35 -0
- data/examples/orders/export_proto.rb +5 -0
- data/examples/orders/grpc_client.py +24 -0
- data/examples/orders/grpc_server.rb +23 -0
- data/examples/orders/server.rb +18 -0
- data/lib/object_framework/application.rb +136 -0
- data/lib/object_framework/capabilities.rb +59 -0
- data/lib/object_framework/errors.rb +85 -0
- data/lib/object_framework/execution.rb +184 -0
- data/lib/object_framework/grpc/messages.rb +30 -0
- data/lib/object_framework/grpc/server.rb +264 -0
- data/lib/object_framework/grpc/service.rb +55 -0
- data/lib/object_framework/http/io.rb +127 -0
- data/lib/object_framework/http/presentation.rb +55 -0
- data/lib/object_framework/http/protocol.rb +209 -0
- data/lib/object_framework/http/router.rb +80 -0
- data/lib/object_framework/http/server.rb +279 -0
- data/lib/object_framework/http2/connection.rb +399 -0
- data/lib/object_framework/http2/frame.rb +21 -0
- data/lib/object_framework/http2/hpack.rb +206 -0
- data/lib/object_framework/http2/hpack_tables.rb +333 -0
- data/lib/object_framework/http2/inbound.rb +75 -0
- data/lib/object_framework/memory/storage.rb +97 -0
- data/lib/object_framework/persistence/mapping.rb +150 -0
- data/lib/object_framework/persistence/query.rb +97 -0
- data/lib/object_framework/persistence/session.rb +173 -0
- data/lib/object_framework/persistence/transaction.rb +54 -0
- data/lib/object_framework/protobuf.rb +268 -0
- data/lib/object_framework/sqlite/compiler.rb +77 -0
- data/lib/object_framework/sqlite/schema.rb +95 -0
- data/lib/object_framework/sqlite/storage.rb +220 -0
- data/lib/object_framework/sqlite.rb +6 -0
- data/lib/object_framework/version.rb +5 -0
- data/lib/object_framework.rb +31 -0
- metadata +105 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "object_framework"
|
|
4
|
+
|
|
5
|
+
class SumNumbers
|
|
6
|
+
def self.call(input)
|
|
7
|
+
{ total: input[:numbers].sum }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
owner = Thread.current.object_id
|
|
12
|
+
task = ObjectFramework::Capabilities::IsolatedTask.new(task: SumNumbers)
|
|
13
|
+
result = task.call({ numbers: [1, 2, 3] })
|
|
14
|
+
puts "Total: #{result[:total]}"
|
|
15
|
+
puts "Request thread preserved: #{Thread.current.object_id == owner}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "object_framework"
|
|
4
|
+
|
|
5
|
+
first = ObjectFramework::Capabilities::Definitions.new
|
|
6
|
+
second = ObjectFramework::Capabilities::Definitions.new
|
|
7
|
+
first.evaluate("class Name; def self.call; 'first application'; end; end")
|
|
8
|
+
second.evaluate("class Name; def self.call; 'second application'; end; end")
|
|
9
|
+
puts first.constant(:Name).call
|
|
10
|
+
puts second.constant(:Name).call
|
|
11
|
+
puts "Definitions leaked: #{Object.const_defined?(:Name)}"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../test/support/spec"
|
|
4
|
+
|
|
5
|
+
class PriceWithTax
|
|
6
|
+
def call(cents:)
|
|
7
|
+
raise ArgumentError, "Price must be nonnegative" if cents.negative?
|
|
8
|
+
cents + cents / 5
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
Spec.test "a zero price remains zero" do
|
|
13
|
+
assert_equal 0, PriceWithTax.new.call(cents: 0)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Spec.test "a price includes twenty percent tax" do
|
|
17
|
+
assert_equal 1200, PriceWithTax.new.call(cents: 1000)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Spec.test "negative prices are rejected" do
|
|
21
|
+
assert_raises(ArgumentError) { PriceWithTax.new.call(cents: -1) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
exit Spec.run
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
- file: 01_plain_use_case.rb
|
|
2
|
+
output: |
|
|
3
|
+
Hello, Ada
|
|
4
|
+
- file: 02_composed_application.rb
|
|
5
|
+
output: |
|
|
6
|
+
Hello, Ada
|
|
7
|
+
Result is frozen: true
|
|
8
|
+
- file: 03_order_domain.rb
|
|
9
|
+
output: |
|
|
10
|
+
confirmed
|
|
11
|
+
An order can only be confirmed once
|
|
12
|
+
- file: 04_scoped_resources.rb
|
|
13
|
+
output: |
|
|
14
|
+
Same resource: true
|
|
15
|
+
opened, closed
|
|
16
|
+
- file: 05_mapping.rb
|
|
17
|
+
output: |
|
|
18
|
+
UUID assigned: true
|
|
19
|
+
Same identity: true
|
|
20
|
+
Independent session: true
|
|
21
|
+
Stored state: pending
|
|
22
|
+
- file: 06_associations.rb
|
|
23
|
+
output: |
|
|
24
|
+
Customer: Ada
|
|
25
|
+
Shared identity: true
|
|
26
|
+
- file: 07_http.rb
|
|
27
|
+
output: |
|
|
28
|
+
HTTP 200
|
|
29
|
+
Order state: confirmed
|
|
30
|
+
- file: 08_errors.rb
|
|
31
|
+
output: |
|
|
32
|
+
404 not_found
|
|
33
|
+
Occurrence ID present: true
|
|
34
|
+
409 order_not_confirmable
|
|
35
|
+
Occurrence ID present: true
|
|
36
|
+
- file: 09_sqlite.rb
|
|
37
|
+
output: |
|
|
38
|
+
Schema version: 2
|
|
39
|
+
Read: pending
|
|
40
|
+
Write: confirmed
|
|
41
|
+
Unmapped note: gift
|
|
42
|
+
Open connections: 0
|
|
43
|
+
- file: 10_grpc.rb
|
|
44
|
+
output: |
|
|
45
|
+
ready: confirmed
|
|
46
|
+
- file: 11_ractor.rb
|
|
47
|
+
output: |
|
|
48
|
+
Total: 6
|
|
49
|
+
Request thread preserved: true
|
|
50
|
+
- file: 12_box.rb
|
|
51
|
+
env:
|
|
52
|
+
RUBY_BOX: '1'
|
|
53
|
+
output: |
|
|
54
|
+
first application
|
|
55
|
+
second application
|
|
56
|
+
Definitions leaked: false
|
|
57
|
+
- file: 13_testing.rb
|
|
58
|
+
output: |
|
|
59
|
+
PASS a zero price remains zero
|
|
60
|
+
PASS a price includes twenty percent tax
|
|
61
|
+
PASS negative prices are rejected
|
|
62
|
+
3 tests, 0 failures
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "object_framework"
|
|
4
|
+
require_relative "domain"
|
|
5
|
+
|
|
6
|
+
module OrdersExample
|
|
7
|
+
class Orders
|
|
8
|
+
def initialize(session:)
|
|
9
|
+
@session = session
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fetch(id)
|
|
13
|
+
@session.find(Order, id)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Bundle = Data.define(:application, :server, :storage)
|
|
18
|
+
|
|
19
|
+
def self.build(port: 0, storage: ObjectFramework::Memory::Storage.new)
|
|
20
|
+
mappings = ObjectFramework::Persistence::Mappings.new
|
|
21
|
+
mappings.define(Order, table: :orders) do
|
|
22
|
+
field :id
|
|
23
|
+
field :state
|
|
24
|
+
field :total_cents, type: :integer
|
|
25
|
+
end
|
|
26
|
+
app = ObjectFramework::Application.new(storage:, mappings:)
|
|
27
|
+
app.errors.register(OrderNotConfirmable, category: :conflict, code: "order_not_confirmable", title: "Order cannot be confirmed")
|
|
28
|
+
app.factory(:orders, dependencies: [:session]) { |session:| Orders.new(session:) }
|
|
29
|
+
app.operation(:confirm_order, use_case: ConfirmOrder, policy: :write, dependencies: [:orders])
|
|
30
|
+
parameters = ObjectFramework::HTTP::Parameters.new do
|
|
31
|
+
field :order_id, source: :path, key: :id, type: String
|
|
32
|
+
end
|
|
33
|
+
controller = ObjectFramework::HTTP::Controller.new(application: app, operation: :confirm_order, parameters:)
|
|
34
|
+
router = ObjectFramework::HTTP::Router.new
|
|
35
|
+
router.controller(:confirm_order, controller)
|
|
36
|
+
router.route("POST", "/orders/:id/confirm", controller: :confirm_order)
|
|
37
|
+
server = ObjectFramework::HTTP::Server.new(application: app, router:, port:)
|
|
38
|
+
app.install(server)
|
|
39
|
+
seed(storage)
|
|
40
|
+
Bundle.new(application: app, server:, storage:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.seed(storage)
|
|
44
|
+
transaction = storage.begin_transaction(mode: :write)
|
|
45
|
+
transaction.insert(:orders, { id: "ready", state: "pending", total_cents: 1500 })
|
|
46
|
+
transaction.insert(:orders, { id: "rejected", state: "cancelled", total_cents: 500 })
|
|
47
|
+
transaction.commit
|
|
48
|
+
ensure
|
|
49
|
+
transaction&.close
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "object_framework"
|
|
4
|
+
|
|
5
|
+
module OrdersExample
|
|
6
|
+
def self.grpc_contract
|
|
7
|
+
request = ObjectFramework::Protobuf::Message.new("ConfirmOrderRequest") do
|
|
8
|
+
field :order_id, 1, :string
|
|
9
|
+
end
|
|
10
|
+
confirmation = ObjectFramework::Protobuf::Message.new("OrderConfirmation") do
|
|
11
|
+
field :id, 1, :string
|
|
12
|
+
field :state, 2, :string
|
|
13
|
+
field :total_cents, 3, :int64
|
|
14
|
+
end
|
|
15
|
+
ObjectFramework::GRPC::Service.new("orders.Orders").tap do |service|
|
|
16
|
+
service.rpc(:ConfirmOrder, input: request, output: confirmation, controller: :confirm_order)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OrdersExample
|
|
4
|
+
class OrderNotConfirmable < StandardError; end
|
|
5
|
+
|
|
6
|
+
class Order
|
|
7
|
+
attr_accessor :id
|
|
8
|
+
attr_reader :state, :total_cents
|
|
9
|
+
|
|
10
|
+
def initialize(id: nil, state: "pending", total_cents:)
|
|
11
|
+
raise ArgumentError, "Total must be a nonnegative integer" unless total_cents.is_a?(Integer) && total_cents >= 0
|
|
12
|
+
raise ArgumentError, "Unknown order state" unless %w[pending confirmed cancelled].include?(state)
|
|
13
|
+
@id, @state, @total_cents = id, state, total_cents
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def confirm
|
|
17
|
+
raise OrderNotConfirmable unless state == "pending" && total_cents.positive?
|
|
18
|
+
@state = "confirmed"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Confirmation = Data.define(:id, :state, :total_cents)
|
|
23
|
+
|
|
24
|
+
class ConfirmOrder
|
|
25
|
+
def initialize(orders:)
|
|
26
|
+
@orders = orders
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(order_id:)
|
|
30
|
+
order = @orders.fetch(order_id)
|
|
31
|
+
order.confirm
|
|
32
|
+
Confirmation.new(id: order.id, state: order.state, total_cents: order.total_cents)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Confirm an order with grpcio and a contract exported by the Ruby server."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
import grpc
|
|
9
|
+
from grpc_tools import protoc
|
|
10
|
+
|
|
11
|
+
port, contract, order_id = int(sys.argv[1]), pathlib.Path(sys.argv[2]).resolve(), sys.argv[3]
|
|
12
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
13
|
+
if protoc.main(["protoc", f"-I{contract.parent}", f"--python_out={directory}", str(contract)]) != 0:
|
|
14
|
+
raise RuntimeError("Contract compilation failed")
|
|
15
|
+
sys.path.insert(0, directory)
|
|
16
|
+
messages = importlib.import_module(contract.stem + "_pb2")
|
|
17
|
+
with grpc.insecure_channel(f"127.0.0.1:{port}") as channel:
|
|
18
|
+
confirm = channel.unary_unary(
|
|
19
|
+
"/orders.Orders/ConfirmOrder",
|
|
20
|
+
request_serializer=messages.ConfirmOrderRequest.SerializeToString,
|
|
21
|
+
response_deserializer=messages.OrderConfirmation.FromString,
|
|
22
|
+
)
|
|
23
|
+
result = confirm(messages.ConfirmOrderRequest(order_id=order_id), timeout=3)
|
|
24
|
+
print(f"{result.id}: {result.state}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "application"
|
|
4
|
+
require_relative "contracts"
|
|
5
|
+
|
|
6
|
+
bundle = OrdersExample.build(port: Integer(ENV.fetch("HTTP_PORT", "8080")))
|
|
7
|
+
controller = ObjectFramework::GRPC::Controller.new(application: bundle.application, operation: :confirm_order)
|
|
8
|
+
grpc = ObjectFramework::GRPC::Server.new(application: bundle.application,
|
|
9
|
+
services: [OrdersExample.grpc_contract], controllers: { confirm_order: controller },
|
|
10
|
+
port: Integer(ENV.fetch("GRPC_PORT", "50051")))
|
|
11
|
+
bundle.application.install(grpc)
|
|
12
|
+
bundle.application.start
|
|
13
|
+
puts "HTTP order API: http://127.0.0.1:#{bundle.server.port}"
|
|
14
|
+
puts "gRPC order API: 127.0.0.1:#{grpc.port} (orders.Orders/ConfirmOrder)"
|
|
15
|
+
|
|
16
|
+
signals = Queue.new
|
|
17
|
+
%w[INT TERM].each { |signal| Signal.trap(signal) { signals << signal } }
|
|
18
|
+
begin
|
|
19
|
+
signals.pop
|
|
20
|
+
ensure
|
|
21
|
+
bundle.application.stop
|
|
22
|
+
bundle.storage.close
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "application"
|
|
4
|
+
|
|
5
|
+
bundle = OrdersExample.build(port: Integer(ENV.fetch("PORT", "8080")))
|
|
6
|
+
bundle.application.start
|
|
7
|
+
puts "Order confirmation API: http://127.0.0.1:#{bundle.server.port}"
|
|
8
|
+
puts "POST /orders/ready/confirm; POST /orders/rejected/confirm"
|
|
9
|
+
|
|
10
|
+
# Signal handlers only wake the main thread; resource cleanup stays in normal Ruby code.
|
|
11
|
+
signals = Queue.new
|
|
12
|
+
%w[INT TERM].each { |signal| Signal.trap(signal) { signals << signal } }
|
|
13
|
+
begin
|
|
14
|
+
signals.pop
|
|
15
|
+
ensure
|
|
16
|
+
bundle.application.stop
|
|
17
|
+
bundle.storage.close
|
|
18
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
Factory = Data.define(:dependencies, :build, :close)
|
|
5
|
+
Operation = Data.define(:use_case, :policy, :dependencies)
|
|
6
|
+
|
|
7
|
+
class Application
|
|
8
|
+
attr_reader :errors, :reporter
|
|
9
|
+
|
|
10
|
+
def initialize(storage: nil, mappings: nil, errors: ErrorRegistry.new, reporter: nil)
|
|
11
|
+
@storage, @mappings, @errors = storage, mappings, errors
|
|
12
|
+
@reporter = reporter || FailureReporter.new(registry: errors)
|
|
13
|
+
@factories, @operations, @modules, @started_modules = {}, {}, {}, []
|
|
14
|
+
@started, @finalized = false, false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def factory(name, dependencies: [], close: false, &build)
|
|
18
|
+
mutable!
|
|
19
|
+
raise ConfigurationError, "Reserved execution dependency: #{name}" if %i[context session transaction].include?(name)
|
|
20
|
+
raise ConfigurationError, "Factory #{name} already registered" if @factories.key?(name)
|
|
21
|
+
raise ConfigurationError, "Factory requires a block" unless build
|
|
22
|
+
@factories[name] = Factory.new(dependencies: dependencies.dup.freeze, build:, close:)
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def operation(name, use_case:, policy:, dependencies: [])
|
|
27
|
+
mutable!
|
|
28
|
+
raise ConfigurationError, "Operation #{name} already registered" if @operations.key?(name)
|
|
29
|
+
raise ConfigurationError, "Unknown transaction policy" unless %i[none read write].include?(policy)
|
|
30
|
+
@operations[name] = Operation.new(use_case:, policy:, dependencies: dependencies.dup.freeze)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def install(mod)
|
|
35
|
+
mutable!
|
|
36
|
+
raise ConfigurationError, "Module #{mod.name} already registered" if @modules.key?(mod.name)
|
|
37
|
+
@modules[mod.name] = mod
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def start
|
|
42
|
+
raise ConfigurationError, "Application is already started" if @started
|
|
43
|
+
unless @finalized
|
|
44
|
+
modules = ordered_modules
|
|
45
|
+
modules.each { |mod| mod.configure(self) }
|
|
46
|
+
validate!
|
|
47
|
+
modules.each { |mod| mod.validate! if mod.respond_to?(:validate!) }
|
|
48
|
+
@factories.freeze
|
|
49
|
+
@operations.freeze
|
|
50
|
+
@modules.freeze
|
|
51
|
+
@errors.validate!
|
|
52
|
+
@finalized = true
|
|
53
|
+
end
|
|
54
|
+
ordered_modules.each do |mod|
|
|
55
|
+
mod.start
|
|
56
|
+
@started_modules << mod
|
|
57
|
+
end
|
|
58
|
+
@started = true
|
|
59
|
+
self
|
|
60
|
+
rescue Exception
|
|
61
|
+
stop
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def stop
|
|
66
|
+
error = nil
|
|
67
|
+
@started_modules.reverse_each do |mod|
|
|
68
|
+
mod.stop
|
|
69
|
+
rescue StandardError => failure
|
|
70
|
+
error ||= failure
|
|
71
|
+
end
|
|
72
|
+
@started_modules.clear
|
|
73
|
+
@started = false
|
|
74
|
+
raise error if error && !$!
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def invoke(name, context: ExecutionContext.new, **arguments)
|
|
79
|
+
raise ConfigurationError, "Application is not started" unless @started
|
|
80
|
+
operation = @operations.fetch(name) { raise ConfigurationError, "Unknown operation: #{name}" }
|
|
81
|
+
Invocation.new(operation:, factories: @factories, storage: @storage, reporter: @reporter,
|
|
82
|
+
mappings: @mappings).call(context:, **arguments)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_operation!(name)
|
|
86
|
+
operation = @operations.fetch(name) { raise ConfigurationError, "Unknown operation: #{name}" }
|
|
87
|
+
unless operation.use_case.is_a?(Class) && operation.use_case.public_instance_methods.include?(:call)
|
|
88
|
+
raise ConfigurationError, "Use cases must be classes implementing call"
|
|
89
|
+
end
|
|
90
|
+
operation
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def mutable!
|
|
96
|
+
raise ConfigurationError, "Application registrations are finalized" if @finalized
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate!
|
|
100
|
+
@mappings&.validate!
|
|
101
|
+
@factories.each_key { |name| validate_dependency(name, []) }
|
|
102
|
+
@operations.each do |name, operation|
|
|
103
|
+
validate_operation!(name)
|
|
104
|
+
if operation.policy != :none && !@storage
|
|
105
|
+
raise ConfigurationError, "Transactional operations require storage"
|
|
106
|
+
end
|
|
107
|
+
operation.dependencies.each { |name| validate_dependency(name, [], policy: operation.policy) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_dependency(name, visiting, policy: nil)
|
|
112
|
+
return if name == :context
|
|
113
|
+
if %i[session transaction].include?(name)
|
|
114
|
+
raise ConfigurationError, "Storage dependency requires a transaction" if policy == :none
|
|
115
|
+
raise ConfigurationError, "Session requires mappings" if name == :session && !@mappings
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
raise ConfigurationError, "Dependency cycle: #{(visiting + [name]).join(' -> ')}" if visiting.include?(name)
|
|
119
|
+
factory = @factories.fetch(name) { raise ConfigurationError, "Unknown dependency: #{name}" }
|
|
120
|
+
factory.dependencies.each { |dependency| validate_dependency(dependency, visiting + [name], policy:) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def ordered_modules
|
|
124
|
+
result = []
|
|
125
|
+
visit = lambda do |name, path|
|
|
126
|
+
raise ConfigurationError, "Module dependency cycle" if path.include?(name)
|
|
127
|
+
mod = @modules.fetch(name) { raise ConfigurationError, "Missing module: #{name}" }
|
|
128
|
+
return if result.include?(mod)
|
|
129
|
+
mod.requires.each { |dependency| visit.call(dependency, path + [name]) }
|
|
130
|
+
result << mod
|
|
131
|
+
end
|
|
132
|
+
@modules.each_key { |name| visit.call(name, []) }
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
module Capabilities
|
|
5
|
+
class Unsupported < ConfigurationError; end
|
|
6
|
+
|
|
7
|
+
class TaskFailure < StandardError
|
|
8
|
+
attr_reader :remote_class
|
|
9
|
+
def initialize(remote_class:, message:, backtrace:)
|
|
10
|
+
@remote_class = remote_class
|
|
11
|
+
super(message)
|
|
12
|
+
set_backtrace(backtrace)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.report
|
|
17
|
+
{ ruby_box: Ruby::Box.enabled?, ractor: defined?(::Ractor) != nil,
|
|
18
|
+
yjit: RubyVM::YJIT.enabled?, zjit: RubyVM::ZJIT.enabled? }.freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Definitions
|
|
22
|
+
def initialize
|
|
23
|
+
raise Unsupported, "Enable Ruby Box at process boot with RUBY_BOX=1" unless Ruby::Box.enabled?
|
|
24
|
+
@box = Ruby::Box.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def load(path); @box.load(File.expand_path(path)); end
|
|
28
|
+
def require(path); @box.require(path); end
|
|
29
|
+
def evaluate(source); @box.eval(source); end
|
|
30
|
+
def constant(name); @box.const_get(name, false); end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class IsolatedTask
|
|
34
|
+
def initialize(task:)
|
|
35
|
+
raise ConfigurationError, "An isolated task must be a class or module implementing call" unless task.is_a?(Module) && task.respond_to?(:call)
|
|
36
|
+
@task = task
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(input, context: nil)
|
|
40
|
+
context&.cancellation&.check!
|
|
41
|
+
payload = Values.detach(input)
|
|
42
|
+
# Only independent values cross this boundary; the request keeps its session.
|
|
43
|
+
ractor = Ractor.new(@task, payload) do |task, values|
|
|
44
|
+
begin
|
|
45
|
+
[:success, ObjectFramework::Values.detach(task.call(values))]
|
|
46
|
+
rescue StandardError => error
|
|
47
|
+
[:failure, error.class.name, error.message, error.backtrace]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
outcome, *result = ractor.value
|
|
51
|
+
context&.cancellation&.check!
|
|
52
|
+
if outcome == :failure
|
|
53
|
+
raise TaskFailure.new(remote_class: result[0], message: result[1], backtrace: result[2])
|
|
54
|
+
end
|
|
55
|
+
Values.detach(result.first)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
class ConfigurationError < StandardError; end
|
|
5
|
+
class InvalidInput < StandardError; end
|
|
6
|
+
class NotFound < StandardError; end
|
|
7
|
+
class Conflict < StandardError; end
|
|
8
|
+
class Duplicate < StandardError; end
|
|
9
|
+
class Unavailable < StandardError; end
|
|
10
|
+
class CapacityExceeded < StandardError; end
|
|
11
|
+
class DeadlineExceeded < StandardError; end
|
|
12
|
+
class Cancelled < StandardError; end
|
|
13
|
+
class ClosedSession < StandardError; end
|
|
14
|
+
class UnsafeResult < StandardError; end
|
|
15
|
+
|
|
16
|
+
ErrorDescription = Data.define(:category, :code, :title, :details)
|
|
17
|
+
|
|
18
|
+
class ErrorRegistry
|
|
19
|
+
CATEGORIES = %i[bad_request invalid_input unauthenticated forbidden not_found conflict
|
|
20
|
+
duplicate transaction_conflict rate_limited capacity unavailable
|
|
21
|
+
deadline cancelled unimplemented internal].freeze
|
|
22
|
+
DEFAULTS = {
|
|
23
|
+
InvalidInput => [:invalid_input, "invalid_input", "Invalid input"],
|
|
24
|
+
NotFound => [:not_found, "not_found", "Resource not found"],
|
|
25
|
+
Conflict => [:transaction_conflict, "transaction_conflict", "Transaction conflict"],
|
|
26
|
+
Duplicate => [:duplicate, "duplicate", "Resource already exists"],
|
|
27
|
+
Unavailable => [:unavailable, "unavailable", "Storage unavailable"],
|
|
28
|
+
CapacityExceeded => [:capacity, "capacity_exceeded", "Capacity exhausted"],
|
|
29
|
+
DeadlineExceeded => [:deadline, "deadline_exceeded", "Deadline exceeded"],
|
|
30
|
+
Cancelled => [:cancelled, "cancelled", "Operation cancelled"]
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@registrations = {}
|
|
35
|
+
DEFAULTS.each { |type, (category, code, title)| register(type, category:, code:, title:) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def register(type, category:, code:, title:, details: ->(_) { {} })
|
|
39
|
+
raise ConfigurationError, "Error registry is finalized" if @registrations.frozen?
|
|
40
|
+
raise ConfigurationError, "Expected an exception class" unless type.is_a?(Class) && type <= StandardError
|
|
41
|
+
raise ConfigurationError, "Duplicate error registration: #{type}" if @registrations.key?(type)
|
|
42
|
+
raise ConfigurationError, "Unknown error category: #{category}" unless CATEGORIES.include?(category)
|
|
43
|
+
raise ConfigurationError, "A stable code and title are required" if code.to_s.empty? || title.to_s.empty?
|
|
44
|
+
@registrations[type] = [category, code.to_s.dup.freeze, title.to_s.dup.freeze, details].freeze
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate!
|
|
49
|
+
codes = @registrations.values.map { |registration| registration[1] }
|
|
50
|
+
raise ConfigurationError, "Ambiguous public error codes" unless codes.uniq.size == codes.size
|
|
51
|
+
@registrations.freeze
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resolve(exception)
|
|
56
|
+
type = exception.class.ancestors.find { |ancestor| @registrations.key?(ancestor) }
|
|
57
|
+
return ErrorDescription.new(category: :internal, code: "internal_error", title: "Internal server error", details: {}) unless type
|
|
58
|
+
|
|
59
|
+
category, code, title, details = @registrations.fetch(type)
|
|
60
|
+
ErrorDescription.new(category:, code:, title:, details: Values.detach(details.call(exception)))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class FailureReporter
|
|
65
|
+
def initialize(sink: ->(record) {}, registry: ErrorRegistry.new)
|
|
66
|
+
@sink, @registry = sink, registry
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def report(exception, context:, secondary: false)
|
|
70
|
+
return if !secondary && !context.claim_failure
|
|
71
|
+
|
|
72
|
+
# Messages and argument values are deliberately absent: they can contain secrets.
|
|
73
|
+
record = {
|
|
74
|
+
operation_id: context.operation_id, code: @registry.resolve(exception).code,
|
|
75
|
+
exception: exception.class.name, cause: exception.cause&.class&.name,
|
|
76
|
+
backtrace: exception.backtrace&.dup, duration: context.elapsed,
|
|
77
|
+
transaction_state: context.transaction_state, secondary:
|
|
78
|
+
}
|
|
79
|
+
@sink.call(Values.detach(record))
|
|
80
|
+
rescue StandardError
|
|
81
|
+
# Diagnostics must never replace the exception being reported.
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|