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,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
module HTTP
|
|
5
|
+
Response = Data.define(:status, :headers, :body)
|
|
6
|
+
|
|
7
|
+
class JSONPresenter
|
|
8
|
+
def call(value)
|
|
9
|
+
Response.new(status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate(json_value(value)))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def json_value(value)
|
|
15
|
+
case value
|
|
16
|
+
when Data then json_value(value.to_h)
|
|
17
|
+
when Hash then value.to_h { |key, item| [key, json_value(item)] }
|
|
18
|
+
when Array then value.map { |item| json_value(item) }
|
|
19
|
+
else value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class ProblemPresenter
|
|
25
|
+
STATUSES = {
|
|
26
|
+
bad_request: 400, invalid_input: 422, unauthenticated: 401, forbidden: 403,
|
|
27
|
+
not_found: 404, conflict: 409, duplicate: 409, transaction_conflict: 409,
|
|
28
|
+
rate_limited: 429, capacity: 503, unavailable: 503, deadline: 503,
|
|
29
|
+
cancelled: 503, unimplemented: 501, internal: 500
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(registry:)
|
|
33
|
+
@registry = registry
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(exception, context:)
|
|
37
|
+
description = @registry.resolve(exception)
|
|
38
|
+
status = exception.is_a?(Error) ? exception.class::STATUS : STATUSES.fetch(description.category)
|
|
39
|
+
problem = description.details.transform_keys(&:to_s).merge(
|
|
40
|
+
"type" => "urn:object-framework:error:#{description.code}", "title" => description.title,
|
|
41
|
+
"status" => status, "instance" => "urn:uuid:#{context.operation_id}",
|
|
42
|
+
"code" => description.code, "operation_id" => context.operation_id
|
|
43
|
+
)
|
|
44
|
+
headers = { "Content-Type" => "application/problem+json" }
|
|
45
|
+
headers.merge!(exception.headers) if exception.is_a?(Error)
|
|
46
|
+
Response.new(status:, headers:, body: JSON.generate(problem))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.fallback(context:)
|
|
50
|
+
body = '{"type":"urn:object-framework:error:internal_error","title":"Internal server error","status":500,"code":"internal_error","operation_id":"' + context.operation_id + '","instance":"urn:uuid:' + context.operation_id + '"}'
|
|
51
|
+
Response.new(status: 500, headers: { "Content-Type" => "application/problem+json" }, body:)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ObjectFramework
|
|
7
|
+
module HTTP
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
attr_reader :headers
|
|
10
|
+
def initialize(message = nil, headers: {})
|
|
11
|
+
super(message || self.class::TITLE)
|
|
12
|
+
@headers = headers
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
ERROR_TYPES = {
|
|
17
|
+
BadRequest: [400, "Bad request"], MethodNotAllowed: [405, "Method not allowed"],
|
|
18
|
+
RequestTimeout: [408, "Request timeout"], PayloadTooLarge: [413, "Payload too large"],
|
|
19
|
+
URITooLong: [414, "Request target too long"], UnsupportedMediaType: [415, "Unsupported media type"],
|
|
20
|
+
ExpectationFailed: [417, "Expectation failed"], HeadersTooLarge: [431, "Request headers too large"],
|
|
21
|
+
NotImplemented: [501, "Transfer coding not implemented"], VersionNotSupported: [505, "HTTP version not supported"]
|
|
22
|
+
}.freeze
|
|
23
|
+
ERROR_TYPES.each do |name, (status, title)|
|
|
24
|
+
type = Class.new(Error)
|
|
25
|
+
type.const_set(:STATUS, status)
|
|
26
|
+
type.const_set(:TITLE, title.freeze)
|
|
27
|
+
const_set(name, type)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.register_errors(registry)
|
|
31
|
+
ERROR_TYPES.each_key do |name|
|
|
32
|
+
type = const_get(name)
|
|
33
|
+
code = name.to_s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
34
|
+
registry.register(type, category: :bad_request, code: "http_#{code}", title: type::TITLE)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Head = Data.define(:method, :path, :query, :headers, :length, :chunked, :keep_alive)
|
|
39
|
+
Input = Data.define(:params, :query, :body)
|
|
40
|
+
|
|
41
|
+
class Parser
|
|
42
|
+
TOKEN = /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/
|
|
43
|
+
attr_reader :line_limit, :header_limit, :body_limit
|
|
44
|
+
|
|
45
|
+
def initialize(line_limit: 8 * 1024, header_limit: 32 * 1024, body_limit: 1024 * 1024)
|
|
46
|
+
@line_limit, @header_limit, @body_limit = line_limit, header_limit, body_limit
|
|
47
|
+
raise ConfigurationError, "HTTP limits must be positive integers" unless [line_limit, header_limit, body_limit].all? { |limit| limit.is_a?(Integer) && limit.positive? }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_head(raw)
|
|
51
|
+
raise BadRequest unless raw.end_with?("\r\n\r\n")
|
|
52
|
+
lines = raw.b.split("\r\n", -1)
|
|
53
|
+
request_line = lines.shift
|
|
54
|
+
raise URITooLong if request_line.bytesize > line_limit
|
|
55
|
+
raise HeadersTooLarge if raw.bytesize - request_line.bytesize - 2 > header_limit
|
|
56
|
+
method, target, version = request_line.split(" ", -1)
|
|
57
|
+
raise BadRequest unless method && TOKEN.match?(method) && target && version && request_line == "#{method} #{target} #{version}"
|
|
58
|
+
raise VersionNotSupported unless version == "HTTP/1.1"
|
|
59
|
+
raise BadRequest if target.match?(/[\x00-\x20\x7f-\xff#]/n)
|
|
60
|
+
headers = {}
|
|
61
|
+
lines[0...-2].each do |line|
|
|
62
|
+
name, value = parse_field(line)
|
|
63
|
+
if headers.key?(name)
|
|
64
|
+
raise BadRequest if %w[host content-length transfer-encoding].include?(name)
|
|
65
|
+
headers[name] = "#{headers[name]}, #{value}"
|
|
66
|
+
else
|
|
67
|
+
headers[name] = value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
host = headers["host"]
|
|
71
|
+
raise BadRequest unless host && /\A(?:\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+)(?::\d{1,5})?\z/.match?(host)
|
|
72
|
+
raise BadRequest if headers.key?("content-length") && headers.key?("transfer-encoding")
|
|
73
|
+
length = 0
|
|
74
|
+
if headers.key?("content-length")
|
|
75
|
+
raise BadRequest unless /\A[0-9]+\z/.match?(headers["content-length"])
|
|
76
|
+
length = Integer(headers["content-length"], 10)
|
|
77
|
+
raise PayloadTooLarge if length > body_limit
|
|
78
|
+
end
|
|
79
|
+
encoding = headers["transfer-encoding"]&.downcase
|
|
80
|
+
if encoding && encoding != "chunked"
|
|
81
|
+
raise BadRequest if encoding.split(",").map(&:strip).count("chunked") != 1 || !encoding.end_with?("chunked")
|
|
82
|
+
raise NotImplemented
|
|
83
|
+
end
|
|
84
|
+
expect = headers["expect"]
|
|
85
|
+
raise ExpectationFailed if expect && expect.downcase != "100-continue"
|
|
86
|
+
path, query = parse_target(method, target)
|
|
87
|
+
Head.new(method:, path:, query:, headers: Values.detach(headers), length:, chunked: encoding == "chunked",
|
|
88
|
+
keep_alive: !headers.fetch("connection", "").downcase.split(",").map(&:strip).include?("close"))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_field(line)
|
|
92
|
+
name, value = line.split(":", 2)
|
|
93
|
+
raise BadRequest unless name && TOKEN.match?(name) && value && !value.match?(/[\x00-\x08\x0a-\x1f\x7f]/)
|
|
94
|
+
[name.downcase, value.strip]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_body(reader, head, deadline:)
|
|
98
|
+
return reader.read_exact(head.length, deadline:) unless head.chunked
|
|
99
|
+
body, trailer_size = +"".b, 0
|
|
100
|
+
loop do
|
|
101
|
+
line = reader.read_line(limit: line_limit, deadline:)
|
|
102
|
+
size, extension = line.split(";", 2)
|
|
103
|
+
raise BadRequest unless /\A[0-9a-fA-F]+\z/.match?(size)
|
|
104
|
+
if extension && !/\A(?:[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:=(?:[!#$%&'*+.^_`|~0-9A-Za-z-]+|"(?:[^"\\\r\n]|\\[\x20-\x7e])*"))?)(?:;[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:=(?:[!#$%&'*+.^_`|~0-9A-Za-z-]+|"(?:[^"\\\r\n]|\\[\x20-\x7e])*"))?)*\z/.match?(extension)
|
|
105
|
+
raise BadRequest
|
|
106
|
+
end
|
|
107
|
+
count = Integer(size, 16)
|
|
108
|
+
raise PayloadTooLarge if body.bytesize + count > body_limit
|
|
109
|
+
if count.zero?
|
|
110
|
+
loop do
|
|
111
|
+
trailer = reader.read_line(limit: header_limit, deadline:)
|
|
112
|
+
trailer_size += trailer.bytesize + 2
|
|
113
|
+
raise HeadersTooLarge if trailer_size > header_limit
|
|
114
|
+
break if trailer.empty?
|
|
115
|
+
name, = parse_field(trailer)
|
|
116
|
+
raise BadRequest if %w[content-length transfer-encoding host connection content-type authorization].include?(name)
|
|
117
|
+
end
|
|
118
|
+
return body
|
|
119
|
+
end
|
|
120
|
+
body << reader.read_exact(count, deadline:)
|
|
121
|
+
raise BadRequest unless reader.read_exact(2, deadline:) == "\r\n"
|
|
122
|
+
end
|
|
123
|
+
rescue EOFError
|
|
124
|
+
raise BadRequest
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def parse_target(method, target)
|
|
130
|
+
return ["*", ""] if method == "OPTIONS" && target == "*"
|
|
131
|
+
uri = URI.parse(target)
|
|
132
|
+
if uri.absolute?
|
|
133
|
+
raise BadRequest unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
|
|
134
|
+
path = uri.path.empty? ? "/" : uri.path
|
|
135
|
+
else
|
|
136
|
+
raise BadRequest unless target.start_with?("/") && !target.start_with?("//")
|
|
137
|
+
path = uri.path
|
|
138
|
+
end
|
|
139
|
+
raise BadRequest if target.match?(/%(?![0-9a-fA-F]{2})/)
|
|
140
|
+
[path, uri.query || ""]
|
|
141
|
+
rescue URI::InvalidURIError
|
|
142
|
+
raise BadRequest
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class JSONDecoder
|
|
147
|
+
def call(body, content_type: nil)
|
|
148
|
+
return {} if body.empty?
|
|
149
|
+
raise UnsupportedMediaType unless content_type&.split(";", 2)&.first&.strip&.downcase == "application/json"
|
|
150
|
+
value = JSON.parse(body.dup.force_encoding(Encoding::UTF_8))
|
|
151
|
+
raise InvalidInput, "Expected a JSON object" unless value.is_a?(Hash)
|
|
152
|
+
value
|
|
153
|
+
rescue JSON::ParserError, EncodingError
|
|
154
|
+
raise BadRequest
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Parameter = Data.define(:name, :source, :key, :type, :required)
|
|
159
|
+
|
|
160
|
+
class Parameters
|
|
161
|
+
def initialize(&declaration)
|
|
162
|
+
@fields = []
|
|
163
|
+
instance_eval(&declaration) if declaration
|
|
164
|
+
@fields.freeze
|
|
165
|
+
freeze
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def field(name, source: :body, key: name, type: String, required: true)
|
|
169
|
+
raise ConfigurationError, "Invalid parameter source" unless %i[path query body].include?(source)
|
|
170
|
+
raise ConfigurationError, "Duplicate parameter" if @fields.any? { |field| field.name == name }
|
|
171
|
+
@fields << Parameter.new(name:, source:, key:, type:, required:)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def normalize(input)
|
|
175
|
+
@fields.each_with_object({}) do |field, result|
|
|
176
|
+
source = input.public_send(field.source == :path ? :params : field.source)
|
|
177
|
+
key = field.source == :path ? field.key.to_sym : field.key.to_s
|
|
178
|
+
unless source.key?(key)
|
|
179
|
+
raise InvalidInput, "Missing required parameter" if field.required
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
value = source.fetch(key)
|
|
183
|
+
if field.source != :body
|
|
184
|
+
value = Integer(value, 10) if field.type == Integer
|
|
185
|
+
value = Float(value) if field.type == Float
|
|
186
|
+
end
|
|
187
|
+
raise InvalidInput, "Invalid parameter type" unless value.is_a?(field.type)
|
|
188
|
+
result[field.name] = value
|
|
189
|
+
end
|
|
190
|
+
rescue ArgumentError, TypeError
|
|
191
|
+
raise InvalidInput, "Invalid parameter value"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class Controller
|
|
196
|
+
def initialize(application:, operation:, parameters: Parameters.new)
|
|
197
|
+
@application, @operation, @parameters = application, operation, parameters
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def call(input, context:)
|
|
201
|
+
@application.invoke(@operation, context:, **@parameters.normalize(input))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def validate!
|
|
205
|
+
@application.validate_operation!(@operation)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObjectFramework
|
|
4
|
+
module HTTP
|
|
5
|
+
Route = Data.define(:method, :pattern, :controller, :expression, :names)
|
|
6
|
+
Match = Data.define(:controller, :params, :allow)
|
|
7
|
+
|
|
8
|
+
class Router
|
|
9
|
+
def initialize
|
|
10
|
+
@controllers, @routes = {}, []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def controller(name, instance)
|
|
14
|
+
mutable!
|
|
15
|
+
raise ConfigurationError, "Duplicate controller: #{name}" if @controllers.key?(name)
|
|
16
|
+
@controllers[name] = instance
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def route(method, pattern, controller:)
|
|
21
|
+
mutable!
|
|
22
|
+
method = method.to_s.upcase
|
|
23
|
+
raise ConfigurationError, "Invalid route" unless Parser::TOKEN.match?(method) && pattern.start_with?("/") && !pattern.include?("?")
|
|
24
|
+
names = []
|
|
25
|
+
expression = pattern.split("/", -1).map do |part|
|
|
26
|
+
if part.start_with?(":")
|
|
27
|
+
name = Persistence::Query.identifier(part.delete_prefix(":"))
|
|
28
|
+
raise ConfigurationError, "Duplicate route parameter" if names.include?(name)
|
|
29
|
+
names << name
|
|
30
|
+
"([^/]+)"
|
|
31
|
+
else
|
|
32
|
+
Regexp.escape(part)
|
|
33
|
+
end
|
|
34
|
+
end.join("/")
|
|
35
|
+
raise ConfigurationError, "Duplicate route" if @routes.any? { |route| route.method == method && route.expression.source == "\\A#{expression}\\z" }
|
|
36
|
+
@routes << Route.new(method:, pattern: pattern.dup.freeze, controller:, expression: /\A#{expression}\z/, names: names.freeze)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate!
|
|
41
|
+
@routes.each do |route|
|
|
42
|
+
controller = @controllers.fetch(route.controller) { raise ConfigurationError, "Unknown route controller" }
|
|
43
|
+
raise ConfigurationError, "Controller must implement call" unless controller.respond_to?(:call)
|
|
44
|
+
controller.validate! if controller.respond_to?(:validate!)
|
|
45
|
+
end
|
|
46
|
+
@controllers.freeze
|
|
47
|
+
@routes.sort_by! { |route| route.names.size } unless @routes.frozen?
|
|
48
|
+
@routes.freeze
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve(method, path)
|
|
53
|
+
matching = path == "*" ? @routes : @routes.select { |route| route.expression.match?(path) }
|
|
54
|
+
raise NotFound, "Route not found" if matching.empty?
|
|
55
|
+
allow = (matching.map(&:method) + ["OPTIONS"]).uniq
|
|
56
|
+
allow << "HEAD" if allow.include?("GET")
|
|
57
|
+
allow.sort!
|
|
58
|
+
return Match.new(controller: nil, params: {}, allow:) if method == "OPTIONS"
|
|
59
|
+
route = matching.find { |candidate| candidate.method == method }
|
|
60
|
+
route ||= matching.find { |candidate| candidate.method == "GET" } if method == "HEAD"
|
|
61
|
+
raise MethodNotAllowed.new(headers: { "Allow" => allow.join(", ") }) unless route
|
|
62
|
+
captures = route.expression.match(path).captures
|
|
63
|
+
params = route.names.zip(captures).to_h.transform_values do |value|
|
|
64
|
+
decoded = URI.decode_www_form_component(value.gsub("+", "%2B"))
|
|
65
|
+
raise BadRequest unless decoded.valid_encoding? && !decoded.match?(/[\x00-\x1f\x7f]/)
|
|
66
|
+
decoded
|
|
67
|
+
end
|
|
68
|
+
Match.new(controller: @controllers.fetch(route.controller), params:, allow:)
|
|
69
|
+
rescue ArgumentError
|
|
70
|
+
raise BadRequest
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def mutable!
|
|
76
|
+
raise ConfigurationError, "Router is finalized" if @routes.frozen?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module ObjectFramework
|
|
7
|
+
module HTTP
|
|
8
|
+
Connection = Struct.new(:io, :buffer, :fiber, :context, keyword_init: true) do
|
|
9
|
+
def close
|
|
10
|
+
io.close unless io.closed?
|
|
11
|
+
rescue IOError, SystemCallError
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Server
|
|
17
|
+
attr_reader :port, :name
|
|
18
|
+
|
|
19
|
+
def initialize(application:, router:, host: "127.0.0.1", port: 0, name: :http,
|
|
20
|
+
parser: Parser.new, presenter: JSONPresenter.new, problem_presenter: nil,
|
|
21
|
+
max_requests: 128, max_connections: 1024, header_timeout: 10,
|
|
22
|
+
body_timeout: 30, execution_timeout: 30, idle_timeout: 30,
|
|
23
|
+
write_timeout: 30, shutdown_timeout: 5, tls_context: nil,
|
|
24
|
+
writer_factory: ->(reader) { Writer.new(reader:) })
|
|
25
|
+
@application, @router, @host, @port, @name = application, router, host, port, name
|
|
26
|
+
@parser, @presenter, @problem_presenter = parser, presenter, problem_presenter
|
|
27
|
+
@max_requests, @max_connections = max_requests, max_connections
|
|
28
|
+
@header_timeout, @body_timeout, @execution_timeout = header_timeout, body_timeout, execution_timeout
|
|
29
|
+
@idle_timeout, @write_timeout, @shutdown_timeout = idle_timeout, write_timeout, shutdown_timeout
|
|
30
|
+
@tls_context, @writer_factory = tls_context, writer_factory
|
|
31
|
+
@mutex, @workers, @admitted_count = Mutex.new, {}, 0
|
|
32
|
+
@completed = Queue.new
|
|
33
|
+
@connections, @waiting = {}.compare_by_identity, {}.compare_by_identity
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def requires; []; end
|
|
37
|
+
|
|
38
|
+
def configure(application)
|
|
39
|
+
HTTP.register_errors(application.errors)
|
|
40
|
+
@problem_presenter ||= ProblemPresenter.new(registry: application.errors)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate!
|
|
44
|
+
@router.validate!
|
|
45
|
+
unless [@max_requests, @max_connections].all? { |value| value.is_a?(Integer) && value.positive? }
|
|
46
|
+
raise ConfigurationError, "Admission limits must be positive integers"
|
|
47
|
+
end
|
|
48
|
+
unless [@header_timeout, @body_timeout, @execution_timeout, @idle_timeout, @write_timeout, @shutdown_timeout].all? { |value| value.is_a?(Numeric) && value.positive? }
|
|
49
|
+
raise ConfigurationError, "HTTP timeouts must be positive"
|
|
50
|
+
end
|
|
51
|
+
if @tls_context
|
|
52
|
+
raise ConfigurationError, "TLS requires an application certificate and private key" unless @tls_context.cert && @tls_context.key
|
|
53
|
+
@tls_context.setup
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def start
|
|
58
|
+
@listener = TCPServer.new(@host, @port)
|
|
59
|
+
@port = @listener.local_address.ip_port
|
|
60
|
+
@wake_reader, @wake_writer = IO.pipe
|
|
61
|
+
@running = true
|
|
62
|
+
@dispatcher = Thread.new { dispatch }
|
|
63
|
+
self
|
|
64
|
+
rescue Exception
|
|
65
|
+
@listener&.close
|
|
66
|
+
@wake_reader&.close
|
|
67
|
+
@wake_writer&.close
|
|
68
|
+
raise
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def active_count; @mutex.synchronize { @workers.size }; end
|
|
72
|
+
def admitted_count; @mutex.synchronize { @admitted_count }; end
|
|
73
|
+
def connection_count; @connections.size; end
|
|
74
|
+
|
|
75
|
+
def stop
|
|
76
|
+
return unless @dispatcher
|
|
77
|
+
@running = false
|
|
78
|
+
wake
|
|
79
|
+
@dispatcher.join
|
|
80
|
+
workers = @mutex.synchronize do
|
|
81
|
+
@workers.each_value { |context, connection| context.cancellation.cancel; connection.close }
|
|
82
|
+
@workers.keys
|
|
83
|
+
end
|
|
84
|
+
deadline = Timing.now + @shutdown_timeout
|
|
85
|
+
workers.each { |thread| thread.join([deadline - Timing.now, 0].max) }
|
|
86
|
+
@wake_reader.close
|
|
87
|
+
@wake_writer.close
|
|
88
|
+
@dispatcher = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def dispatch
|
|
94
|
+
while @running
|
|
95
|
+
complete_requests
|
|
96
|
+
now = Timing.now
|
|
97
|
+
@waiting.dup.each { |connection, (_, deadline)| resume_connection(connection) if deadline <= now }
|
|
98
|
+
reads = [@listener, @wake_reader] + @waiting.select { |_, (mode, _)| mode == :read }.keys.map(&:io)
|
|
99
|
+
writes = @waiting.select { |_, (mode, _)| mode == :write }.keys.map(&:io)
|
|
100
|
+
deadline = @waiting.values.map(&:last).min
|
|
101
|
+
timeout = deadline ? [[deadline - Timing.now, 0].max, 0.5].min : 0.5
|
|
102
|
+
readable, writable = IO.select(reads, writes, nil, timeout)
|
|
103
|
+
next unless readable
|
|
104
|
+
drain_wake if readable.delete(@wake_reader)
|
|
105
|
+
accept_connections if readable.delete(@listener)
|
|
106
|
+
ready = readable + writable
|
|
107
|
+
@waiting.keys.each { |connection| resume_connection(connection) if ready.include?(connection.io) }
|
|
108
|
+
end
|
|
109
|
+
ensure
|
|
110
|
+
@connections.each_key(&:close)
|
|
111
|
+
@connections.clear
|
|
112
|
+
@waiting.clear
|
|
113
|
+
@listener.close unless @listener.closed?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def accept_connections
|
|
117
|
+
loop do
|
|
118
|
+
socket = @listener.accept_nonblock(exception: false)
|
|
119
|
+
break if socket == :wait_readable
|
|
120
|
+
if @connections.size >= @max_connections
|
|
121
|
+
socket.close
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
io = @tls_context ? OpenSSL::SSL::SSLSocket.new(socket, @tls_context) : socket
|
|
125
|
+
io.sync_close = true if @tls_context
|
|
126
|
+
connection = Connection.new(io:, buffer: +"".b, context: ExecutionContext.new)
|
|
127
|
+
@connections[connection] = true
|
|
128
|
+
connection.fiber = connection_fiber(connection)
|
|
129
|
+
resume_connection(connection)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def connection_fiber(connection)
|
|
134
|
+
Fiber.new do
|
|
135
|
+
reader = Reader.new(io: connection.io, buffer: connection.buffer,
|
|
136
|
+
waiter: ->(mode, deadline) { Fiber.yield([mode, deadline]) })
|
|
137
|
+
begin
|
|
138
|
+
handshake(connection, reader) if @tls_context
|
|
139
|
+
idle = false
|
|
140
|
+
loop do
|
|
141
|
+
connection.context = ExecutionContext.new
|
|
142
|
+
head = reader.read_head(@parser, header_timeout: @header_timeout, idle_timeout: @idle_timeout, idle:)
|
|
143
|
+
raise CapacityExceeded if Fiber.yield([:request, head]) == :rejected
|
|
144
|
+
idle = true
|
|
145
|
+
end
|
|
146
|
+
rescue EOFError
|
|
147
|
+
nil
|
|
148
|
+
rescue StandardError => error
|
|
149
|
+
@application.reporter.report(error, context: connection.context)
|
|
150
|
+
response = problem(error, context: connection.context)
|
|
151
|
+
Writer.new(reader:).call(response, keep_alive: false, deadline: Timing.now + @write_timeout)
|
|
152
|
+
end
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def handshake(connection, reader)
|
|
158
|
+
deadline = Timing.now + @header_timeout
|
|
159
|
+
loop do
|
|
160
|
+
raise RequestTimeout if Timing.now >= deadline
|
|
161
|
+
result = connection.io.accept_nonblock(exception: false)
|
|
162
|
+
break unless %i[wait_readable wait_writable].include?(result)
|
|
163
|
+
reader.waiter.call(result == :wait_readable ? :read : :write, deadline)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def resume_connection(connection, value = nil)
|
|
168
|
+
@waiting.delete(connection)
|
|
169
|
+
result = connection.fiber.resume(value)
|
|
170
|
+
if result && result.first == :request
|
|
171
|
+
resume_connection(connection, :rejected) unless admit(connection, result.last)
|
|
172
|
+
elsif result
|
|
173
|
+
@waiting[connection] = result
|
|
174
|
+
else
|
|
175
|
+
forget(connection)
|
|
176
|
+
end
|
|
177
|
+
rescue StandardError => error
|
|
178
|
+
@application.reporter.report(error, context: connection.context)
|
|
179
|
+
forget(connection)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def admit(connection, head)
|
|
183
|
+
@mutex.synchronize do
|
|
184
|
+
return false if @workers.size >= @max_requests
|
|
185
|
+
@admitted_count += 1
|
|
186
|
+
thread = Thread.new { serve(connection, head) }
|
|
187
|
+
@workers[thread] = [connection.context, connection]
|
|
188
|
+
end
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def serve(connection, head)
|
|
193
|
+
context = connection.context
|
|
194
|
+
reader = Reader.new(io: connection.io, buffer: connection.buffer)
|
|
195
|
+
writer = nil
|
|
196
|
+
keep_alive, body_read = false, false
|
|
197
|
+
begin
|
|
198
|
+
writer = @writer_factory.call(reader)
|
|
199
|
+
reader.write("HTTP/1.1 100 Continue\r\n\r\n", deadline: Timing.now + @write_timeout) if head.headers["expect"]
|
|
200
|
+
body = @parser.read_body(reader, head, deadline: Timing.now + @body_timeout)
|
|
201
|
+
body_read = true
|
|
202
|
+
context.cancellation.deadline = Timing.now + @execution_timeout
|
|
203
|
+
context.cancellation.check!
|
|
204
|
+
match = @router.resolve(head.method, head.path)
|
|
205
|
+
if head.method == "OPTIONS"
|
|
206
|
+
response = Response.new(status: 204, headers: { "Allow" => match.allow.join(", ") }, body: "")
|
|
207
|
+
else
|
|
208
|
+
input = Input.new(params: match.params, query: query_parameters(head.query),
|
|
209
|
+
body: JSONDecoder.new.call(body, content_type: head.headers["content-type"]))
|
|
210
|
+
result = match.controller.call(input, context:)
|
|
211
|
+
context.cancellation.check!
|
|
212
|
+
response = @presenter.call(result)
|
|
213
|
+
end
|
|
214
|
+
writer.call(response, head: head.method == "HEAD", keep_alive: head.keep_alive, deadline: Timing.now + @write_timeout)
|
|
215
|
+
keep_alive = head.keep_alive
|
|
216
|
+
rescue StandardError => error
|
|
217
|
+
@application.reporter.report(error, context:)
|
|
218
|
+
writer ||= Writer.new(reader:)
|
|
219
|
+
unless writer.started
|
|
220
|
+
begin
|
|
221
|
+
writer.call(problem(error, context:), head: head.method == "HEAD", keep_alive: body_read && head.keep_alive, deadline: Timing.now + @write_timeout)
|
|
222
|
+
keep_alive = body_read && head.keep_alive
|
|
223
|
+
rescue StandardError => secondary
|
|
224
|
+
@application.reporter.report(secondary, context:, secondary: true)
|
|
225
|
+
keep_alive = false
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
ensure
|
|
229
|
+
@mutex.synchronize { @workers.delete(Thread.current) }
|
|
230
|
+
reuse = keep_alive && @running
|
|
231
|
+
connection.close unless reuse
|
|
232
|
+
@completed << [reuse ? :reuse : :close, connection]
|
|
233
|
+
wake
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def query_parameters(query)
|
|
238
|
+
URI.decode_www_form(query).each_with_object({}) do |(key, value), result|
|
|
239
|
+
result[key] = result.key?(key) ? Array(result[key]) + [value] : value
|
|
240
|
+
end
|
|
241
|
+
rescue ArgumentError
|
|
242
|
+
raise BadRequest
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def problem(error, context:)
|
|
246
|
+
@problem_presenter.call(error, context:)
|
|
247
|
+
rescue StandardError => secondary
|
|
248
|
+
@application.reporter.report(secondary, context:, secondary: true)
|
|
249
|
+
ProblemPresenter.fallback(context:)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def complete_requests
|
|
253
|
+
until @completed.empty?
|
|
254
|
+
action, connection = @completed.pop
|
|
255
|
+
action == :reuse && @running ? resume_connection(connection) : forget(connection)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def forget(connection)
|
|
260
|
+
@waiting.delete(connection)
|
|
261
|
+
@connections.delete(connection)
|
|
262
|
+
connection.close
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def wake
|
|
266
|
+
@wake_writer&.write_nonblock(".", exception: false)
|
|
267
|
+
rescue IOError, SystemCallError
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def drain_wake
|
|
272
|
+
loop do
|
|
273
|
+
result = @wake_reader.read_nonblock(1024, exception: false)
|
|
274
|
+
break if result == :wait_readable || result.nil?
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|