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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +2 -2
  3. data/CHANGELOG.md +22 -0
  4. data/README.md +0 -2
  5. data/bin/syntropy +8 -86
  6. data/cmd/_banner.rb +16 -0
  7. data/cmd/help.rb +12 -0
  8. data/cmd/serve.rb +95 -0
  9. data/cmd/test.rb +40 -0
  10. data/examples/{counter.rb → basic/counter.rb} +1 -1
  11. data/examples/{templates.rb → basic/templates.rb} +1 -1
  12. data/examples/mcp-oauth/.ruby-version +1 -0
  13. data/examples/mcp-oauth/Gemfile +8 -0
  14. data/examples/mcp-oauth/README.md +128 -0
  15. data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
  16. data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
  17. data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
  18. data/examples/mcp-oauth/app/index.md +1 -0
  19. data/examples/mcp-oauth/app/mcp.rb +38 -0
  20. data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
  21. data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
  22. data/examples/mcp-oauth/app/oauth/register.rb +15 -0
  23. data/examples/mcp-oauth/app/oauth/token.rb +79 -0
  24. data/examples/mcp-oauth/app/signin.rb +85 -0
  25. data/examples/mcp-oauth/test/helper.rb +9 -0
  26. data/examples/mcp-oauth/test/test_app.rb +27 -0
  27. data/examples/mcp-oauth/test/test_oauth.rb +628 -0
  28. data/lib/syntropy/app.rb +23 -12
  29. data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
  30. data/lib/syntropy/applets/builtin/req.rb +1 -1
  31. data/lib/syntropy/dev_mode.rb +1 -1
  32. data/lib/syntropy/errors.rb +19 -12
  33. data/lib/syntropy/http/client.rb +43 -0
  34. data/lib/syntropy/http/client_connection.rb +36 -0
  35. data/lib/syntropy/http/io_extensions.rb +148 -0
  36. data/lib/syntropy/http/server.rb +174 -0
  37. data/lib/syntropy/http/server_connection.rb +367 -0
  38. data/lib/syntropy/http/status.rb +76 -0
  39. data/lib/syntropy/http.rb +7 -0
  40. data/lib/syntropy/json_api.rb +2 -5
  41. data/lib/syntropy/logger.rb +5 -1
  42. data/lib/syntropy/mime_types.rb +37 -0
  43. data/lib/syntropy/papercraft_extensions.rb +1 -1
  44. data/lib/syntropy/request/mock_adapter.rb +60 -0
  45. data/lib/syntropy/request/request_info.rb +255 -0
  46. data/lib/syntropy/request/response.rb +206 -0
  47. data/lib/syntropy/request/validation.rb +146 -0
  48. data/lib/syntropy/request.rb +99 -0
  49. data/lib/syntropy/routing_tree.rb +2 -1
  50. data/lib/syntropy/test.rb +65 -0
  51. data/lib/syntropy/utils.rb +1 -1
  52. data/lib/syntropy/version.rb +1 -1
  53. data/lib/syntropy.rb +4 -27
  54. data/syntropy.gemspec +2 -4
  55. data/test/app/.well-known/foo.rb +3 -0
  56. data/test/app/about/_error.rb +1 -1
  57. data/test/app/api+.rb +1 -1
  58. data/test/app_custom/_site.rb +1 -1
  59. data/test/bm_router_proc.rb +3 -3
  60. data/test/helper.rb +4 -27
  61. data/test/test_app.rb +83 -98
  62. data/test/test_caching.rb +2 -2
  63. data/test/test_errors.rb +6 -6
  64. data/test/test_http_client.rb +52 -0
  65. data/test/test_http_client_connection.rb +43 -0
  66. data/test/{test_connection.rb → test_http_server_connection.rb} +32 -32
  67. data/test/test_json_api.rb +14 -12
  68. data/test/test_mock_adapter.rb +59 -0
  69. data/test/{test_request_extensions.rb → test_request.rb} +150 -18
  70. data/test/test_response.rb +112 -0
  71. data/test/test_routing_tree.rb +15 -3
  72. data/test/test_server.rb +1 -1
  73. metadata +57 -35
  74. data/lib/syntropy/connection.rb +0 -402
  75. data/lib/syntropy/request_extensions.rb +0 -308
  76. data/lib/syntropy/server.rb +0 -173
  77. /data/examples/{bad.rb → basic/bad.rb} +0 -0
  78. /data/examples/{card.rb → basic/card.rb} +0 -0
  79. /data/examples/{counter.js → basic/counter.js} +0 -0
  80. /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
  81. /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
  82. /data/examples/{index.md → basic/index.md} +0 -0
@@ -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)