syntropy 0.29.0 → 0.30.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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: syntropy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.0
4
+ version: 0.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
@@ -38,47 +38,47 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: 3.2.0
40
40
  - !ruby/object:Gem::Dependency
41
- name: qeweney
41
+ name: uringmachine
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.24'
46
+ version: 1.0.0
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.24'
53
+ version: 1.0.0
54
54
  - !ruby/object:Gem::Dependency
55
- name: uringmachine
55
+ name: cgi
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
- - - "~>"
58
+ - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: 1.0.0
60
+ version: '0'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
- - - "~>"
65
+ - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: 1.0.0
67
+ version: '0'
68
68
  - !ruby/object:Gem::Dependency
69
- name: listen
69
+ name: escape_utils
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
- - - "~>"
72
+ - - '='
73
73
  - !ruby/object:Gem::Version
74
- version: 3.9.0
74
+ version: 1.3.0
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - "~>"
79
+ - - '='
80
80
  - !ruby/object:Gem::Version
81
- version: 3.9.0
81
+ version: 1.3.0
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: json
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -196,18 +196,25 @@ files:
196
196
  - lib/syntropy/applets/builtin/json_api.js
197
197
  - lib/syntropy/applets/builtin/ping.rb
198
198
  - lib/syntropy/applets/builtin/req.rb
199
- - lib/syntropy/connection.rb
200
199
  - lib/syntropy/connection_pool.rb
201
200
  - lib/syntropy/dev_mode.rb
202
201
  - lib/syntropy/errors.rb
202
+ - lib/syntropy/http.rb
203
+ - lib/syntropy/http/connection.rb
204
+ - lib/syntropy/http/server.rb
205
+ - lib/syntropy/http/status.rb
203
206
  - lib/syntropy/json_api.rb
204
207
  - lib/syntropy/logger.rb
205
208
  - lib/syntropy/markdown.rb
209
+ - lib/syntropy/mime_types.rb
206
210
  - lib/syntropy/module.rb
207
211
  - lib/syntropy/papercraft_extensions.rb
208
- - lib/syntropy/request_extensions.rb
212
+ - lib/syntropy/request.rb
213
+ - lib/syntropy/request/mock_adapter.rb
214
+ - lib/syntropy/request/request_info.rb
215
+ - lib/syntropy/request/response.rb
216
+ - lib/syntropy/request/validation.rb
209
217
  - lib/syntropy/routing_tree.rb
210
- - lib/syntropy/server.rb
211
218
  - lib/syntropy/side_run.rb
212
219
  - lib/syntropy/utils.rb
213
220
  - lib/syntropy/version.rb
@@ -252,8 +259,11 @@ files:
252
259
  - test/test_connection_pool.rb
253
260
  - test/test_errors.rb
254
261
  - test/test_json_api.rb
262
+ - test/test_mock_adapter.rb
255
263
  - test/test_module.rb
256
264
  - test/test_request_extensions.rb
265
+ - test/test_request_info.rb
266
+ - test/test_response.rb
257
267
  - test/test_routing_tree.rb
258
268
  - test/test_server.rb
259
269
  - test/test_side_run.rb
@@ -1,402 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'qeweney'
4
- require 'stringio'
5
- require 'syntropy/errors'
6
- require 'syntropy/request_extensions'
7
-
8
- module Syntropy
9
- # Implements an HTTP/1.1 connection received by the Syntropy server. This
10
- # implementation rejects incoming HTTP/0.9 or HTTP/1.0 requests. The response
11
- # body is sent exclusively using chunked transfer encoding. Request bodies are
12
- # accepted using either fixed length (Content-Length header) or chunked
13
- # transfer encoding.
14
- class Connection
15
- attr_reader :fd, :response_headers, :logger
16
-
17
- def initialize(server, machine, fd, env, &app)
18
- @server = server
19
- @machine = machine
20
- @fd = fd
21
- @env = env
22
- @logger = env[:logger]
23
- @io = machine.io(fd, :socket)
24
- @app = app
25
-
26
- @done = nil
27
- @response_headers = nil
28
- end
29
-
30
- def run
31
- loop do
32
- @done = nil
33
- @response_headers = nil
34
- persist = serve_request
35
- break if !persist
36
- end
37
- rescue UM::Terminate
38
- # server is terminated, do nothing
39
- rescue StandardError => e
40
- @logger&.error(
41
- message: 'Uncaught error while running connection',
42
- error: e
43
- )
44
- ensure
45
- @machine.close_async(@fd)
46
- end
47
-
48
- # Processes an incoming request by parsing the headers, creating a request
49
- # object and handing it off to the app handler. Returns true if the
50
- # connection should be persisted.
51
- def serve_request
52
- @closed = nil
53
- headers = parse_headers
54
- return false if !headers
55
-
56
- request = Qeweney::Request.new(headers, self)
57
-
58
- request.start_stamp = monotonic_clock
59
- @app.call(request)
60
- persist_connection?(headers)
61
- rescue StandardError => e
62
- handle_error(request, e)
63
- false
64
- end
65
-
66
- # Handles an error encountered while serving a request by logging the error
67
- # and optionally sending an error response with the relevant HTTP status
68
- # code. For I/O errors, no response is sent.
69
- #
70
- # @param request [Qeweney::Request] HTTP request
71
- # @param err [Exception] error
72
- # @return [void]
73
- def handle_error(request, err)
74
- case err
75
- when SystemCallError
76
- log_error(err, 'I/O error')
77
- false
78
- when ProtocolError
79
- log_error(err, err.message)
80
- respond(request, err.message, ':status' => err.http_status)
81
- else
82
- log_error(err, 'Internal error')
83
- return if !request || @done
84
-
85
- respond(request, 'Internal server error', ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
86
- end
87
- end
88
-
89
- # Logs the given err and given message.
90
- #
91
- # @param err [Exception] error
92
- # @param message [String] error message
93
- # @return [void]
94
- def log_error(err, message)
95
- @logger&.error(message: "#{message}, closing connection", error: err)
96
- end
97
-
98
- def get_body(req)
99
- headers = req.headers
100
- return nil if headers[':body-done-reading']
101
-
102
- content_length = headers['content-length']
103
- if content_length
104
-
105
- chunk = @io.read(content_length.to_i)
106
- headers[':body-done-reading'] = true
107
- return chunk
108
- end
109
-
110
- chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
111
- if chunked_encoding
112
- buf = +''
113
- while (chunk = read_chunk(headers, nil))
114
- buf << chunk
115
- end
116
- headers[':body-done-reading'] = true
117
- return buf
118
- end
119
-
120
- nil
121
- end
122
-
123
- def get_body_chunk(req, _buffered_only = false)
124
- headers = req.headers
125
- content_length = headers['content-length']
126
- if content_length
127
- return nil if headers[':body-done-reading']
128
-
129
- chunk = @io.read(content_length.to_i)
130
- headers[':body-done-reading'] = true
131
- return chunk
132
- end
133
-
134
- chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
135
- return read_chunk(headers, nil) if chunked_encoding
136
-
137
- return nil if headers[':body-done-reading']
138
-
139
- # if content-length is not specified, we read to EOF, up to max 1MB size
140
- chunk = read(1 << 20, nil, false)
141
- headers[':body-done-reading'] = true
142
- chunk
143
- end
144
-
145
- def complete?(req)
146
- req.headers[':body-done-reading']
147
- end
148
-
149
- # response API
150
-
151
- # Sets response headers before sending any response. This method is used to
152
- # add headers such as Set-Cookie or cache control headers to a response
153
- # before actually responding, specifically in middleware hooks.
154
- #
155
- # @param headers [Hash] response headers
156
- # @return [void]
157
- def set_response_headers(headers)
158
- @response_headers ? @response_headers.merge!(headers) : @response_headers = headers
159
- end
160
-
161
- def set_cookie(*cookies)
162
- existing_cookies = @response_headers && @response_headers['Set-Cookie']
163
- if existing_cookies
164
- @response_headers['Set-Cookie'] = existing_cookies + cookies
165
- else
166
- set_response_headers('Set-Cookie' => cookies)
167
- end
168
- end
169
-
170
- SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
171
-
172
- EMPTY_CHUNK = "0\r\n\r\n"
173
- EMPTY_CHUNK_LEN = EMPTY_CHUNK.bytesize
174
-
175
- CHUNKED_ENCODING_POSTLUDE = "\r\n#{EMPTY_CHUNK}"
176
-
177
- # Sends response including headers and body. Waits for the request to complete
178
- # if not yet completed. The body is sent using chunked transfer encoding.
179
- # @param request [Qeweney::Request] HTTP request
180
- # @param body [String] response body
181
- # @param headers
182
- def respond(request, body, headers)
183
- headers = @response_headers.merge(headers) if @response_headers
184
-
185
- formatted_headers = format_headers(headers, body)
186
- @response_headers = headers
187
- request&.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
188
- if body
189
- chunk_prelude = "#{body.bytesize.to_s(16)}\r\n"
190
- @machine.sendv(@fd, formatted_headers, chunk_prelude, body, CHUNKED_ENCODING_POSTLUDE)
191
- else
192
- @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
193
- end
194
- @logger&.info(request: request, response_headers: headers) if request
195
- @done = true
196
- end
197
-
198
- # Sends response headers. If empty_response is truthy, the response status
199
- # code will default to 204, otherwise to 200.
200
- # @param request [Qeweney::Request] HTTP request
201
- # @param headers [Hash] response headers
202
- # @param empty_response [boolean] whether a response body will be sent
203
- # @return [void]
204
- def send_headers(request, headers, empty_response: false)
205
- formatted_headers = format_headers(headers, !empty_response)
206
- request.tx_incr(formatted_headers.bytesize)
207
- @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
208
- @response_headers = headers
209
- end
210
-
211
- # Sends a response body chunk. If no headers were sent, default headers are
212
- # sent using #send_headers. if the done option is true(thy), an empty chunk
213
- # will be sent to signal response completion to the client.
214
- # @param request [Qeweney::Request] HTTP request
215
- # @param chunk [String] response body chunk
216
- # @param done [boolean] whether the response is completed
217
- # @return [void]
218
- def send_chunk(request, chunk, done: false)
219
- data = +''
220
- data << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" if chunk
221
- data << EMPTY_CHUNK if done
222
- return if data.empty?
223
-
224
- request.tx_incr(data.bytesize)
225
- @machine.send(@fd, data, data.bytesize, SEND_FLAGS)
226
- return if @done || !done
227
-
228
- @logger&.info(request: request, response_headers: @response_headers)
229
- @done = true
230
- end
231
-
232
- # Finishes the response to the current request. If no headers were sent,
233
- # default headers are sent using #send_headers.
234
- # @return [void]
235
- def finish(request)
236
- request.tx_incr(EMPTY_CHUNK_LEN)
237
- @machine.send(@fd, EMPTY_CHUNK, EMPTY_CHUNK_LEN, SEND_FLAGS)
238
- return if @done
239
-
240
- @logger&.info(request, request, response_headers: @response_headers)
241
- @done = true
242
- end
243
-
244
- def respond_with_static_file(req, path, env, cache_headers)
245
- fd = @machine.open(path, UM::O_RDONLY)
246
- env ||= {}
247
- if env[:headers]
248
- env[:headers].merge!(cache_headers)
249
- else
250
- env[:headers] = cache_headers
251
- end
252
-
253
- maxlen = env[:max_len] || 65_536
254
- buf = String.new(capacity: maxlen)
255
- headers_sent = nil
256
- loop do
257
- res = @machine.read(fd, buf, maxlen, 0)
258
- if res < maxlen && !headers_sent
259
- return respond(req, buf, env[:headers])
260
- elsif res == 0
261
- return finish(req)
262
- end
263
-
264
- if !headers_sent
265
- send_headers(req, env[:headers])
266
- headers_sent = true
267
- end
268
- done = res < maxlen
269
- send_chunk(req, buf, done: done)
270
- return if done
271
- end
272
- end
273
-
274
- def close
275
- return if @closed
276
-
277
- @closed = true
278
- @machine.shutdown(@fd, UM::SHUT_WR)
279
- @machine.close_async(@fd)
280
- end
281
-
282
- def monotonic_clock
283
- ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
284
- end
285
-
286
- def with_stream
287
- yield @io, @fd
288
- end
289
-
290
- private
291
-
292
- RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+http\/([019\.]{1,3})/i
293
- RE_HEADER_LINE = /^([a-z0-9-]+):\s+(.+)/i
294
- MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
295
- MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
296
- MAX_CHUNK_SIZE_LEN = 16
297
-
298
- def persist_connection?(headers)
299
- connection = headers['connection']&.downcase
300
- return connection != 'close'
301
- end
302
-
303
- def parse_headers
304
- headers = get_request_line(MAX_REQUEST_LINE_LEN)
305
- return nil if !headers
306
-
307
- loop do
308
- line = @io.read_line(MAX_HEADER_LINE_LEN)
309
- break if line.nil? || line.empty?
310
-
311
- m = line.match(RE_HEADER_LINE)
312
- raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
313
-
314
- headers[m[1].downcase] = m[2]
315
- end
316
-
317
- headers
318
- end
319
-
320
- def get_request_line(buf)
321
- line = @io.read_line(MAX_REQUEST_LINE_LEN)
322
- return nil if !line
323
-
324
- m = line.match(RE_REQUEST_LINE)
325
- raise ProtocolError, 'Invalid request line' if !m
326
-
327
- http_version = m[3]
328
- raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
329
-
330
- {
331
- ':method' => m[1].downcase,
332
- ':path' => m[2],
333
- ':protocol' => 'http/1.1'
334
- }
335
- end
336
-
337
- def read_chunk(headers, buffer)
338
- chunk_size_str = @io.read_line(MAX_CHUNK_SIZE_LEN)
339
- return nil if !chunk_size_str
340
-
341
- chunk_size = chunk_size_str.to_i(16)
342
- if chunk_size == 0
343
- headers[':body-done-reading'] = true
344
- @io.read_line(0)
345
- return nil
346
- end
347
-
348
- chunk = @io.read(chunk_size)
349
- @io.read_line(0)
350
-
351
- buffer ? (buffer << chunk) : chunk
352
- end
353
-
354
- INTERNAL_HEADER_REGEXP = /^:/
355
-
356
- # Formats response headers into an array. If empty_response is true(thy),
357
- # the response status code will default to 204, otherwise to 200.
358
- # @param headers [Hash] response headers
359
- # @param body [boolean] whether a response body will be sent
360
- # @return [String] formatted response headers
361
- def format_headers(headers, body)
362
- status = headers[':status'] || (body ? Qeweney::Status::OK : Qeweney::Status::NO_CONTENT)
363
- lines = format_status_line(body, status)
364
- lines << @env[:server_headers] if @env[:server_headers]
365
- headers.each do |k, v|
366
- next if k =~ INTERNAL_HEADER_REGEXP
367
-
368
- collect_header_lines(lines, k, v)
369
- end
370
- lines << "\r\n"
371
- lines
372
- end
373
-
374
- def format_status_line(body, status)
375
- if !body
376
- empty_status_line(status)
377
- else
378
- with_body_status_line(status, body)
379
- end
380
- end
381
-
382
- def empty_status_line(status)
383
- if status == 204
384
- +"HTTP/1.1 #{status}\r\n"
385
- else
386
- +"HTTP/1.1 #{status}\r\nContent-Length: 0\r\n"
387
- end
388
- end
389
-
390
- def with_body_status_line(status, body)
391
- +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
392
- end
393
-
394
- def collect_header_lines(lines, key, value)
395
- if value.is_a?(Array)
396
- value.inject(lines) { |_, item| lines << "#{key}: #{item}\r\n" }
397
- else
398
- lines << "#{key}: #{value}\r\n"
399
- end
400
- end
401
- end
402
- end
@@ -1,173 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'syntropy/connection'
4
- require 'syntropy/request_extensions'
5
-
6
- module Syntropy
7
- class Server
8
- PENDING_REQUESTS_GRACE_PERIOD = 0.1
9
- PENDING_REQUESTS_TIMEOUT_PERIOD = 5
10
-
11
- def self.syntropy_app(_machine, env)
12
- if env[:app_location]
13
- env[:logger]&.info(message: 'Loading web app', location: env[:app_location])
14
- require env[:app_location]
15
-
16
- env.merge!(Syntropy.config)
17
- end
18
- env[:app]
19
- end
20
-
21
- def self.static_app(env); end
22
-
23
- def initialize(machine, env, &app)
24
- @machine = machine
25
- @env = env
26
- @app = app || app_from_env
27
- @server_fds = []
28
- @accept_fibers = []
29
- end
30
-
31
- def app_from_env
32
- case @env[:app_type]
33
- when nil, :syntropy
34
- Server.syntropy_app(@machine, @env)
35
- when :static
36
- Server.static_app(@env)
37
- else
38
- raise "Invalid app type #{@env[:app_type].inspect}"
39
- end
40
- end
41
-
42
- def run
43
- setup
44
- @machine.await(@accept_fibers)
45
- rescue UM::Terminate
46
- graceful_shutdown
47
- end
48
-
49
- def stop!
50
- graceful_shutdown
51
- end
52
-
53
- private
54
-
55
- def setup
56
- bind_info = get_bind_entries
57
- bind_info.each do |(host, port)|
58
- fd = setup_server_socket(host, port)
59
- @server_fds << fd
60
- @accept_fibers << @machine.spin { accept_incoming(fd) }
61
- end
62
- bind_string = bind_info.map { it.join(':') }.join(', ')
63
- @env[:logger]&.info(message: "Listening on #{bind_string}")
64
- setup_server_extensions
65
-
66
- # map fibers
67
- @connection_fibers = Set.new
68
- end
69
-
70
- def get_bind_entries
71
- bind = @env[:bind]
72
- case bind
73
- when Array
74
- bind.map { bind_info(it) }
75
- when String
76
- [bind_info(bind)]
77
- else
78
- # default
79
- [['0.0.0.0', 1234]]
80
- end
81
- end
82
-
83
- def bind_info(bind_string)
84
- parts = bind_string.split(':')
85
- [parts[0], parts[1].to_i]
86
- end
87
-
88
- def setup_server_socket(host, port)
89
- fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
90
- @machine.setsockopt(fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
91
- @machine.setsockopt(fd, UM::SOL_SOCKET, UM::SO_REUSEPORT, true)
92
- @machine.bind(fd, host, port)
93
- @machine.listen(fd, UM::SOMAXCONN)
94
- fd
95
- end
96
-
97
- def setup_server_extensions
98
- extensions = @env[:server_extensions]
99
- return if !extensions
100
-
101
- server_name = extensions[:name]
102
- if extensions[:date]
103
- @date_header_fiber = @machine.spin {
104
- @machine.periodically(1) { update_server_headers(server_name) }
105
- }
106
- update_server_headers(server_name)
107
- elsif server_name
108
- @env[:server_headers] = "Server: #{server_name}\r\n"
109
- end
110
- end
111
-
112
- def update_server_headers(server_name)
113
- @env[:server_date] = Time.now
114
- if server_name
115
- @env[:server_headers] = "Server: #{server_name}\r\nDate: #{@env[:server_date].httpdate}\r\n"
116
- else
117
- @env[:server_headers] = "Date: #{Time.now.httpdate}\r\n"
118
- end
119
- end
120
-
121
- def accept_incoming(listen_fd)
122
- @machine.accept_each(listen_fd) { start_client_connection(it) }
123
- rescue UM::Terminate
124
- # terminated
125
- end
126
-
127
- def start_client_connection(fd)
128
- conn = Connection.new(self, @machine, fd, @env, &@app)
129
- f = @machine.spin(conn) do
130
- it.run
131
- ensure
132
- @connection_fibers.delete(f)
133
- end
134
- @connection_fibers << f
135
- end
136
-
137
- def close_all_server_fds
138
- @server_fds.each { @machine.close_async(it) }
139
- end
140
-
141
- STOP = UM::Terminate.new
142
-
143
- def stop_accept_fibers
144
- @accept_fibers.each { @machine.schedule(it, STOP) if !it.done? }
145
- @machine.await(@accept_fibers)
146
- end
147
-
148
- def graceful_shutdown
149
- @env[:logger]&.info(message: 'Shutting down gracefully...')
150
-
151
- # stop listening
152
- close_all_server_fds
153
- stop_accept_fibers
154
- @machine.snooze
155
-
156
- return if @connection_fibers.empty?
157
-
158
- # sleep for a bit, let requests finish
159
- @machine.sleep(PENDING_REQUESTS_GRACE_PERIOD)
160
- return if @connection_fibers.empty?
161
-
162
- # terminate pending fibers
163
- pending = @connection_fibers.to_a
164
- pending.each { @machine.schedule(it, STOP) }
165
-
166
- @machine.timeout(PENDING_REQUESTS_TIMEOUT_PERIOD, UM::Terminate) do
167
- @machine.await(@connection_fibers)
168
- rescue UM::Terminate
169
- # timeout on waiting for adapters to finish running, do nothing
170
- end
171
- end
172
- end
173
- end