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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +2 -2
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +11 -0
  5. data/README.md +0 -4
  6. data/bin/syntropy +2 -3
  7. data/cmd/setup/template/site/Dockerfile +1 -1
  8. data/lib/syntropy/app.rb +22 -14
  9. data/lib/syntropy/errors.rb +23 -10
  10. data/lib/syntropy/http/connection.rb +396 -0
  11. data/lib/syntropy/http/server.rb +174 -0
  12. data/lib/syntropy/http/status.rb +76 -0
  13. data/lib/syntropy/http.rb +5 -0
  14. data/lib/syntropy/json_api.rb +2 -5
  15. data/lib/syntropy/logger.rb +103 -0
  16. data/lib/syntropy/mime_types.rb +37 -0
  17. data/lib/syntropy/request/mock_adapter.rb +58 -0
  18. data/lib/syntropy/request/request_info.rb +236 -0
  19. data/lib/syntropy/request/response.rb +206 -0
  20. data/lib/syntropy/{request_extensions.rb → request/validation.rb} +4 -147
  21. data/lib/syntropy/request.rb +99 -0
  22. data/lib/syntropy/utils.rb +1 -1
  23. data/lib/syntropy/version.rb +1 -1
  24. data/lib/syntropy.rb +53 -5
  25. data/syntropy.gemspec +5 -7
  26. data/test/app/about/_error.rb +1 -1
  27. data/test/app/api+.rb +1 -1
  28. data/test/app_custom/_site.rb +1 -1
  29. data/test/bm_router_proc.rb +3 -3
  30. data/test/helper.rb +12 -7
  31. data/test/test_app.rb +30 -30
  32. data/test/test_caching.rb +2 -2
  33. data/test/test_connection.rb +649 -0
  34. data/test/test_errors.rb +6 -6
  35. data/test/test_json_api.rb +10 -8
  36. data/test/test_mock_adapter.rb +59 -0
  37. data/test/test_request_info.rb +90 -0
  38. data/test/test_response.rb +112 -0
  39. data/test/test_server.rb +336 -0
  40. metadata +34 -34
  41. data/lib/syntropy/file_watch.rb +0 -28
  42. 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 'qeweney'
4
- require 'json'
3
+ require 'uri'
4
+ require 'escape_utils'
5
5
 
6
6
  module Syntropy
7
- # Extensions for the Qeweney::Request class
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' => Qeweney::Status::NOT_MODIFIED)
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)