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,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
module Values
|
|
5
|
+
# Storage values are immutable; domain state receives its own editable copy.
|
|
6
|
+
def self.mutable_copy(value)
|
|
7
|
+
case value
|
|
8
|
+
when String then value.dup
|
|
9
|
+
when Array then value.map { |item| mutable_copy(item) }
|
|
10
|
+
when Hash then value.to_h { |key, item| [mutable_copy(key), mutable_copy(item)] }
|
|
11
|
+
when Data then value.class.new(**mutable_copy(value.to_h))
|
|
12
|
+
else value
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.detach(value, visiting = {})
|
|
17
|
+
composite = value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(Data)
|
|
18
|
+
if composite
|
|
19
|
+
raise UnsafeResult, "Public values cannot contain cycles" if visiting.key?(value.object_id)
|
|
20
|
+
visiting[value.object_id] = true
|
|
21
|
+
end
|
|
22
|
+
case value
|
|
23
|
+
when nil, true, false, Symbol, Integer, Float then value
|
|
24
|
+
when String then value.dup.freeze
|
|
25
|
+
when Array then value.map { |item| detach(item, visiting) }.freeze
|
|
26
|
+
when Hash then value.to_h { |key, item| [detach(key, visiting), detach(item, visiting)] }.freeze
|
|
27
|
+
when Data then value.class.new(**detach(value.to_h, visiting))
|
|
28
|
+
else raise UnsafeResult, "Public results must contain independent values (#{value.class})"
|
|
29
|
+
end
|
|
30
|
+
ensure
|
|
31
|
+
visiting.delete(value.object_id) if composite
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class Cancellation
|
|
36
|
+
def initialize(deadline: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
37
|
+
@deadline, @clock, @mutex, @cancelled = deadline, clock, Mutex.new, false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cancel
|
|
41
|
+
@mutex.synchronize { @cancelled = true }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def deadline
|
|
45
|
+
@mutex.synchronize { @deadline }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def deadline=(deadline)
|
|
49
|
+
@mutex.synchronize { @deadline = deadline }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check!
|
|
53
|
+
raise Cancelled, "Operation cancelled" if @mutex.synchronize { @cancelled }
|
|
54
|
+
limit = deadline
|
|
55
|
+
raise DeadlineExceeded, "Deadline exceeded" if limit && @clock.call >= limit
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class ExecutionContext
|
|
60
|
+
attr_reader :operation_id, :cancellation
|
|
61
|
+
attr_accessor :transaction_state
|
|
62
|
+
|
|
63
|
+
def initialize(cancellation: Cancellation.new)
|
|
64
|
+
@operation_id = SecureRandom.uuid.freeze
|
|
65
|
+
@cancellation, @started_at = cancellation, monotonic
|
|
66
|
+
@transaction_state, @failure_reported = :none, false
|
|
67
|
+
@mutex = Mutex.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def elapsed
|
|
71
|
+
monotonic - @started_at
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def claim_failure
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
return false if @failure_reported
|
|
77
|
+
@failure_reported = true
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def monotonic
|
|
84
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class Scope
|
|
89
|
+
def initialize(factories, context:)
|
|
90
|
+
@factories, @context, @instances, @resources = factories, context, { context: }, []
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def provide(name, instance)
|
|
94
|
+
@instances[name] = instance
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolve(name)
|
|
98
|
+
return @instances.fetch(name) if @instances.key?(name)
|
|
99
|
+
factory = @factories.fetch(name)
|
|
100
|
+
arguments = factory.dependencies.to_h { |dependency| [dependency, resolve(dependency)] }
|
|
101
|
+
instance = factory.build.call(**arguments)
|
|
102
|
+
@resources << instance if factory.close
|
|
103
|
+
@instances[name] = instance
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def close(reporter:)
|
|
107
|
+
@resources.reverse_each do |resource|
|
|
108
|
+
resource.close
|
|
109
|
+
rescue StandardError => error
|
|
110
|
+
reporter.report(error, context: @context, secondary: true)
|
|
111
|
+
end
|
|
112
|
+
@resources.clear
|
|
113
|
+
@instances.clear
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
class Invocation
|
|
118
|
+
def initialize(operation:, factories:, storage:, reporter:, mappings: nil)
|
|
119
|
+
@operation, @factories, @storage, @reporter, @mappings = operation, factories, storage, reporter, mappings
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def call(context: ExecutionContext.new, **arguments)
|
|
123
|
+
scope = Scope.new(@factories, context:)
|
|
124
|
+
transaction = session = nil
|
|
125
|
+
begin
|
|
126
|
+
context.cancellation.check!
|
|
127
|
+
if @operation.policy != :none
|
|
128
|
+
transaction = if @storage.respond_to?(:begin_transaction_for)
|
|
129
|
+
@storage.begin_transaction_for(mode: @operation.policy, context:)
|
|
130
|
+
else
|
|
131
|
+
@storage.begin_transaction(mode: @operation.policy)
|
|
132
|
+
end
|
|
133
|
+
context.transaction_state = :active
|
|
134
|
+
scope.provide(:transaction, transaction)
|
|
135
|
+
if @mappings
|
|
136
|
+
session = Persistence::Session.new(transaction:, mappings: @mappings)
|
|
137
|
+
scope.provide(:session, session)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
dependencies = @operation.dependencies.to_h { |name| [name, scope.resolve(name)] }
|
|
141
|
+
use_case = @operation.use_case.new(**dependencies)
|
|
142
|
+
result = Values.detach(use_case.call(**arguments))
|
|
143
|
+
context.cancellation.check!
|
|
144
|
+
session&.flush
|
|
145
|
+
if transaction
|
|
146
|
+
context.transaction_state = :committing
|
|
147
|
+
transaction.commit
|
|
148
|
+
end
|
|
149
|
+
context.transaction_state = :committed if transaction
|
|
150
|
+
result
|
|
151
|
+
rescue StandardError => error
|
|
152
|
+
rollback(transaction, context:)
|
|
153
|
+
@reporter.report(error, context:)
|
|
154
|
+
raise
|
|
155
|
+
ensure
|
|
156
|
+
# Also release resources when a non-StandardError unwinds the request thread.
|
|
157
|
+
rollback(transaction, context:) if context.transaction_state == :active
|
|
158
|
+
session&.close
|
|
159
|
+
scope.close(reporter: @reporter)
|
|
160
|
+
close_transaction(transaction, context:)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def rollback(transaction, context:)
|
|
167
|
+
return unless transaction
|
|
168
|
+
state = transaction.respond_to?(:state) ? transaction.state : context.transaction_state
|
|
169
|
+
context.transaction_state = state == :committing ? :indeterminate : state
|
|
170
|
+
return unless state == :active
|
|
171
|
+
transaction.rollback
|
|
172
|
+
context.transaction_state = :rolled_back
|
|
173
|
+
rescue StandardError => error
|
|
174
|
+
context.transaction_state = :indeterminate
|
|
175
|
+
@reporter.report(error, context:, secondary: true)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def close_transaction(transaction, context:)
|
|
179
|
+
transaction&.close
|
|
180
|
+
rescue StandardError => error
|
|
181
|
+
@reporter.report(error, context:, secondary: true)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
module GRPC
|
|
5
|
+
class Unimplemented < StandardError; end
|
|
6
|
+
|
|
7
|
+
class Messages
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def self.frame(bytes)
|
|
11
|
+
[0, bytes.bytesize].pack("CN") + bytes.b
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(input:, descriptor:, message_limit: 1024 * 1024)
|
|
15
|
+
@input, @descriptor, @limit = input, descriptor, message_limit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each
|
|
19
|
+
return enum_for(:each) unless block_given?
|
|
20
|
+
while (header = @input.read_exact(5, allow_eof: true))
|
|
21
|
+
compressed, length = header.unpack("CN")
|
|
22
|
+
raise InvalidInput, "Invalid gRPC compression flag" unless [0, 1].include?(compressed)
|
|
23
|
+
raise Unimplemented, "Message compression is not supported" if compressed == 1
|
|
24
|
+
raise CapacityExceeded, "Message exceeds configured limit" if length > @limit
|
|
25
|
+
yield @descriptor.decode(@input.read_exact(length))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
module GRPC
|
|
5
|
+
class Controller
|
|
6
|
+
def initialize(application:, operation:)
|
|
7
|
+
@application, @operation = application, operation
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(input, context:)
|
|
11
|
+
@application.invoke(@operation, context:, **input)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate!
|
|
15
|
+
@application.validate_operation!(@operation)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class StatusPresenter
|
|
20
|
+
STATUSES = { bad_request: 3, invalid_input: 3, unauthenticated: 16, forbidden: 7,
|
|
21
|
+
not_found: 5, conflict: 9, duplicate: 6, transaction_conflict: 10,
|
|
22
|
+
rate_limited: 8, capacity: 8, unavailable: 14, deadline: 4, cancelled: 1,
|
|
23
|
+
unimplemented: 12, internal: 13 }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(registry:); @registry = registry; end
|
|
26
|
+
|
|
27
|
+
def call(error)
|
|
28
|
+
description = @registry.resolve(error)
|
|
29
|
+
[STATUSES.fetch(description.category), description.code, description.title]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Call
|
|
34
|
+
def initialize(connection:, stream:, descriptor:, message_limit:)
|
|
35
|
+
@connection, @stream, @descriptor, @limit = connection, stream, descriptor, message_limit
|
|
36
|
+
@started = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def send(value)
|
|
40
|
+
@stream.context.cancellation.check!
|
|
41
|
+
payload = @descriptor.encode(value)
|
|
42
|
+
raise CapacityExceeded, "Message exceeds configured limit" if payload.bytesize > @limit
|
|
43
|
+
unless @started
|
|
44
|
+
@connection.send_headers(@stream, initial_headers)
|
|
45
|
+
@started = true
|
|
46
|
+
end
|
|
47
|
+
@connection.send_data(@stream, Messages.frame(payload))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def finish(status: 0, code: "ok", message: "")
|
|
51
|
+
return if @connection.reset?(@stream)
|
|
52
|
+
trailers = [["grpc-status", status.to_s], ["error-code", code], ["operation-id", @stream.context.operation_id]]
|
|
53
|
+
trailers << ["grpc-message", escape(message)] unless message.empty?
|
|
54
|
+
trailers = initial_headers + trailers unless @started
|
|
55
|
+
@connection.send_headers(@stream, trailers, end_stream: true)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def initial_headers
|
|
61
|
+
[[":status", "200"], ["content-type", "application/grpc"], ["grpc-accept-encoding", "identity"], ["operation-id", @stream.context.operation_id]]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def escape(value)
|
|
65
|
+
value.b.bytes.map { |byte| byte.between?(32, 126) && byte != 37 ? byte.chr : format("%%%02X", byte) }.join
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class Server
|
|
70
|
+
attr_reader :name, :port
|
|
71
|
+
|
|
72
|
+
def initialize(application:, services:, controllers:, host: "127.0.0.1", port: 0,
|
|
73
|
+
name: :grpc, max_requests: 128, max_connections: 128,
|
|
74
|
+
message_limit: 1024 * 1024, header_limit: 32 * 1024,
|
|
75
|
+
execution_timeout: 30, idle_timeout: 30, shutdown_timeout: 5,
|
|
76
|
+
tls_context: nil, status_presenter: nil)
|
|
77
|
+
@application, @services, @controllers, @host, @port, @name = application, services.dup, controllers.dup, host, port, name
|
|
78
|
+
@max_requests, @max_connections, @message_limit, @header_limit = max_requests, max_connections, message_limit, header_limit
|
|
79
|
+
@execution_timeout, @idle_timeout, @shutdown_timeout, @tls_context = execution_timeout, idle_timeout, shutdown_timeout, tls_context
|
|
80
|
+
@presenter = status_presenter || StatusPresenter.new(registry: application.errors)
|
|
81
|
+
@mutex, @workers, @connections, @routes = Mutex.new, {}, {}.compare_by_identity, {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def requires; []; end
|
|
85
|
+
|
|
86
|
+
def configure(application)
|
|
87
|
+
application.errors.register(Unimplemented, category: :unimplemented, code: "rpc_unimplemented", title: "RPC method not implemented")
|
|
88
|
+
application.errors.register(HTTP2::ProtocolError, category: :bad_request, code: "http2_protocol_error", title: "Invalid HTTP/2 request")
|
|
89
|
+
application.errors.register(HTTP2::CompressionError, category: :bad_request, code: "hpack_error", title: "Invalid compressed headers")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate!
|
|
93
|
+
unless [@max_requests, @max_connections, @message_limit, @header_limit].all? { |value| value.is_a?(Integer) && value.positive? }
|
|
94
|
+
raise ConfigurationError, "gRPC limits must be positive integers"
|
|
95
|
+
end
|
|
96
|
+
unless [@execution_timeout, @idle_timeout, @shutdown_timeout].all? { |value| value.is_a?(Numeric) && value.positive? }
|
|
97
|
+
raise ConfigurationError, "gRPC timeouts must be positive"
|
|
98
|
+
end
|
|
99
|
+
@services.each do |service|
|
|
100
|
+
service.methods.each_value do |method|
|
|
101
|
+
controller = @controllers.fetch(method.controller) { raise ConfigurationError, "Missing RPC controller" }
|
|
102
|
+
raise ConfigurationError, "RPC controllers must implement call" unless controller.respond_to?(:call)
|
|
103
|
+
controller.validate! if controller.respond_to?(:validate!)
|
|
104
|
+
path = "/#{service.name}/#{method.name}"
|
|
105
|
+
raise ConfigurationError, "Duplicate RPC route" if @routes.key?(path)
|
|
106
|
+
@routes[path] = method
|
|
107
|
+
end
|
|
108
|
+
service.finalize!
|
|
109
|
+
end
|
|
110
|
+
@routes.freeze
|
|
111
|
+
@controllers.freeze
|
|
112
|
+
@services.freeze
|
|
113
|
+
if @tls_context
|
|
114
|
+
raise ConfigurationError, "TLS requires a certificate and private key" unless @tls_context.cert && @tls_context.key
|
|
115
|
+
@tls_context.alpn_select_cb = ->(protocols) { protocols.include?("h2") ? "h2" : raise(HTTP2::ProtocolError, "h2 required") }
|
|
116
|
+
@tls_context.setup
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def start
|
|
121
|
+
@listener = TCPServer.new(@host, @port)
|
|
122
|
+
@port = @listener.local_address.ip_port
|
|
123
|
+
@wake_reader, @wake_writer = IO.pipe
|
|
124
|
+
@running = true
|
|
125
|
+
@acceptor = Thread.new { accept_connections }
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def active_count; @mutex.synchronize { @workers.size }; end
|
|
130
|
+
|
|
131
|
+
def stop
|
|
132
|
+
return unless @acceptor
|
|
133
|
+
@running = false
|
|
134
|
+
@wake_writer.write_nonblock(".", exception: false)
|
|
135
|
+
@acceptor.join
|
|
136
|
+
connections, workers = @mutex.synchronize do
|
|
137
|
+
@workers.each_value { |stream| stream.context.cancellation.cancel }
|
|
138
|
+
[@connections.to_a, @workers.keys]
|
|
139
|
+
end
|
|
140
|
+
connections.each { |connection, _| connection.close }
|
|
141
|
+
deadline = HTTP::Timing.now + @shutdown_timeout
|
|
142
|
+
(workers + connections.map(&:last)).each { |thread| thread.join([deadline - HTTP::Timing.now, 0].max) }
|
|
143
|
+
@listener.close
|
|
144
|
+
@wake_reader.close
|
|
145
|
+
@wake_writer.close
|
|
146
|
+
@acceptor = nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def accept_connections
|
|
152
|
+
while @running
|
|
153
|
+
readable = IO.select([@listener, @wake_reader])&.first
|
|
154
|
+
break if !@running || readable.include?(@wake_reader)
|
|
155
|
+
loop do
|
|
156
|
+
socket = @listener.accept_nonblock(exception: false)
|
|
157
|
+
break if socket == :wait_readable
|
|
158
|
+
@mutex.synchronize do
|
|
159
|
+
if @connections.size >= @max_connections
|
|
160
|
+
socket.close
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
io = @tls_context ? OpenSSL::SSL::SSLSocket.new(socket, @tls_context) : socket
|
|
164
|
+
io.sync_close = true if @tls_context
|
|
165
|
+
connection = HTTP2::Connection.new(io:, on_stream: method(:admit), reporter: @application.reporter,
|
|
166
|
+
stream_limit: @max_requests, header_limit: @header_limit, idle_timeout: @idle_timeout, tls: !!@tls_context)
|
|
167
|
+
thread = Thread.new do
|
|
168
|
+
begin
|
|
169
|
+
connection.run
|
|
170
|
+
ensure
|
|
171
|
+
@mutex.synchronize { @connections.delete(connection) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
@connections[connection] = thread
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def admit(connection, stream, headers)
|
|
181
|
+
metadata = headers.to_h
|
|
182
|
+
context = stream.context
|
|
183
|
+
set_deadline(context, metadata["grpc-timeout"])
|
|
184
|
+
unless metadata[":method"] == "POST" && metadata.fetch("content-type", "").match?(/\Aapplication\/grpc(?:\+proto)?(?:;.*)?\z/)
|
|
185
|
+
connection.send_headers(stream, [[":status", "415"]], end_stream: true)
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
method = @routes[metadata[":path"]]
|
|
189
|
+
unless method
|
|
190
|
+
fail_call(connection, stream, Unimplemented.new)
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
admitted = @mutex.synchronize do
|
|
194
|
+
if @workers.size >= @max_requests
|
|
195
|
+
false
|
|
196
|
+
else
|
|
197
|
+
thread = Thread.new { perform(connection, stream, method) }
|
|
198
|
+
@workers[thread] = stream
|
|
199
|
+
true
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
fail_call(connection, stream, CapacityExceeded.new) unless admitted
|
|
203
|
+
rescue StandardError => error
|
|
204
|
+
fail_call(connection, stream, error)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def perform(connection, stream, method)
|
|
208
|
+
call = Call.new(connection:, stream:, descriptor: method.output, message_limit: @message_limit)
|
|
209
|
+
messages = Messages.new(input: stream.input, descriptor: method.input, message_limit: @message_limit)
|
|
210
|
+
begin
|
|
211
|
+
input = method.client_streaming ? messages : single_message(messages)
|
|
212
|
+
result = @controllers.fetch(method.controller).call(input, context: stream.context)
|
|
213
|
+
if method.server_streaming
|
|
214
|
+
result.each { |value| call.send(value) }
|
|
215
|
+
else
|
|
216
|
+
call.send(result)
|
|
217
|
+
end
|
|
218
|
+
call.finish
|
|
219
|
+
rescue StandardError => error
|
|
220
|
+
fail_call(connection, stream, error, call:)
|
|
221
|
+
ensure
|
|
222
|
+
@mutex.synchronize { @workers.delete(Thread.current) }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def single_message(messages)
|
|
227
|
+
value = nil
|
|
228
|
+
count = 0
|
|
229
|
+
messages.each do |message|
|
|
230
|
+
count += 1
|
|
231
|
+
raise InvalidInput, "Unary RPC requires exactly one message" if count > 1
|
|
232
|
+
value = message
|
|
233
|
+
end
|
|
234
|
+
raise InvalidInput, "Unary RPC requires exactly one message" unless count == 1
|
|
235
|
+
value
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def fail_call(connection, stream, error, call: nil)
|
|
239
|
+
@application.reporter.report(error, context: stream.context)
|
|
240
|
+
begin
|
|
241
|
+
status, code, message = @presenter.call(error)
|
|
242
|
+
rescue StandardError => secondary
|
|
243
|
+
@application.reporter.report(secondary, context: stream.context, secondary: true)
|
|
244
|
+
status, code, message = 13, "internal_error", "Internal server error"
|
|
245
|
+
end
|
|
246
|
+
call ||= Call.new(connection:, stream:, descriptor: nil, message_limit: @message_limit)
|
|
247
|
+
call.finish(status:, code:, message:)
|
|
248
|
+
rescue StandardError => secondary
|
|
249
|
+
@application.reporter.report(secondary, context: stream.context, secondary: true)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def set_deadline(context, value)
|
|
253
|
+
timeout = @execution_timeout
|
|
254
|
+
if value
|
|
255
|
+
match = /\A([0-9]{1,8})([HMSmun])\z/.match(value)
|
|
256
|
+
raise InvalidInput, "Invalid gRPC timeout" unless match
|
|
257
|
+
units = { "H" => 3600, "M" => 60, "S" => 1, "m" => 0.001, "u" => 0.000001, "n" => 0.000000001 }
|
|
258
|
+
timeout = [timeout, Integer(match[1], 10) * units.fetch(match[2])].min
|
|
259
|
+
end
|
|
260
|
+
context.cancellation.deadline = HTTP::Timing.now + timeout
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
module GRPC
|
|
5
|
+
Method = Data.define(:name, :input, :output, :controller, :client_streaming, :server_streaming)
|
|
6
|
+
|
|
7
|
+
class Service
|
|
8
|
+
attr_reader :name, :methods
|
|
9
|
+
|
|
10
|
+
def initialize(name)
|
|
11
|
+
parts = name.to_s.split(".", -1)
|
|
12
|
+
raise ConfigurationError, "Invalid service name" if parts.empty?
|
|
13
|
+
parts.each { |part| Protobuf.name(part) }
|
|
14
|
+
@name, @methods = name.to_s.dup.freeze, {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def rpc(name, input:, output:, controller:, client_streaming: false, server_streaming: false)
|
|
18
|
+
raise ConfigurationError, "Service is finalized" if @methods.frozen?
|
|
19
|
+
name = Protobuf.name(name)
|
|
20
|
+
raise ConfigurationError, "Duplicate RPC method" if @methods.key?(name)
|
|
21
|
+
raise ConfigurationError, "RPC contracts require message descriptors" unless input.is_a?(Protobuf::Message) && output.is_a?(Protobuf::Message)
|
|
22
|
+
@methods[name] = Method.new(name:, input:, output:, controller:, client_streaming:, server_streaming:)
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def finalize!
|
|
27
|
+
to_proto
|
|
28
|
+
@methods.freeze
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_proto
|
|
33
|
+
definitions = {}
|
|
34
|
+
visit = lambda do |type|
|
|
35
|
+
if definitions.key?(type.name)
|
|
36
|
+
raise ConfigurationError, "Ambiguous protobuf type name: #{type.name}" unless definitions[type.name].equal?(type)
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
definitions[type.name] = type
|
|
40
|
+
type.dependencies.each { |child| visit.call(child) } if type.is_a?(Protobuf::Message)
|
|
41
|
+
end
|
|
42
|
+
@methods.each_value { |method| visit.call(method.input); visit.call(method.output) }
|
|
43
|
+
package = name.split(".")[0...-1].join(".")
|
|
44
|
+
output = "syntax = \"proto3\";\n"
|
|
45
|
+
output += "package #{package};\n" unless package.empty?
|
|
46
|
+
output += "\n" + definitions.values.map(&:definition).join("\n")
|
|
47
|
+
output += "\nservice #{name.split('.').last} {\n"
|
|
48
|
+
@methods.each_value do |method|
|
|
49
|
+
output += " rpc #{method.name} (#{method.client_streaming ? 'stream ' : ''}#{method.input.name}) returns (#{method.server_streaming ? 'stream ' : ''}#{method.output.name});\n"
|
|
50
|
+
end
|
|
51
|
+
output + "}\n"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module ObjectFramework
|
|
6
|
+
module HTTP
|
|
7
|
+
module Timing
|
|
8
|
+
def self.now
|
|
9
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.wait(io, mode, deadline)
|
|
13
|
+
remaining = deadline - now
|
|
14
|
+
raise RequestTimeout if remaining <= 0
|
|
15
|
+
readable, writable = mode == :read ? [[io], nil] : [nil, [io]]
|
|
16
|
+
raise RequestTimeout unless IO.select(readable, writable, nil, remaining)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Reader
|
|
21
|
+
attr_reader :io, :buffer, :waiter
|
|
22
|
+
|
|
23
|
+
def initialize(io:, buffer: +"".b, waiter: nil)
|
|
24
|
+
@io, @buffer = io, buffer
|
|
25
|
+
@waiter = waiter || ->(mode, deadline) { Timing.wait(io, mode, deadline) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read_head(parser, header_timeout:, idle_timeout:, idle: false)
|
|
29
|
+
started = !idle || !buffer.empty?
|
|
30
|
+
deadline = Timing.now + (started ? header_timeout : idle_timeout)
|
|
31
|
+
loop do
|
|
32
|
+
first_line = buffer.index("\r\n")
|
|
33
|
+
raise URITooLong if (first_line || buffer.bytesize) > parser.line_limit
|
|
34
|
+
if (finish = buffer.index("\r\n\r\n"))
|
|
35
|
+
return parser.parse_head(buffer.slice!(0, finish + 4))
|
|
36
|
+
end
|
|
37
|
+
raise HeadersTooLarge if first_line && buffer.bytesize - first_line - 2 > parser.header_limit
|
|
38
|
+
buffer << read_chunk(deadline:)
|
|
39
|
+
unless started
|
|
40
|
+
started, deadline = true, Timing.now + header_timeout
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
rescue RequestTimeout
|
|
44
|
+
raise EOFError if idle && !started
|
|
45
|
+
raise
|
|
46
|
+
rescue EOFError
|
|
47
|
+
raise BadRequest unless buffer.empty?
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_exact(count, deadline:)
|
|
52
|
+
buffer << read_chunk(deadline:) while buffer.bytesize < count
|
|
53
|
+
buffer.slice!(0, count)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_line(limit:, deadline:)
|
|
57
|
+
loop do
|
|
58
|
+
if (finish = buffer.index("\r\n"))
|
|
59
|
+
raise HeadersTooLarge if finish > limit
|
|
60
|
+
return buffer.slice!(0, finish + 2).delete_suffix("\r\n")
|
|
61
|
+
end
|
|
62
|
+
raise HeadersTooLarge if buffer.bytesize > limit
|
|
63
|
+
buffer << read_chunk(deadline:)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def write(bytes, deadline:)
|
|
68
|
+
offset = 0
|
|
69
|
+
while offset < bytes.bytesize
|
|
70
|
+
raise RequestTimeout if Timing.now >= deadline
|
|
71
|
+
result = io.write_nonblock(bytes.byteslice(offset..), exception: false)
|
|
72
|
+
case result
|
|
73
|
+
when :wait_readable then waiter.call(:read, deadline)
|
|
74
|
+
when :wait_writable then waiter.call(:write, deadline)
|
|
75
|
+
else
|
|
76
|
+
offset += result
|
|
77
|
+
yield result if block_given?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def read_chunk(deadline:)
|
|
85
|
+
loop do
|
|
86
|
+
raise RequestTimeout if Timing.now >= deadline
|
|
87
|
+
result = io.read_nonblock(16_384, exception: false)
|
|
88
|
+
case result
|
|
89
|
+
when :wait_readable then waiter.call(:read, deadline)
|
|
90
|
+
when :wait_writable then waiter.call(:write, deadline)
|
|
91
|
+
when nil then raise EOFError
|
|
92
|
+
else return result
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class Writer
|
|
99
|
+
REASONS = { 200 => "OK", 204 => "No Content", 400 => "Bad Request", 401 => "Unauthorized",
|
|
100
|
+
403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 408 => "Request Timeout",
|
|
101
|
+
409 => "Conflict", 413 => "Content Too Large", 414 => "URI Too Long", 415 => "Unsupported Media Type",
|
|
102
|
+
417 => "Expectation Failed", 422 => "Unprocessable Content", 429 => "Too Many Requests",
|
|
103
|
+
431 => "Request Header Fields Too Large", 500 => "Internal Server Error", 501 => "Not Implemented",
|
|
104
|
+
503 => "Service Unavailable", 505 => "HTTP Version Not Supported" }.freeze
|
|
105
|
+
attr_reader :started
|
|
106
|
+
|
|
107
|
+
def initialize(reader:)
|
|
108
|
+
@reader, @started = reader, false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def call(response, head: false, keep_alive: true, deadline:)
|
|
112
|
+
status, body = response.status, response.body
|
|
113
|
+
raise InvalidInput, "Invalid response status" unless status.is_a?(Integer) && status.between?(200, 599)
|
|
114
|
+
headers = response.headers.reject { |name, _| %w[date connection content-length].include?(name.downcase) }
|
|
115
|
+
headers = headers.merge("Date" => Time.now.httpdate, "Connection" => keep_alive ? "keep-alive" : "close")
|
|
116
|
+
headers["Content-Length"] = body.bytesize.to_s unless [204, 304].include?(status)
|
|
117
|
+
headers.each do |name, value|
|
|
118
|
+
raise InvalidInput, "Invalid response header" unless Parser::TOKEN.match?(name) && !value.to_s.match?(/[\x00-\x1f\x7f]/)
|
|
119
|
+
raise InvalidInput, "Response transfer encodings are not supported" if name.downcase == "transfer-encoding"
|
|
120
|
+
end
|
|
121
|
+
prefix = "HTTP/1.1 #{status} #{REASONS.fetch(status, 'Response')}\r\n" + headers.map { |key, value| "#{key}: #{value}\r\n" }.join + "\r\n"
|
|
122
|
+
bytes = head || [204, 304].include?(status) ? prefix : prefix + body
|
|
123
|
+
@reader.write(bytes, deadline:) { @started = true }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|