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
data/lib/syntropy/connection.rb
DELETED
|
@@ -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,308 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'qeweney'
|
|
4
|
-
require 'json'
|
|
5
|
-
|
|
6
|
-
class Qeweney::Request
|
|
7
|
-
attr_accessor :start_stamp
|
|
8
|
-
|
|
9
|
-
def respond_with_static_file(path, etag, last_modified, opts)
|
|
10
|
-
cache_headers = (etag || last_modified) ? {
|
|
11
|
-
'etag' => etag,
|
|
12
|
-
'last-modified' => last_modified
|
|
13
|
-
} : {}
|
|
14
|
-
|
|
15
|
-
adapter.respond_with_static_file(self, path, opts, cache_headers)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def set_response_headers(headers)
|
|
19
|
-
adapter.set_response_headers(headers)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def set_cookie(*)
|
|
23
|
-
adapter.set_cookie(*)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def upgrade(protocol, custom_headers = nil, &block)
|
|
27
|
-
super(protocol, custom_headers)
|
|
28
|
-
adapter.with_stream(&block)
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
module Syntropy
|
|
33
|
-
# Extensions for the Qeweney::Request class
|
|
34
|
-
module RequestExtensions
|
|
35
|
-
attr_reader :route_params
|
|
36
|
-
attr_accessor :route
|
|
37
|
-
|
|
38
|
-
# Initializes request with additional fields
|
|
39
|
-
def initialize(headers, adapter)
|
|
40
|
-
@headers = headers
|
|
41
|
-
@adapter = adapter
|
|
42
|
-
@route = nil
|
|
43
|
-
@route_params = {}
|
|
44
|
-
@ctx = nil
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Sets up mock request additional fields
|
|
48
|
-
def setup_mock_request
|
|
49
|
-
@route = nil
|
|
50
|
-
@route_params = {}
|
|
51
|
-
@ctx = nil
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Returns the request context
|
|
55
|
-
def ctx
|
|
56
|
-
@ctx ||= {}
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Checks the request's HTTP method against the given accepted values. If not
|
|
60
|
-
# included in the accepted values, raises an exception. Otherwise, returns
|
|
61
|
-
# the request's HTTP method.
|
|
62
|
-
#
|
|
63
|
-
# @param accepted [Array<String>] list of accepted HTTP methods
|
|
64
|
-
# @return [String] request's HTTP method
|
|
65
|
-
def validate_http_method(*accepted)
|
|
66
|
-
return method if accepted.include?(method)
|
|
67
|
-
|
|
68
|
-
raise Syntropy::Error.method_not_allowed
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Responds according to the given map. The given map defines the responses
|
|
72
|
-
# for each method. The value for each method is either an array containing
|
|
73
|
-
# the body and header values to use as response, or a proc returning such an
|
|
74
|
-
# array. For example:
|
|
75
|
-
#
|
|
76
|
-
# req.respond_by_http_method(
|
|
77
|
-
# 'head' => [nil, headers],
|
|
78
|
-
# 'get' => -> { [IO.read(fn), headers] }
|
|
79
|
-
# )
|
|
80
|
-
#
|
|
81
|
-
# If the request's method is not included in the given map, an exception is
|
|
82
|
-
# raised.
|
|
83
|
-
#
|
|
84
|
-
# @param map [Hash] hash mapping HTTP methods to responses
|
|
85
|
-
# @return [void]
|
|
86
|
-
def respond_by_http_method(map)
|
|
87
|
-
value = map[self.method]
|
|
88
|
-
raise Syntropy::Error.method_not_allowed if !value
|
|
89
|
-
|
|
90
|
-
value = value.() if value.is_a?(Proc)
|
|
91
|
-
(body, headers) = value
|
|
92
|
-
respond(body, headers)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Responds to GET requests with the given body and headers. Otherwise raises
|
|
96
|
-
# an exception.
|
|
97
|
-
#
|
|
98
|
-
# @param body [String, nil] response body
|
|
99
|
-
# @param headers [Hash] response headers
|
|
100
|
-
# @return [void]
|
|
101
|
-
def respond_on_get(body, headers = {})
|
|
102
|
-
case self.method
|
|
103
|
-
when 'head'
|
|
104
|
-
respond(nil, headers)
|
|
105
|
-
when 'get'
|
|
106
|
-
respond(body, headers)
|
|
107
|
-
else
|
|
108
|
-
raise Syntropy::Error.method_not_allowed
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Responds to POST requests with the given body and headers. Otherwise
|
|
113
|
-
# raises an exception.
|
|
114
|
-
#
|
|
115
|
-
# @param body [String, nil] response body
|
|
116
|
-
# @param headers [Hash] response headers
|
|
117
|
-
# @return [void]
|
|
118
|
-
def respond_on_post(body, headers = {})
|
|
119
|
-
case self.method
|
|
120
|
-
when 'head'
|
|
121
|
-
respond(nil, headers)
|
|
122
|
-
when 'post'
|
|
123
|
-
respond(body, headers)
|
|
124
|
-
else
|
|
125
|
-
raise Syntropy::Error.method_not_allowed
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Validates and optionally converts request parameter value for the given
|
|
130
|
-
# parameter name against the given clauses. If no clauses are given,
|
|
131
|
-
# verifies the parameter value is not nil. A clause can be a class, such as
|
|
132
|
-
# String, Integer, etc, in which case the value is converted into the
|
|
133
|
-
# corresponding value. A clause can also be a range, for verifying the value
|
|
134
|
-
# is within the range. A clause can also be an array of two or more clauses,
|
|
135
|
-
# at least one of which should match the value. If the validation fails, an
|
|
136
|
-
# exception is raised. Example:
|
|
137
|
-
#
|
|
138
|
-
# height = req.validate_param(:height, Integer, 1..100)
|
|
139
|
-
#
|
|
140
|
-
# @param name [Symbol] parameter name
|
|
141
|
-
# @clauses [Array] one or more validation clauses
|
|
142
|
-
# @return [any] validated parameter value
|
|
143
|
-
def validate_param(name, *clauses)
|
|
144
|
-
validate(query[name], *clauses)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Validates and optionally converts a value against the given clauses. If no
|
|
148
|
-
# clauses are given, verifies the parameter value is not nil. A clause can
|
|
149
|
-
# be a class, such as String, Integer, etc, in which case the value is
|
|
150
|
-
# converted into the corresponding value. A clause can also be a range, for
|
|
151
|
-
# verifying the value is within the range. A clause can also be an array of
|
|
152
|
-
# two or more clauses, at least one of which should match the value. If the
|
|
153
|
-
# validation fails, an exception is raised.
|
|
154
|
-
#
|
|
155
|
-
# @param value [any] value
|
|
156
|
-
# @clauses [Array] one or more validation clauses
|
|
157
|
-
# @return [any] validated value
|
|
158
|
-
def validate(value, *clauses)
|
|
159
|
-
raise Syntropy::ValidationError, 'Validation error' if clauses.empty? && !value
|
|
160
|
-
|
|
161
|
-
clauses.each do |c|
|
|
162
|
-
valid = param_is_valid?(value, c)
|
|
163
|
-
raise(Syntropy::ValidationError, 'Validation error') if !valid
|
|
164
|
-
|
|
165
|
-
value = param_convert(value, c)
|
|
166
|
-
end
|
|
167
|
-
value
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# Validates request cache information. If the request cache information
|
|
171
|
-
# matches the given etag or last_modified values, responds with a 304 Not
|
|
172
|
-
# Modified status. Otherwise, yields to the given block for a normal
|
|
173
|
-
# response, and sets cache control headers according to the given arguments.
|
|
174
|
-
#
|
|
175
|
-
# @param cache_control [String] value for Cache-Control header
|
|
176
|
-
# @param etag [String, nil] Etag header value
|
|
177
|
-
# @param last_modified [String, nil] Last-Modified header value
|
|
178
|
-
# @return [void]
|
|
179
|
-
def validate_cache(cache_control: 'public', etag: nil, last_modified: nil)
|
|
180
|
-
validated = false
|
|
181
|
-
if (client_etag = headers['if-none-match'])
|
|
182
|
-
validated = true if client_etag == etag
|
|
183
|
-
end
|
|
184
|
-
if (client_mtime = headers['if-modified-since'])
|
|
185
|
-
validated = true if client_mtime == last_modified
|
|
186
|
-
end
|
|
187
|
-
if validated
|
|
188
|
-
respond(nil, ':status' => Qeweney::Status::NOT_MODIFIED)
|
|
189
|
-
else
|
|
190
|
-
cache_headers = {
|
|
191
|
-
'Cache-Control' => cache_control
|
|
192
|
-
}
|
|
193
|
-
cache_headers['Etag'] = etag if etag
|
|
194
|
-
cache_headers['Last-Modified'] = last_modified if last_modified
|
|
195
|
-
set_response_headers(cache_headers)
|
|
196
|
-
yield
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Reads the request body and returns form data.
|
|
201
|
-
#
|
|
202
|
-
# @return [Hash] form data
|
|
203
|
-
def get_form_data
|
|
204
|
-
body = read
|
|
205
|
-
if !body || body.empty?
|
|
206
|
-
raise Syntropy::Error.new('Missing form data', Qeweney::Status::BAD_REQUEST)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
Qeweney::Request.parse_form_data(body, headers)
|
|
210
|
-
rescue Qeweney::BadRequestError
|
|
211
|
-
raise Syntropy::Error.new('Invalid form data', Qeweney::Status::BAD_REQUEST)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def html_response(html, **headers)
|
|
215
|
-
respond(
|
|
216
|
-
html,
|
|
217
|
-
'Content-Type' => 'text/html; charset=utf-8',
|
|
218
|
-
**headers
|
|
219
|
-
)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def json_response(obj, **headers)
|
|
223
|
-
respond(
|
|
224
|
-
JSON.dump(obj),
|
|
225
|
-
'Content-Type' => 'application/json; charset=utf-8',
|
|
226
|
-
**headers
|
|
227
|
-
)
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def json_pretty_response(obj, **headers)
|
|
231
|
-
respond(
|
|
232
|
-
JSON.pretty_generate(obj),
|
|
233
|
-
'Content-Type' => 'application/json; charset=utf-8',
|
|
234
|
-
**headers
|
|
235
|
-
)
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def browser?
|
|
239
|
-
user_agent = headers['user-agent']
|
|
240
|
-
user_agent && user_agent =~ /^Mozilla\//
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
# Returns true if the accept header includes the given MIME type
|
|
244
|
-
#
|
|
245
|
-
# @param mime_type [String] MIME type
|
|
246
|
-
# @return [bool]
|
|
247
|
-
def accept?(mime_type)
|
|
248
|
-
accept = headers['accept']
|
|
249
|
-
return nil if !accept
|
|
250
|
-
|
|
251
|
-
@accept_parts ||= parse_accept_parts(accept)
|
|
252
|
-
@accept_parts.include?(mime_type)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
private
|
|
256
|
-
|
|
257
|
-
def parse_accept_parts(accept)
|
|
258
|
-
accept.split(',').map { it.match(/^\s*([^\s;]+)/)[1] }
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
BOOL_REGEXP = /^(t|f|true|false|on|off|1|0|yes|no)$/
|
|
262
|
-
BOOL_TRUE_REGEXP = /^(t|true|on|1|yes)$/
|
|
263
|
-
INTEGER_REGEXP = /^[+-]?[0-9]+$/
|
|
264
|
-
FLOAT_REGEXP = /^[+-]?[0-9]+(\.[0-9]+)?$/
|
|
265
|
-
|
|
266
|
-
# Returns true the given value matches the given condition.
|
|
267
|
-
#
|
|
268
|
-
# @param value [any] value
|
|
269
|
-
# @param cond [any] condition
|
|
270
|
-
# @return [bool]
|
|
271
|
-
def param_is_valid?(value, cond)
|
|
272
|
-
return cond.any? { |c| param_is_valid?(value, c) } if cond.is_a?(Array)
|
|
273
|
-
|
|
274
|
-
if value
|
|
275
|
-
if cond == :bool
|
|
276
|
-
return value =~ BOOL_REGEXP
|
|
277
|
-
elsif cond == Integer
|
|
278
|
-
return value =~ INTEGER_REGEXP
|
|
279
|
-
elsif cond == Float
|
|
280
|
-
return value =~ FLOAT_REGEXP
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
cond === value
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
# Converts the given value according to the given class.
|
|
288
|
-
#
|
|
289
|
-
# @param value [any] value
|
|
290
|
-
# @param klass [Class] class
|
|
291
|
-
# @return [any] converted value
|
|
292
|
-
def param_convert(value, klass)
|
|
293
|
-
if klass == :bool
|
|
294
|
-
value =~ BOOL_TRUE_REGEXP ? true : false
|
|
295
|
-
elsif klass == Integer
|
|
296
|
-
value.to_i
|
|
297
|
-
elsif klass == Float
|
|
298
|
-
value.to_f
|
|
299
|
-
elsif klass == Symbol
|
|
300
|
-
value.to_sym
|
|
301
|
-
else
|
|
302
|
-
value
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
Qeweney::Request.include(Syntropy::RequestExtensions)
|