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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +78 -0
  3. data/README.md +250 -0
  4. data/THIRD_PARTY_NOTICES.md +45 -0
  5. data/examples/guides/01_plain_use_case.rb +13 -0
  6. data/examples/guides/02_composed_application.rb +25 -0
  7. data/examples/guides/03_order_domain.rb +14 -0
  8. data/examples/guides/04_scoped_resources.rb +38 -0
  9. data/examples/guides/05_mapping.rb +39 -0
  10. data/examples/guides/06_associations.rb +49 -0
  11. data/examples/guides/07_http.rb +17 -0
  12. data/examples/guides/08_errors.rb +20 -0
  13. data/examples/guides/09_sqlite.rb +38 -0
  14. data/examples/guides/10_grpc.rb +26 -0
  15. data/examples/guides/11_ractor.rb +15 -0
  16. data/examples/guides/12_box.rb +11 -0
  17. data/examples/guides/13_testing.rb +24 -0
  18. data/examples/guides/manifest.yml +62 -0
  19. data/examples/orders/application.rb +51 -0
  20. data/examples/orders/contracts.rb +19 -0
  21. data/examples/orders/domain.rb +35 -0
  22. data/examples/orders/export_proto.rb +5 -0
  23. data/examples/orders/grpc_client.py +24 -0
  24. data/examples/orders/grpc_server.rb +23 -0
  25. data/examples/orders/server.rb +18 -0
  26. data/lib/object_framework/application.rb +136 -0
  27. data/lib/object_framework/capabilities.rb +59 -0
  28. data/lib/object_framework/errors.rb +85 -0
  29. data/lib/object_framework/execution.rb +184 -0
  30. data/lib/object_framework/grpc/messages.rb +30 -0
  31. data/lib/object_framework/grpc/server.rb +264 -0
  32. data/lib/object_framework/grpc/service.rb +55 -0
  33. data/lib/object_framework/http/io.rb +127 -0
  34. data/lib/object_framework/http/presentation.rb +55 -0
  35. data/lib/object_framework/http/protocol.rb +209 -0
  36. data/lib/object_framework/http/router.rb +80 -0
  37. data/lib/object_framework/http/server.rb +279 -0
  38. data/lib/object_framework/http2/connection.rb +399 -0
  39. data/lib/object_framework/http2/frame.rb +21 -0
  40. data/lib/object_framework/http2/hpack.rb +206 -0
  41. data/lib/object_framework/http2/hpack_tables.rb +333 -0
  42. data/lib/object_framework/http2/inbound.rb +75 -0
  43. data/lib/object_framework/memory/storage.rb +97 -0
  44. data/lib/object_framework/persistence/mapping.rb +150 -0
  45. data/lib/object_framework/persistence/query.rb +97 -0
  46. data/lib/object_framework/persistence/session.rb +173 -0
  47. data/lib/object_framework/persistence/transaction.rb +54 -0
  48. data/lib/object_framework/protobuf.rb +268 -0
  49. data/lib/object_framework/sqlite/compiler.rb +77 -0
  50. data/lib/object_framework/sqlite/schema.rb +95 -0
  51. data/lib/object_framework/sqlite/storage.rb +220 -0
  52. data/lib/object_framework/sqlite.rb +6 -0
  53. data/lib/object_framework/version.rb +5 -0
  54. data/lib/object_framework.rb +31 -0
  55. 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