syntropy 0.28.2 → 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/.ruby-version +1 -0
- data/CHANGELOG.md +11 -0
- data/README.md +0 -4
- data/bin/syntropy +2 -3
- data/cmd/setup/template/site/Dockerfile +1 -1
- data/lib/syntropy/app.rb +22 -14
- data/lib/syntropy/errors.rb +23 -10
- 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/logger.rb +103 -0
- 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 -147
- 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 +53 -5
- data/syntropy.gemspec +5 -7
- 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 +12 -7
- data/test/test_app.rb +30 -30
- data/test/test_caching.rb +2 -2
- data/test/test_connection.rb +649 -0
- 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 +336 -0
- metadata +34 -34
- data/lib/syntropy/file_watch.rb +0 -28
- data/test/test_file_watch.rb +0 -36
|
@@ -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
|
|
@@ -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 html_response(html, **headers)
|
|
183
|
+
respond(
|
|
184
|
+
html,
|
|
185
|
+
'Content-Type' => 'text/html; charset=utf-8',
|
|
186
|
+
**headers
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def json_response(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
|
|
@@ -1,34 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
4
|
-
require '
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'escape_utils'
|
|
5
5
|
|
|
6
6
|
module Syntropy
|
|
7
|
-
|
|
8
|
-
module RequestExtensions
|
|
9
|
-
attr_reader :route_params
|
|
10
|
-
attr_accessor :route
|
|
11
|
-
|
|
12
|
-
# Initializes request with additional fields
|
|
13
|
-
def initialize(headers, adapter)
|
|
14
|
-
@headers = headers
|
|
15
|
-
@adapter = adapter
|
|
16
|
-
@route = nil
|
|
17
|
-
@route_params = {}
|
|
18
|
-
@ctx = nil
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Sets up mock request additional fields
|
|
22
|
-
def setup_mock_request
|
|
23
|
-
@route = nil
|
|
24
|
-
@route_params = {}
|
|
25
|
-
@ctx = nil
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Returns the request context
|
|
29
|
-
def ctx
|
|
30
|
-
@ctx ||= {}
|
|
31
|
-
end
|
|
7
|
+
module RequestValidationMethods
|
|
32
8
|
|
|
33
9
|
# Checks the request's HTTP method against the given accepted values. If not
|
|
34
10
|
# included in the accepted values, raises an exception. Otherwise, returns
|
|
@@ -42,64 +18,6 @@ module Syntropy
|
|
|
42
18
|
raise Syntropy::Error.method_not_allowed
|
|
43
19
|
end
|
|
44
20
|
|
|
45
|
-
# Responds according to the given map. The given map defines the responses
|
|
46
|
-
# for each method. The value for each method is either an array containing
|
|
47
|
-
# the body and header values to use as response, or a proc returning such an
|
|
48
|
-
# array. For example:
|
|
49
|
-
#
|
|
50
|
-
# req.respond_by_http_method(
|
|
51
|
-
# 'head' => [nil, headers],
|
|
52
|
-
# 'get' => -> { [IO.read(fn), headers] }
|
|
53
|
-
# )
|
|
54
|
-
#
|
|
55
|
-
# If the request's method is not included in the given map, an exception is
|
|
56
|
-
# raised.
|
|
57
|
-
#
|
|
58
|
-
# @param map [Hash] hash mapping HTTP methods to responses
|
|
59
|
-
# @return [void]
|
|
60
|
-
def respond_by_http_method(map)
|
|
61
|
-
value = map[self.method]
|
|
62
|
-
raise Syntropy::Error.method_not_allowed if !value
|
|
63
|
-
|
|
64
|
-
value = value.() if value.is_a?(Proc)
|
|
65
|
-
(body, headers) = value
|
|
66
|
-
respond(body, headers)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Responds to GET requests with the given body and headers. Otherwise raises
|
|
70
|
-
# an exception.
|
|
71
|
-
#
|
|
72
|
-
# @param body [String, nil] response body
|
|
73
|
-
# @param headers [Hash] response headers
|
|
74
|
-
# @return [void]
|
|
75
|
-
def respond_on_get(body, headers = {})
|
|
76
|
-
case self.method
|
|
77
|
-
when 'head'
|
|
78
|
-
respond(nil, headers)
|
|
79
|
-
when 'get'
|
|
80
|
-
respond(body, headers)
|
|
81
|
-
else
|
|
82
|
-
raise Syntropy::Error.method_not_allowed
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Responds to POST requests with the given body and headers. Otherwise
|
|
87
|
-
# raises an exception.
|
|
88
|
-
#
|
|
89
|
-
# @param body [String, nil] response body
|
|
90
|
-
# @param headers [Hash] response headers
|
|
91
|
-
# @return [void]
|
|
92
|
-
def respond_on_post(body, headers = {})
|
|
93
|
-
case self.method
|
|
94
|
-
when 'head'
|
|
95
|
-
respond(nil, headers)
|
|
96
|
-
when 'post'
|
|
97
|
-
respond(body, headers)
|
|
98
|
-
else
|
|
99
|
-
raise Syntropy::Error.method_not_allowed
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
21
|
# Validates and optionally converts request parameter value for the given
|
|
104
22
|
# parameter name against the given clauses. If no clauses are given,
|
|
105
23
|
# verifies the parameter value is not nil. A clause can be a class, such as
|
|
@@ -159,7 +77,7 @@ module Syntropy
|
|
|
159
77
|
validated = true if client_mtime == last_modified
|
|
160
78
|
end
|
|
161
79
|
if validated
|
|
162
|
-
respond(nil, ':status' =>
|
|
80
|
+
respond(nil, ':status' => HTTP::NOT_MODIFIED)
|
|
163
81
|
else
|
|
164
82
|
cache_headers = {
|
|
165
83
|
'Cache-Control' => cache_control
|
|
@@ -171,67 +89,8 @@ module Syntropy
|
|
|
171
89
|
end
|
|
172
90
|
end
|
|
173
91
|
|
|
174
|
-
# Reads the request body and returns form data.
|
|
175
|
-
#
|
|
176
|
-
# @return [Hash] form data
|
|
177
|
-
def get_form_data
|
|
178
|
-
body = read
|
|
179
|
-
if !body || body.empty?
|
|
180
|
-
raise Syntropy::Error.new('Missing form data', Qeweney::Status::BAD_REQUEST)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
Qeweney::Request.parse_form_data(body, headers)
|
|
184
|
-
rescue Qeweney::BadRequestError
|
|
185
|
-
raise Syntropy::Error.new('Invalid form data', Qeweney::Status::BAD_REQUEST)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def html_response(html, **headers)
|
|
189
|
-
respond(
|
|
190
|
-
html,
|
|
191
|
-
'Content-Type' => 'text/html; charset=utf-8',
|
|
192
|
-
**headers
|
|
193
|
-
)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def json_response(obj, **headers)
|
|
197
|
-
respond(
|
|
198
|
-
JSON.dump(obj),
|
|
199
|
-
'Content-Type' => 'application/json; charset=utf-8',
|
|
200
|
-
**headers
|
|
201
|
-
)
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def json_pretty_response(obj, **headers)
|
|
205
|
-
respond(
|
|
206
|
-
JSON.pretty_generate(obj),
|
|
207
|
-
'Content-Type' => 'application/json; charset=utf-8',
|
|
208
|
-
**headers
|
|
209
|
-
)
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def browser?
|
|
213
|
-
user_agent = headers['user-agent']
|
|
214
|
-
user_agent && user_agent =~ /^Mozilla\//
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
# Returns true if the accept header includes the given MIME type
|
|
218
|
-
#
|
|
219
|
-
# @param mime_type [String] MIME type
|
|
220
|
-
# @return [bool]
|
|
221
|
-
def accept?(mime_type)
|
|
222
|
-
accept = headers['accept']
|
|
223
|
-
return nil if !accept
|
|
224
|
-
|
|
225
|
-
@accept_parts ||= parse_accept_parts(accept)
|
|
226
|
-
@accept_parts.include?(mime_type)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
92
|
private
|
|
230
93
|
|
|
231
|
-
def parse_accept_parts(accept)
|
|
232
|
-
accept.split(',').map { it.match(/^\s*([^\s;]+)/)[1] }
|
|
233
|
-
end
|
|
234
|
-
|
|
235
94
|
BOOL_REGEXP = /^(t|f|true|false|on|off|1|0|yes|no)$/
|
|
236
95
|
BOOL_TRUE_REGEXP = /^(t|true|on|1|yes)$/
|
|
237
96
|
INTEGER_REGEXP = /^[+-]?[0-9]+$/
|
|
@@ -278,5 +137,3 @@ module Syntropy
|
|
|
278
137
|
end
|
|
279
138
|
end
|
|
280
139
|
end
|
|
281
|
-
|
|
282
|
-
Qeweney::Request.include(Syntropy::RequestExtensions)
|