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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +2 -2
- data/CHANGELOG.md +5 -0
- data/README.md +0 -2
- data/lib/syntropy/app.rb +8 -8
- data/lib/syntropy/errors.rb +13 -12
- data/lib/syntropy/http/connection.rb +396 -0
- data/lib/syntropy/http/server.rb +174 -0
- data/lib/syntropy/http/status.rb +76 -0
- data/lib/syntropy/http.rb +5 -0
- data/lib/syntropy/json_api.rb +2 -5
- data/lib/syntropy/mime_types.rb +37 -0
- data/lib/syntropy/request/mock_adapter.rb +58 -0
- data/lib/syntropy/request/request_info.rb +236 -0
- data/lib/syntropy/request/response.rb +206 -0
- data/lib/syntropy/{request_extensions.rb → request/validation.rb} +4 -173
- data/lib/syntropy/request.rb +99 -0
- data/lib/syntropy/utils.rb +1 -1
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +3 -6
- data/syntropy.gemspec +2 -3
- data/test/app/about/_error.rb +1 -1
- data/test/app/api+.rb +1 -1
- data/test/app_custom/_site.rb +1 -1
- data/test/bm_router_proc.rb +3 -3
- data/test/helper.rb +6 -5
- data/test/test_app.rb +30 -30
- data/test/test_caching.rb +2 -2
- data/test/test_connection.rb +4 -4
- data/test/test_errors.rb +6 -6
- data/test/test_json_api.rb +10 -8
- data/test/test_mock_adapter.rb +59 -0
- data/test/test_request_info.rb +90 -0
- data/test/test_response.rb +112 -0
- data/test/test_server.rb +1 -1
- metadata +27 -17
- data/lib/syntropy/connection.rb +0 -402
- data/lib/syntropy/server.rb +0 -173
|
@@ -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
|
data/lib/syntropy/json_api.rb
CHANGED
|
@@ -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 },
|
|
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
|