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
@@ -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
- Dir[File.join(dir.gsub(/[\[\]]/) { "\\#{it}"}, '*')]
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.