syntropy 0.29.0 → 0.31.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 +22 -0
- data/README.md +0 -2
- data/bin/syntropy +8 -86
- data/cmd/_banner.rb +16 -0
- data/cmd/help.rb +12 -0
- data/cmd/serve.rb +95 -0
- data/cmd/test.rb +40 -0
- data/examples/{counter.rb → basic/counter.rb} +1 -1
- data/examples/{templates.rb → basic/templates.rb} +1 -1
- data/examples/mcp-oauth/.ruby-version +1 -0
- data/examples/mcp-oauth/Gemfile +8 -0
- data/examples/mcp-oauth/README.md +128 -0
- data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
- data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
- data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
- data/examples/mcp-oauth/app/index.md +1 -0
- data/examples/mcp-oauth/app/mcp.rb +38 -0
- data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
- data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
- data/examples/mcp-oauth/app/oauth/register.rb +15 -0
- data/examples/mcp-oauth/app/oauth/token.rb +79 -0
- data/examples/mcp-oauth/app/signin.rb +85 -0
- data/examples/mcp-oauth/test/helper.rb +9 -0
- data/examples/mcp-oauth/test/test_app.rb +27 -0
- data/examples/mcp-oauth/test/test_oauth.rb +628 -0
- data/lib/syntropy/app.rb +23 -12
- data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
- data/lib/syntropy/applets/builtin/req.rb +1 -1
- data/lib/syntropy/dev_mode.rb +1 -1
- data/lib/syntropy/errors.rb +19 -12
- data/lib/syntropy/http/client.rb +43 -0
- data/lib/syntropy/http/client_connection.rb +36 -0
- data/lib/syntropy/http/io_extensions.rb +148 -0
- data/lib/syntropy/http/server.rb +174 -0
- data/lib/syntropy/http/server_connection.rb +367 -0
- data/lib/syntropy/http/status.rb +76 -0
- data/lib/syntropy/http.rb +7 -0
- data/lib/syntropy/json_api.rb +2 -5
- data/lib/syntropy/logger.rb +5 -1
- data/lib/syntropy/mime_types.rb +37 -0
- data/lib/syntropy/papercraft_extensions.rb +1 -1
- data/lib/syntropy/request/mock_adapter.rb +60 -0
- data/lib/syntropy/request/request_info.rb +255 -0
- data/lib/syntropy/request/response.rb +206 -0
- data/lib/syntropy/request/validation.rb +146 -0
- data/lib/syntropy/request.rb +99 -0
- data/lib/syntropy/routing_tree.rb +2 -1
- data/lib/syntropy/test.rb +65 -0
- data/lib/syntropy/utils.rb +1 -1
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +4 -27
- data/syntropy.gemspec +2 -4
- data/test/app/.well-known/foo.rb +3 -0
- 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 +4 -27
- data/test/test_app.rb +83 -98
- data/test/test_caching.rb +2 -2
- data/test/test_errors.rb +6 -6
- data/test/test_http_client.rb +52 -0
- data/test/test_http_client_connection.rb +43 -0
- data/test/{test_connection.rb → test_http_server_connection.rb} +32 -32
- data/test/test_json_api.rb +14 -12
- data/test/test_mock_adapter.rb +59 -0
- data/test/{test_request_extensions.rb → test_request.rb} +150 -18
- data/test/test_response.rb +112 -0
- data/test/test_routing_tree.rb +15 -3
- data/test/test_server.rb +1 -1
- metadata +57 -35
- data/lib/syntropy/connection.rb +0 -402
- data/lib/syntropy/request_extensions.rb +0 -308
- data/lib/syntropy/server.rb +0 -173
- /data/examples/{bad.rb → basic/bad.rb} +0 -0
- /data/examples/{card.rb → basic/card.rb} +0 -0
- /data/examples/{counter.js → basic/counter.js} +0 -0
- /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
- /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
- /data/examples/{index.md → basic/index.md} +0 -0
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
def content_type
|
|
38
|
+
ct = @headers['content-type']
|
|
39
|
+
return nil if !ct
|
|
40
|
+
|
|
41
|
+
m = ct.match(/^([^;]+)/)
|
|
42
|
+
return nil if !m
|
|
43
|
+
|
|
44
|
+
m[1].strip
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Rewrites the request path by replacing the given src with the given
|
|
48
|
+
# replacement.
|
|
49
|
+
#
|
|
50
|
+
# @param src [String, Regexp] src pattern
|
|
51
|
+
# @param replacement [String] replacement
|
|
52
|
+
# @return [Syntropy::Request] self
|
|
53
|
+
def rewrite!(src, replacement)
|
|
54
|
+
@headers[':path'] = @headers[':path']
|
|
55
|
+
.gsub(src, replacement)
|
|
56
|
+
.gsub('//', '/')
|
|
57
|
+
@path = nil
|
|
58
|
+
@uri = nil
|
|
59
|
+
@full_uri = nil
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def uri
|
|
64
|
+
@uri ||= URI.parse(@headers[':path'] || '')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def full_uri
|
|
68
|
+
@full_uri = "#{scheme}://#{host}#{uri}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def path
|
|
72
|
+
@path ||= uri.path
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def query_string
|
|
76
|
+
@query_string ||= uri.query
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def query
|
|
80
|
+
return @query if @query
|
|
81
|
+
|
|
82
|
+
@query = (q = uri.query) ? parse_query(q) : {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
QUERY_KV_REGEXP = /([^=]+)(?:=(.*))?/
|
|
86
|
+
|
|
87
|
+
def parse_query(query)
|
|
88
|
+
query.split('&').each_with_object({}) do |kv, h|
|
|
89
|
+
k, v = kv.match(QUERY_KV_REGEXP)[1..2]
|
|
90
|
+
h[k] = v ? URI.decode_www_form_component(v) : true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def request_id
|
|
95
|
+
@headers['x-request-id']
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def forwarded_for
|
|
99
|
+
@headers['x-forwarded-for']
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# TODO: should return encodings in client's order of preference (and take
|
|
103
|
+
# into account q weights)
|
|
104
|
+
def accept_encoding
|
|
105
|
+
encoding = @headers['accept-encoding']
|
|
106
|
+
return [] unless encoding
|
|
107
|
+
|
|
108
|
+
encoding.split(',').map { |i| i.strip }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cookies
|
|
112
|
+
@cookies ||= parse_cookies(headers['cookie'])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
COOKIE_RE = /^([^=]+)=(.*)$/.freeze
|
|
116
|
+
SEMICOLON = ';'
|
|
117
|
+
|
|
118
|
+
def parse_cookies(cookies)
|
|
119
|
+
return {} unless cookies
|
|
120
|
+
|
|
121
|
+
cookies.split(SEMICOLON).each_with_object({}) do |c, h|
|
|
122
|
+
raise BadRequestError, 'Invalid cookie format' unless c.strip =~ COOKIE_RE
|
|
123
|
+
|
|
124
|
+
key, value = Regexp.last_match[1..2]
|
|
125
|
+
h[key] = EscapeUtils.unescape_uri(value)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Reads the request body and returns form data.
|
|
130
|
+
#
|
|
131
|
+
# @return [Hash] form data
|
|
132
|
+
def get_form_data
|
|
133
|
+
body = read
|
|
134
|
+
if !body || body.empty?
|
|
135
|
+
raise Syntropy::Error.new('Missing form data', HTTP::BAD_REQUEST)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
Syntropy::Request.parse_form_data(body, headers)
|
|
139
|
+
rescue Syntropy::BadRequestError
|
|
140
|
+
raise Syntropy::Error.new('Invalid form data', HTTP::BAD_REQUEST)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def browser?
|
|
144
|
+
user_agent = headers['user-agent']
|
|
145
|
+
user_agent && user_agent =~ /^Mozilla\//
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Returns true if the accept header includes the given MIME type
|
|
149
|
+
#
|
|
150
|
+
# @param mime_type [String] MIME type
|
|
151
|
+
# @return [bool]
|
|
152
|
+
def accept?(mime_type)
|
|
153
|
+
accept = headers['accept']
|
|
154
|
+
return nil if !accept
|
|
155
|
+
|
|
156
|
+
@accept_parts ||= parse_accept_parts(accept)
|
|
157
|
+
@accept_parts.include?(mime_type)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def auth_bearer_token
|
|
161
|
+
auth = headers['authorization']
|
|
162
|
+
if (m = auth.match(/Bearer\s+([^\w]+)/))
|
|
163
|
+
return m[1]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def parse_accept_parts(accept)
|
|
172
|
+
accept.split(',').map { it.match(/^\s*([^\s;]+)/)[1] }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
module RequestInfoClassMethods
|
|
177
|
+
def parse_form_data(body, headers)
|
|
178
|
+
case (content_type = headers['content-type'])
|
|
179
|
+
when /^multipart\/form\-data; boundary=([^\s]+)/
|
|
180
|
+
boundary = "--#{Regexp.last_match(1)}"
|
|
181
|
+
parse_multipart_form_data(body, boundary)
|
|
182
|
+
when /^application\/x-www-form-urlencoded/
|
|
183
|
+
parse_urlencoded_form_data(body)
|
|
184
|
+
else
|
|
185
|
+
raise BadRequestError, "Unsupported form data content type: #{content_type}"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_multipart_form_data(body, boundary)
|
|
190
|
+
parts = body.split(boundary)
|
|
191
|
+
raise BadRequestError, 'Invalid form data' if parts.size < 2
|
|
192
|
+
parts.each_with_object({}) do |p, h|
|
|
193
|
+
next if p.empty? || p == "--\r\n"
|
|
194
|
+
|
|
195
|
+
# remove post-boundary \r\n
|
|
196
|
+
p.slice!(0, 2)
|
|
197
|
+
parse_multipart_form_data_part(p, h)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_multipart_form_data_part(part, hash)
|
|
202
|
+
body, headers = parse_multipart_form_data_part_headers(part)
|
|
203
|
+
disposition = headers['content-disposition'] || ''
|
|
204
|
+
|
|
205
|
+
name = (disposition =~ /name="([^"]+)"/) ? Regexp.last_match(1) : nil
|
|
206
|
+
filename = (disposition =~ /filename="([^"]+)"/) ? Regexp.last_match(1) : nil
|
|
207
|
+
|
|
208
|
+
if filename
|
|
209
|
+
hash[name] = { filename: filename, content_type: headers['content-type'], data: body }
|
|
210
|
+
else
|
|
211
|
+
hash[name] = body
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_multipart_form_data_part_headers(part)
|
|
216
|
+
headers = {}
|
|
217
|
+
while true
|
|
218
|
+
idx = part.index("\r\n")
|
|
219
|
+
break unless idx
|
|
220
|
+
|
|
221
|
+
header = part[0, idx]
|
|
222
|
+
part.slice!(0, idx + 2)
|
|
223
|
+
break if header.empty?
|
|
224
|
+
|
|
225
|
+
next unless header =~ /^([^\:]+)\:\s?(.+)$/
|
|
226
|
+
|
|
227
|
+
headers[Regexp.last_match(1).downcase] = Regexp.last_match(2)
|
|
228
|
+
end
|
|
229
|
+
# remove trailing \r\n
|
|
230
|
+
part.slice!(part.size - 2, 2)
|
|
231
|
+
[part, headers]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
PARAMETER_RE = /^([^=]+)(?:=(.*))?$/.freeze
|
|
235
|
+
MAX_PARAMETER_NAME_SIZE = 256
|
|
236
|
+
MAX_PARAMETER_VALUE_SIZE = 2**20 # 1MB
|
|
237
|
+
|
|
238
|
+
def parse_urlencoded_form_data(body)
|
|
239
|
+
return {} unless body
|
|
240
|
+
|
|
241
|
+
body.force_encoding(Encoding::UTF_8) unless body.encoding == Encoding::UTF_8
|
|
242
|
+
body.split('&').each_with_object({}) do |i, m|
|
|
243
|
+
raise BadRequestError, 'Invalid parameter format' unless i =~ PARAMETER_RE
|
|
244
|
+
|
|
245
|
+
k = Regexp.last_match(1)
|
|
246
|
+
raise BadRequestError, 'Invalid parameter size' if k.size > MAX_PARAMETER_NAME_SIZE
|
|
247
|
+
|
|
248
|
+
v = Regexp.last_match(2)
|
|
249
|
+
raise BadRequestError, 'Invalid parameter size' if v && v.size > MAX_PARAMETER_VALUE_SIZE
|
|
250
|
+
|
|
251
|
+
m[EscapeUtils.unescape_uri(k)] = v ? EscapeUtils.unescape_uri(v) : true
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'digest/sha1'
|
|
5
|
+
|
|
6
|
+
require_relative '../http/status'
|
|
7
|
+
require_relative '../mime_types'
|
|
8
|
+
|
|
9
|
+
module Syntropy
|
|
10
|
+
module StaticFileCaching
|
|
11
|
+
class << self
|
|
12
|
+
def file_stat_to_etag(stat)
|
|
13
|
+
"#{stat.mtime.to_i.to_s(36)}#{stat.size.to_s(36)}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def file_stat_to_last_modified(stat)
|
|
17
|
+
stat.mtime.httpdate
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module ResponseMethods
|
|
23
|
+
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
24
|
+
|
|
25
|
+
def upgrade_to_websocket(custom_headers = nil)
|
|
26
|
+
key = "#{headers['sec-websocket-key']}#{WEBSOCKET_GUID}"
|
|
27
|
+
upgrade_headers = {
|
|
28
|
+
'Sec-WebSocket-Accept' => Digest::SHA1.base64digest(key)
|
|
29
|
+
}
|
|
30
|
+
upgrade_headers.merge!(custom_headers) if custom_headers
|
|
31
|
+
upgrade('websocket', upgrade_headers)
|
|
32
|
+
|
|
33
|
+
adapter.websocket_connection(self)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def redirect(url, status = HTTP::FOUND)
|
|
37
|
+
respond(nil, ':status' => status, 'Location' => url)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def redirect_to_https(status = HTTP::MOVED_PERMANENTLY)
|
|
41
|
+
secure_uri = "https://#{host}#{uri}"
|
|
42
|
+
redirect(secure_uri, status)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def redirect_to_host(new_host, status = HTTP::FOUND)
|
|
46
|
+
secure_uri = "//#{new_host}#{uri}"
|
|
47
|
+
redirect(secure_uri, status)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def serve_file(path, opts = {})
|
|
51
|
+
full_path = file_full_path(path, opts)
|
|
52
|
+
stat = File.stat(full_path)
|
|
53
|
+
etag = StaticFileCaching.file_stat_to_etag(stat)
|
|
54
|
+
last_modified = StaticFileCaching.file_stat_to_last_modified(stat)
|
|
55
|
+
|
|
56
|
+
if validate_static_file_cache(etag, last_modified)
|
|
57
|
+
return respond(nil, {
|
|
58
|
+
':status' => HTTP::NOT_MODIFIED,
|
|
59
|
+
'etag' => etag
|
|
60
|
+
})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
mime_type = MimeTypes[File.extname(path)]
|
|
64
|
+
opts[:stat] = stat
|
|
65
|
+
(opts[:headers] ||= {})['Content-Type'] ||= mime_type if mime_type
|
|
66
|
+
|
|
67
|
+
respond_with_static_file(full_path, etag, last_modified, opts)
|
|
68
|
+
rescue Errno::ENOENT
|
|
69
|
+
respond(nil, ':status' => HTTP::NOT_FOUND)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_static_file_cache(etag, last_modified)
|
|
73
|
+
if (none_match = headers['if-none-match'])
|
|
74
|
+
return true if none_match == etag
|
|
75
|
+
end
|
|
76
|
+
if (modified_since = headers['if-modified-since'])
|
|
77
|
+
return true if modified_since == last_modified
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def file_full_path(path, opts)
|
|
84
|
+
if (base_path = opts[:base_path])
|
|
85
|
+
File.join(opts[:base_path], path)
|
|
86
|
+
else
|
|
87
|
+
path
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def serve_io(io, opts)
|
|
92
|
+
respond(io.read, opts[:headers] || {})
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def respond_with_static_file(path, etag, last_modified, opts)
|
|
96
|
+
cache_headers = (etag || last_modified) ? {
|
|
97
|
+
'etag' => etag,
|
|
98
|
+
'last-modified' => last_modified
|
|
99
|
+
} : {}
|
|
100
|
+
|
|
101
|
+
adapter.respond_with_static_file(self, path, opts, cache_headers)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def set_response_headers(headers)
|
|
105
|
+
adapter.set_response_headers(headers)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def set_cookie(*)
|
|
109
|
+
adapter.set_cookie(*)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def upgrade(protocol, custom_headers = nil, &block)
|
|
113
|
+
upgrade_headers = {
|
|
114
|
+
':status' => HTTP::SWITCHING_PROTOCOLS,
|
|
115
|
+
'Upgrade' => protocol,
|
|
116
|
+
'Connection' => 'upgrade'
|
|
117
|
+
}
|
|
118
|
+
upgrade_headers.merge!(custom_headers) if custom_headers
|
|
119
|
+
|
|
120
|
+
respond(nil, upgrade_headers)
|
|
121
|
+
adapter.with_stream(&block)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Responds according to the given map. The given map defines the responses
|
|
125
|
+
# for each method. The value for each method is either an array containing
|
|
126
|
+
# the body and header values to use as response, or a proc returning such an
|
|
127
|
+
# array. For example:
|
|
128
|
+
#
|
|
129
|
+
# req.respond_by_http_method(
|
|
130
|
+
# 'head' => [nil, headers],
|
|
131
|
+
# 'get' => -> { [IO.read(fn), headers] }
|
|
132
|
+
# )
|
|
133
|
+
#
|
|
134
|
+
# If the request's method is not included in the given map, an exception is
|
|
135
|
+
# raised.
|
|
136
|
+
#
|
|
137
|
+
# @param map [Hash] hash mapping HTTP methods to responses
|
|
138
|
+
# @return [void]
|
|
139
|
+
def respond_by_http_method(map)
|
|
140
|
+
value = map[self.method]
|
|
141
|
+
raise Syntropy::Error.method_not_allowed if !value
|
|
142
|
+
|
|
143
|
+
value = value.() if value.is_a?(Proc)
|
|
144
|
+
(body, headers) = value
|
|
145
|
+
respond(body, headers)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Responds to GET requests with the given body and headers. Otherwise raises
|
|
149
|
+
# an exception.
|
|
150
|
+
#
|
|
151
|
+
# @param body [String, nil] response body
|
|
152
|
+
# @param headers [Hash] response headers
|
|
153
|
+
# @return [void]
|
|
154
|
+
def respond_on_get(body, headers = {})
|
|
155
|
+
case self.method
|
|
156
|
+
when 'head'
|
|
157
|
+
respond(nil, headers)
|
|
158
|
+
when 'get'
|
|
159
|
+
respond(body, headers)
|
|
160
|
+
else
|
|
161
|
+
raise Syntropy::Error.method_not_allowed
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Responds to POST requests with the given body and headers. Otherwise
|
|
166
|
+
# raises an exception.
|
|
167
|
+
#
|
|
168
|
+
# @param body [String, nil] response body
|
|
169
|
+
# @param headers [Hash] response headers
|
|
170
|
+
# @return [void]
|
|
171
|
+
def respond_on_post(body, headers = {})
|
|
172
|
+
case self.method
|
|
173
|
+
when 'head'
|
|
174
|
+
respond(nil, headers)
|
|
175
|
+
when 'post'
|
|
176
|
+
respond(body, headers)
|
|
177
|
+
else
|
|
178
|
+
raise Syntropy::Error.method_not_allowed
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def respond_html(html, **headers)
|
|
183
|
+
respond(
|
|
184
|
+
html,
|
|
185
|
+
'Content-Type' => 'text/html; charset=utf-8',
|
|
186
|
+
**headers
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def respond_json(obj, **headers)
|
|
191
|
+
respond(
|
|
192
|
+
JSON.dump(obj),
|
|
193
|
+
'Content-Type' => 'application/json; charset=utf-8',
|
|
194
|
+
**headers
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def json_pretty_response(obj, **headers)
|
|
199
|
+
respond(
|
|
200
|
+
JSON.pretty_generate(obj),
|
|
201
|
+
'Content-Type' => 'application/json; charset=utf-8',
|
|
202
|
+
**headers
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'escape_utils'
|
|
5
|
+
|
|
6
|
+
module Syntropy
|
|
7
|
+
module RequestValidationMethods
|
|
8
|
+
|
|
9
|
+
# Checks the request's HTTP method against the given accepted values. If not
|
|
10
|
+
# included in the accepted values, raises an exception. Otherwise, returns
|
|
11
|
+
# the request's HTTP method.
|
|
12
|
+
#
|
|
13
|
+
# @param accepted [Array<String>] list of accepted HTTP methods
|
|
14
|
+
# @return [String] request's HTTP method
|
|
15
|
+
def validate_http_method(*accepted)
|
|
16
|
+
return method if accepted.include?(method)
|
|
17
|
+
|
|
18
|
+
raise Syntropy::Error.method_not_allowed
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_content_type(*accepted)
|
|
22
|
+
ct = content_type
|
|
23
|
+
return ct if accepted.include?(ct)
|
|
24
|
+
|
|
25
|
+
raise Syntropy::InvalidRequestContentTypeError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Validates and optionally converts request parameter value for the given
|
|
29
|
+
# parameter name against the given clauses. If no clauses are given,
|
|
30
|
+
# verifies the parameter value is not nil. A clause can be a class, such as
|
|
31
|
+
# String, Integer, etc, in which case the value is converted into the
|
|
32
|
+
# corresponding value. A clause can also be a range, for verifying the value
|
|
33
|
+
# is within the range. A clause can also be an array of two or more clauses,
|
|
34
|
+
# at least one of which should match the value. If the validation fails, an
|
|
35
|
+
# exception is raised. Example:
|
|
36
|
+
#
|
|
37
|
+
# height = req.validate_param(:height, Integer, 1..100)
|
|
38
|
+
#
|
|
39
|
+
# @param name [Symbol] parameter name
|
|
40
|
+
# @clauses [Array] one or more validation clauses
|
|
41
|
+
# @return [any] validated parameter value
|
|
42
|
+
def validate_param(name, *clauses)
|
|
43
|
+
validate(query[name], *clauses)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Validates and optionally converts a value against the given clauses. If no
|
|
47
|
+
# clauses are given, verifies the parameter value is not nil. A clause can
|
|
48
|
+
# be a class, such as String, Integer, etc, in which case the value is
|
|
49
|
+
# converted into the corresponding value. A clause can also be a range, for
|
|
50
|
+
# verifying the value is within the range. A clause can also be an array of
|
|
51
|
+
# two or more clauses, at least one of which should match the value. If the
|
|
52
|
+
# validation fails, an exception is raised.
|
|
53
|
+
#
|
|
54
|
+
# @param value [any] value
|
|
55
|
+
# @clauses [Array] one or more validation clauses
|
|
56
|
+
# @return [any] validated value
|
|
57
|
+
def validate(value, *clauses, message: 'Validation error')
|
|
58
|
+
raise Syntropy::ValidationError, message if clauses.empty? && !value
|
|
59
|
+
|
|
60
|
+
clauses.each do |c|
|
|
61
|
+
valid = param_is_valid?(value, c)
|
|
62
|
+
raise(Syntropy::ValidationError, message) if !valid
|
|
63
|
+
|
|
64
|
+
value = param_convert(value, c)
|
|
65
|
+
end
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Validates request cache information. If the request cache information
|
|
70
|
+
# matches the given etag or last_modified values, responds with a 304 Not
|
|
71
|
+
# Modified status. Otherwise, yields to the given block for a normal
|
|
72
|
+
# response, and sets cache control headers according to the given arguments.
|
|
73
|
+
#
|
|
74
|
+
# @param cache_control [String] value for Cache-Control header
|
|
75
|
+
# @param etag [String, nil] Etag header value
|
|
76
|
+
# @param last_modified [String, nil] Last-Modified header value
|
|
77
|
+
# @return [void]
|
|
78
|
+
def validate_cache(cache_control: 'public', etag: nil, last_modified: nil)
|
|
79
|
+
validated = false
|
|
80
|
+
if (client_etag = headers['if-none-match'])
|
|
81
|
+
validated = true if client_etag == etag
|
|
82
|
+
end
|
|
83
|
+
if (client_mtime = headers['if-modified-since'])
|
|
84
|
+
validated = true if client_mtime == last_modified
|
|
85
|
+
end
|
|
86
|
+
if validated
|
|
87
|
+
respond(nil, ':status' => HTTP::NOT_MODIFIED)
|
|
88
|
+
else
|
|
89
|
+
cache_headers = {
|
|
90
|
+
'Cache-Control' => cache_control
|
|
91
|
+
}
|
|
92
|
+
cache_headers['Etag'] = etag if etag
|
|
93
|
+
cache_headers['Last-Modified'] = last_modified if last_modified
|
|
94
|
+
set_response_headers(cache_headers)
|
|
95
|
+
yield
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
BOOL_REGEXP = /^(t|f|true|false|on|off|1|0|yes|no)$/
|
|
102
|
+
BOOL_TRUE_REGEXP = /^(t|true|on|1|yes)$/
|
|
103
|
+
INTEGER_REGEXP = /^[+-]?[0-9]+$/
|
|
104
|
+
FLOAT_REGEXP = /^[+-]?[0-9]+(\.[0-9]+)?$/
|
|
105
|
+
|
|
106
|
+
# Returns true the given value matches the given condition.
|
|
107
|
+
#
|
|
108
|
+
# @param value [any] value
|
|
109
|
+
# @param cond [any] condition
|
|
110
|
+
# @return [bool]
|
|
111
|
+
def param_is_valid?(value, cond)
|
|
112
|
+
return cond.any? { |c| param_is_valid?(value, c) } if cond.is_a?(Array)
|
|
113
|
+
|
|
114
|
+
if value
|
|
115
|
+
if cond == :bool
|
|
116
|
+
return value =~ BOOL_REGEXP
|
|
117
|
+
elsif cond == Integer
|
|
118
|
+
return value =~ INTEGER_REGEXP
|
|
119
|
+
elsif cond == Float
|
|
120
|
+
return value =~ FLOAT_REGEXP
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
cond === value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Converts the given value according to the given class.
|
|
128
|
+
#
|
|
129
|
+
# @param value [any] value
|
|
130
|
+
# @param klass [Class] class
|
|
131
|
+
# @return [any] converted value
|
|
132
|
+
def param_convert(value, klass)
|
|
133
|
+
if klass == :bool
|
|
134
|
+
value =~ BOOL_TRUE_REGEXP ? true : false
|
|
135
|
+
elsif klass == Integer
|
|
136
|
+
value.to_i
|
|
137
|
+
elsif klass == Float
|
|
138
|
+
value.to_f
|
|
139
|
+
elsif klass == Symbol
|
|
140
|
+
value.to_sym
|
|
141
|
+
else
|
|
142
|
+
value
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative './request/request_info'
|
|
4
|
+
require_relative './request/validation'
|
|
5
|
+
require_relative './request/response'
|
|
6
|
+
require_relative './http/status'
|
|
7
|
+
|
|
8
|
+
module Syntropy
|
|
9
|
+
class Request
|
|
10
|
+
include RequestInfoMethods
|
|
11
|
+
include RequestValidationMethods
|
|
12
|
+
include ResponseMethods
|
|
13
|
+
|
|
14
|
+
extend RequestInfoClassMethods
|
|
15
|
+
|
|
16
|
+
attr_reader :headers, :adapter, :start_stamp, :route_params
|
|
17
|
+
attr_accessor :route
|
|
18
|
+
|
|
19
|
+
def initialize(headers, adapter)
|
|
20
|
+
@headers = headers
|
|
21
|
+
@adapter = adapter
|
|
22
|
+
@start_stamp = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
23
|
+
@route = nil
|
|
24
|
+
@route_params = {}
|
|
25
|
+
@ctx = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the request context
|
|
29
|
+
def ctx
|
|
30
|
+
@ctx ||= {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def next_chunk
|
|
34
|
+
@adapter.get_body_chunk(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def each_chunk
|
|
38
|
+
while (chunk = @adapter.get_body_chunk(self))
|
|
39
|
+
yield chunk
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read
|
|
44
|
+
@adapter.get_body(self)
|
|
45
|
+
end
|
|
46
|
+
alias_method :body, :read
|
|
47
|
+
|
|
48
|
+
def complete?
|
|
49
|
+
@adapter.complete?(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
EMPTY_HEADERS = {}.freeze
|
|
53
|
+
|
|
54
|
+
def respond(body, headers = EMPTY_HEADERS)
|
|
55
|
+
@adapter.respond(self, body, headers)
|
|
56
|
+
@headers_sent = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def send_headers(headers = EMPTY_HEADERS, empty_response = false)
|
|
60
|
+
return if @headers_sent
|
|
61
|
+
|
|
62
|
+
@headers_sent = true
|
|
63
|
+
@adapter.send_headers(self, headers, empty_response: empty_response)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def send_chunk(body, done: false)
|
|
67
|
+
send_headers({}) unless @headers_sent
|
|
68
|
+
|
|
69
|
+
@adapter.send_chunk(self, body, done: done)
|
|
70
|
+
end
|
|
71
|
+
alias_method :<<, :send_chunk
|
|
72
|
+
|
|
73
|
+
def finish
|
|
74
|
+
send_headers({}) unless @headers_sent
|
|
75
|
+
|
|
76
|
+
@adapter.finish(self)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def headers_sent?
|
|
80
|
+
@headers_sent
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def rx_incr(count)
|
|
84
|
+
headers[':rx'] ? headers[':rx'] += count : headers[':rx'] = count
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def tx_incr(count)
|
|
88
|
+
headers[':tx'] ? headers[':tx'] += count : headers[':tx'] = count
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def transfer_counts
|
|
92
|
+
[headers[':rx'], headers[':tx']]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def total_transfer
|
|
96
|
+
(headers[':rx'] || 0) + (headers[':tx'] || 0)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -260,7 +260,8 @@ module Syntropy
|
|
|
260
260
|
# @param dir [String] directory path
|
|
261
261
|
# @return [Array<String>] array of file entries
|
|
262
262
|
def file_search(dir)
|
|
263
|
-
|
|
263
|
+
spec = File.join(dir.gsub(/[\[\]]/) { "\\#{it}"}, '{*,.*}')
|
|
264
|
+
Dir[spec].reject { it =~ /\/\.$/ }
|
|
264
265
|
end
|
|
265
266
|
|
|
266
267
|
# Computes a route entry and/or target for the given file path.
|