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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/http/connection'
4
+
5
+ module Syntropy
6
+ module HTTP
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
174
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ module HTTP
5
+ # translated from https://golang.org/pkg/net/http/#pkg-constants
6
+ # https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
7
+
8
+ CONTINUE = 100 # RFC 7231, 6.2.1
9
+ SWITCHING_PROTOCOLS = 101 # RFC 7231, 6.2.2
10
+ PROCESSING = 102 # RFC 2518, 10.1
11
+ EARLY_HINTS = 103 # RFC 8297
12
+
13
+ OK = 200 # RFC 7231, 6.3.1
14
+ CREATED = 201 # RFC 7231, 6.3.2
15
+ ACCEPTED = 202 # RFC 7231, 6.3.3
16
+ NON_AUTHORITATIVE_INFO = 203 # RFC 7231, 6.3.4
17
+ NO_CONTENT = 204 # RFC 7231, 6.3.5
18
+ RESET_CONTENT = 205 # RFC 7231, 6.3.6
19
+ PARTIAL_CONTENT = 206 # RFC 7233, 4.1
20
+ MULTI_STATUS = 207 # RFC 4918, 11.1
21
+ ALREADY_REPORTED = 208 # RFC 5842, 7.1
22
+ IM_USED = 226 # RFC 3229, 10.4.1
23
+
24
+ MULTIPLE_CHOICES = 300 # RFC 7231, 6.4.1
25
+ MOVED_PERMANENTLY = 301 # RFC 7231, 6.4.2
26
+ FOUND = 302 # RFC 7231, 6.4.3
27
+ SEE_OTHER = 303 # RFC 7231, 6.4.4
28
+ NOT_MODIFIED = 304 # RFC 7232, 4.1
29
+ USE_PROXY = 305 # RFC 7231, 6.4.5
30
+
31
+ TEMPORARY_REDIRECT = 307 # RFC 7231, 6.4.7
32
+ PERMANENT_REDIRECT = 308 # RFC 7538, 3
33
+
34
+ BAD_REQUEST = 400 # RFC 7231, 6.5.1
35
+ UNAUTHORIZED = 401 # RFC 7235, 3.1
36
+ PAYMENT_REQUIRED = 402 # RFC 7231, 6.5.2
37
+ FORBIDDEN = 403 # RFC 7231, 6.5.3
38
+ NOT_FOUND = 404 # RFC 7231, 6.5.4
39
+ METHOD_NOT_ALLOWED = 405 # RFC 7231, 6.5.5
40
+ NOT_ACCEPTABLE = 406 # RFC 7231, 6.5.6
41
+ PROXY_AUTH_REQUIRED = 407 # RFC 7235, 3.2
42
+ REQUEST_TIMEOUT = 408 # RFC 7231, 6.5.7
43
+ CONFLICT = 409 # RFC 7231, 6.5.8
44
+ GONE = 410 # RFC 7231, 6.5.9
45
+ LENGTH_REQUIRED = 411 # RFC 7231, 6.5.10
46
+ PRECONDITION_FAILED = 412 # RFC 7232, 4.2
47
+ REQUEST_ENTITY_TOO_LARGE = 413 # RFC 7231, 6.5.11
48
+ REQUEST_URI_TOO_LONG = 414 # RFC 7231, 6.5.12
49
+ UNSUPPORTED_MEDIA_TYPE = 415 # RFC 7231, 6.5.13
50
+ REQUESTED_RANGE_NOT_SATISFIABLE = 416 # RFC 7233, 4.4
51
+ EXPECTATION_FAILED = 417 # RFC 7231, 6.5.14
52
+ TEAPOT = 418 # RFC 7168, 2.3.3
53
+ MISDIRECTED_REQUEST = 421 # RFC 7540, 9.1.2
54
+ UNPROCESSABLE_ENTITY = 422 # RFC 4918, 11.2
55
+ LOCKED = 423 # RFC 4918, 11.3
56
+ FAILED_DEPENDENCY = 424 # RFC 4918, 11.4
57
+ TOO_EARLY = 425 # RFC 8470, 5.2.
58
+ UPGRADE_REQUIRED = 426 # RFC 7231, 6.5.15
59
+ PRECONDITION_REQUIRED = 428 # RFC 6585, 3
60
+ TOO_MANY_REQUESTS = 429 # RFC 6585, 4
61
+ REQUEST_HEADER_FIELDS_TOO_LARGE = 431 # RFC 6585, 5
62
+ UNAVAILABLE_FOR_LEGAL_REASONS = 451 # RFC 7725, 3
63
+
64
+ INTERNAL_SERVER_ERROR = 500 # RFC 7231, 6.6.1
65
+ NOT_IMPLEMENTED = 501 # RFC 7231, 6.6.2
66
+ BAD_GATEWAY = 502 # RFC 7231, 6.6.3
67
+ SERVICE_UNAVAILABLE = 503 # RFC 7231, 6.6.4
68
+ GATEWAY_TIMEOUT = 504 # RFC 7231, 6.6.5
69
+ HTTP_VERSION_NOT_SUPPORTED = 505 # RFC 7231, 6.6.6
70
+ VARIANT_ALSO_NEGOTIATES = 506 # RFC 2295, 8.1
71
+ INSUFFICIENT_STORAGE = 507 # RFC 4918, 11.5
72
+ LOOP_DETECTED = 508 # RFC 5842, 7.2
73
+ NOT_EXTENDED = 510 # RFC 2774, 7
74
+ NETWORK_AUTHENTICATION_REQUIRED = 511 # RFC 6585, 6
75
+ end
76
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/http/status'
4
+ require 'syntropy/http/connection'
5
+ require 'syntropy/http/server'
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'qeweney'
4
3
  require 'syntropy/errors'
5
4
  require 'json'
6
5
 
@@ -31,7 +30,7 @@ module Syntropy
31
30
  else
32
31
  raise Syntropy::Error.method_not_allowed
33
32
  end
34
- [{ status: 'OK', response: response }, Qeweney::Status::OK]
33
+ [{ status: 'OK', response: response }, HTTP::OK]
35
34
  rescue => e
36
35
  if !e.is_a?(Syntropy::Error)
37
36
  p e
@@ -55,10 +54,8 @@ module Syntropy
55
54
  raise err
56
55
  end
57
56
 
58
- INTERNAL_SERVER_ERROR = Qeweney::Status::INTERNAL_SERVER_ERROR
59
-
60
57
  def __error_response__(err)
61
- http_status = err.respond_to?(:http_status) ? err.http_status : INTERNAL_SERVER_ERROR
58
+ http_status = err.respond_to?(:http_status) ? err.http_status : HTTP::INTERNAL_SERVER_ERROR
62
59
  error_name = err.class.name.split('::').last
63
60
  [{ status: error_name, message: err.message }, http_status]
64
61
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ # File extension to MIME type mapping
5
+ module MimeTypes
6
+ TYPES = {
7
+ 'html' => 'text/html',
8
+ 'css' => 'text/css',
9
+ 'js' => 'application/javascript',
10
+ 'txt' => 'text/plain',
11
+ 'text' => 'text/plain',
12
+ 'gif' => 'image/gif',
13
+ 'jpg' => 'image/jpeg',
14
+ 'jpeg' => 'image/jpeg',
15
+ 'png' => 'image/png',
16
+ 'ico' => 'image/x-icon',
17
+ 'svg' => 'image/svg+xml',
18
+ 'pdf' => 'application/pdf',
19
+ 'json' => 'application/json',
20
+ }.freeze
21
+
22
+ EXT_REGEXP = /\.?([^\.]+)$/.freeze
23
+
24
+ def self.[](ref)
25
+ case ref
26
+ when Symbol
27
+ TYPES[ref.to_s]
28
+ when EXT_REGEXP
29
+ TYPES[Regexp.last_match(1)]
30
+ when ''
31
+ nil
32
+ else
33
+ raise "Invalid argument #{ref.inspect}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ class MockAdapter
5
+ attr_reader :response_body, :response_headers, :calls
6
+
7
+ def get_body_chunk(_req)
8
+ @request_body_chunks.shift
9
+ end
10
+
11
+ def get_body(_req)
12
+ body = @request_body_chunks.join('')
13
+ @request_body_chunks.clear
14
+ body
15
+ end
16
+
17
+ def complete?(_req)
18
+ @request_body_chunks.empty?
19
+ end
20
+
21
+ def initialize(request_body)
22
+ case request_body
23
+ when Array
24
+ @request_body_chunks = request_body
25
+ when nil
26
+ @request_body_chunks = []
27
+ else
28
+ @request_body_chunks = [request_body]
29
+ end
30
+ @calls = []
31
+ end
32
+
33
+ def set_response_headers(headers)
34
+ @response_headers = headers
35
+ end
36
+
37
+ def respond(req, body, headers)
38
+ headers = @response_headers.merge(headers) if @response_headers
39
+ @calls << [:respond, req, body, headers]
40
+ @response_body = body
41
+ @response_headers = headers
42
+ end
43
+
44
+ def status
45
+ response_headers[':status'] || HTTP::OK
46
+ end
47
+
48
+ def method_missing(sym, *args)
49
+ calls << [sym, *args]
50
+ end
51
+
52
+ def self.mock(headers = {}, request_body = nil)
53
+ headers[':method'] ||= ''
54
+ headers[':path'] ||= ''
55
+ Request.new(headers, new(request_body))
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'escape_utils'
5
+
6
+ module Syntropy
7
+ module RequestInfoMethods
8
+ def host
9
+ @headers['host'] || @headers[':authority']
10
+ end
11
+ alias_method :authority, :host
12
+
13
+ def connection
14
+ @headers['connection']
15
+ end
16
+
17
+ def upgrade_protocol
18
+ connection == 'upgrade' && @headers['upgrade']&.downcase
19
+ end
20
+
21
+ def websocket_version
22
+ headers['sec-websocket-version'].to_i
23
+ end
24
+
25
+ def protocol
26
+ @protocol ||= @adapter.protocol
27
+ end
28
+
29
+ def method
30
+ @method ||= @headers[':method'].downcase
31
+ end
32
+
33
+ def scheme
34
+ @scheme ||= @headers[':scheme']
35
+ end
36
+
37
+ # Rewrites the request path by replacing the given src with the given
38
+ # replacement.
39
+ #
40
+ # @param src [String, Regexp] src pattern
41
+ # @param replacement [String] replacement
42
+ # @return [Syntropy::Request] self
43
+ def rewrite!(src, replacement)
44
+ @headers[':path'] = @headers[':path']
45
+ .gsub(src, replacement)
46
+ .gsub('//', '/')
47
+ @path = nil
48
+ @uri = nil
49
+ @full_uri = nil
50
+ self
51
+ end
52
+
53
+ def uri
54
+ @uri ||= URI.parse(@headers[':path'] || '')
55
+ end
56
+
57
+ def full_uri
58
+ @full_uri = "#{scheme}://#{host}#{uri}"
59
+ end
60
+
61
+ def path
62
+ @path ||= uri.path
63
+ end
64
+
65
+ def query_string
66
+ @query_string ||= uri.query
67
+ end
68
+
69
+ def query
70
+ return @query if @query
71
+
72
+ @query = (q = uri.query) ? parse_query(q) : {}
73
+ end
74
+
75
+ QUERY_KV_REGEXP = /([^=]+)(?:=(.*))?/
76
+
77
+ def parse_query(query)
78
+ query.split('&').each_with_object({}) do |kv, h|
79
+ k, v = kv.match(QUERY_KV_REGEXP)[1..2]
80
+ h[k.to_sym] = v ? URI.decode_www_form_component(v) : true
81
+ end
82
+ end
83
+
84
+ def request_id
85
+ @headers['x-request-id']
86
+ end
87
+
88
+ def forwarded_for
89
+ @headers['x-forwarded-for']
90
+ end
91
+
92
+ # TODO: should return encodings in client's order of preference (and take
93
+ # into account q weights)
94
+ def accept_encoding
95
+ encoding = @headers['accept-encoding']
96
+ return [] unless encoding
97
+
98
+ encoding.split(',').map { |i| i.strip }
99
+ end
100
+
101
+ def cookies
102
+ @cookies ||= parse_cookies(headers['cookie'])
103
+ end
104
+
105
+ COOKIE_RE = /^([^=]+)=(.*)$/.freeze
106
+ SEMICOLON = ';'
107
+
108
+ def parse_cookies(cookies)
109
+ return {} unless cookies
110
+
111
+ cookies.split(SEMICOLON).each_with_object({}) do |c, h|
112
+ raise BadRequestError, 'Invalid cookie format' unless c.strip =~ COOKIE_RE
113
+
114
+ key, value = Regexp.last_match[1..2]
115
+ h[key] = EscapeUtils.unescape_uri(value)
116
+ end
117
+ end
118
+
119
+ # Reads the request body and returns form data.
120
+ #
121
+ # @return [Hash] form data
122
+ def get_form_data
123
+ body = read
124
+ if !body || body.empty?
125
+ raise Syntropy::Error.new('Missing form data', HTTP::BAD_REQUEST)
126
+ end
127
+
128
+ Syntropy::Request.parse_form_data(body, headers)
129
+ rescue Syntropy::BadRequestError
130
+ raise Syntropy::Error.new('Invalid form data', HTTP::BAD_REQUEST)
131
+ end
132
+
133
+ def browser?
134
+ user_agent = headers['user-agent']
135
+ user_agent && user_agent =~ /^Mozilla\//
136
+ end
137
+
138
+ # Returns true if the accept header includes the given MIME type
139
+ #
140
+ # @param mime_type [String] MIME type
141
+ # @return [bool]
142
+ def accept?(mime_type)
143
+ accept = headers['accept']
144
+ return nil if !accept
145
+
146
+ @accept_parts ||= parse_accept_parts(accept)
147
+ @accept_parts.include?(mime_type)
148
+ end
149
+
150
+ private
151
+
152
+ def parse_accept_parts(accept)
153
+ accept.split(',').map { it.match(/^\s*([^\s;]+)/)[1] }
154
+ end
155
+ end
156
+
157
+ module RequestInfoClassMethods
158
+ def parse_form_data(body, headers)
159
+ case (content_type = headers['content-type'])
160
+ when /^multipart\/form\-data; boundary=([^\s]+)/
161
+ boundary = "--#{Regexp.last_match(1)}"
162
+ parse_multipart_form_data(body, boundary)
163
+ when /^application\/x-www-form-urlencoded/
164
+ parse_urlencoded_form_data(body)
165
+ else
166
+ raise BadRequestError, "Unsupported form data content type: #{content_type}"
167
+ end
168
+ end
169
+
170
+ def parse_multipart_form_data(body, boundary)
171
+ parts = body.split(boundary)
172
+ raise BadRequestError, 'Invalid form data' if parts.size < 2
173
+ parts.each_with_object({}) do |p, h|
174
+ next if p.empty? || p == "--\r\n"
175
+
176
+ # remove post-boundary \r\n
177
+ p.slice!(0, 2)
178
+ parse_multipart_form_data_part(p, h)
179
+ end
180
+ end
181
+
182
+ def parse_multipart_form_data_part(part, hash)
183
+ body, headers = parse_multipart_form_data_part_headers(part)
184
+ disposition = headers['content-disposition'] || ''
185
+
186
+ name = (disposition =~ /name="([^"]+)"/) ? Regexp.last_match(1) : nil
187
+ filename = (disposition =~ /filename="([^"]+)"/) ? Regexp.last_match(1) : nil
188
+
189
+ if filename
190
+ hash[name] = { filename: filename, content_type: headers['content-type'], data: body }
191
+ else
192
+ hash[name] = body
193
+ end
194
+ end
195
+
196
+ def parse_multipart_form_data_part_headers(part)
197
+ headers = {}
198
+ while true
199
+ idx = part.index("\r\n")
200
+ break unless idx
201
+
202
+ header = part[0, idx]
203
+ part.slice!(0, idx + 2)
204
+ break if header.empty?
205
+
206
+ next unless header =~ /^([^\:]+)\:\s?(.+)$/
207
+
208
+ headers[Regexp.last_match(1).downcase] = Regexp.last_match(2)
209
+ end
210
+ # remove trailing \r\n
211
+ part.slice!(part.size - 2, 2)
212
+ [part, headers]
213
+ end
214
+
215
+ PARAMETER_RE = /^([^=]+)(?:=(.*))?$/.freeze
216
+ MAX_PARAMETER_NAME_SIZE = 256
217
+ MAX_PARAMETER_VALUE_SIZE = 2**20 # 1MB
218
+
219
+ def parse_urlencoded_form_data(body)
220
+ return {} unless body
221
+
222
+ body.force_encoding(Encoding::UTF_8) unless body.encoding == Encoding::UTF_8
223
+ body.split('&').each_with_object({}) do |i, m|
224
+ raise BadRequestError, 'Invalid parameter format' unless i =~ PARAMETER_RE
225
+
226
+ k = Regexp.last_match(1)
227
+ raise BadRequestError, 'Invalid parameter size' if k.size > MAX_PARAMETER_NAME_SIZE
228
+
229
+ v = Regexp.last_match(2)
230
+ raise BadRequestError, 'Invalid parameter size' if v && v.size > MAX_PARAMETER_VALUE_SIZE
231
+
232
+ m[EscapeUtils.unescape_uri(k)] = v ? EscapeUtils.unescape_uri(v) : true
233
+ end
234
+ end
235
+ end
236
+ end