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,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/errors'
4
+ require 'syntropy/http/io_extensions'
5
+
6
+ module Syntropy
7
+ module HTTP
8
+ # Implements an HTTP/1.1 connection received by the Syntropy server. This
9
+ # implementation rejects incoming HTTP/0.9 or HTTP/1.0 requests. The response
10
+ # body is sent exclusively using chunked transfer encoding. Request bodies are
11
+ # accepted using either fixed length (Content-Length header) or chunked
12
+ # transfer encoding.
13
+ class ServerConnection
14
+ attr_reader :fd, :response_headers, :logger
15
+
16
+ def initialize(machine, fd, env, io_mode: :socket, &app)
17
+ @machine = machine
18
+ @fd = fd
19
+ @env = env
20
+ @logger = env[:logger]
21
+ @io = machine.io(fd, io_mode)
22
+ @app = app
23
+
24
+ @done = nil
25
+ @response_headers = nil
26
+ end
27
+
28
+ def run
29
+ loop do
30
+ @done = nil
31
+ @response_headers = nil
32
+ persist = serve_request
33
+ break if !persist
34
+ end
35
+ rescue UM::Terminate
36
+ # server is terminated, do nothing
37
+ rescue StandardError => e
38
+ @logger&.error(
39
+ message: 'Uncaught error while running connection',
40
+ error: e
41
+ )
42
+ ensure
43
+ @machine.close_async(@fd)
44
+ end
45
+
46
+ # Processes an incoming request by parsing the headers, creating a request
47
+ # object and handing it off to the app handler. Returns true if the
48
+ # connection should be persisted.
49
+ def serve_request
50
+ @closed = nil
51
+ headers = parse_headers
52
+ return false if !headers
53
+
54
+ request = Syntropy::Request.new(headers, self)
55
+
56
+ @app.call(request)
57
+ persist_connection?(headers)
58
+ rescue StandardError => e
59
+ handle_error(request, e)
60
+ false
61
+ end
62
+
63
+ # Handles an error encountered while serving a request by logging the error
64
+ # and optionally sending an error response with the relevant HTTP status
65
+ # code. For I/O errors, no response is sent.
66
+ #
67
+ # @param request [Syntropy::Request] HTTP request
68
+ # @param err [Exception] error
69
+ # @return [void]
70
+ def handle_error(request, err)
71
+ case err
72
+ when SystemCallError
73
+ log_error(err, 'I/O error')
74
+ false
75
+ when ProtocolError
76
+ log_error(err, err.message)
77
+ respond(request, err.message, ':status' => err.http_status)
78
+ else
79
+ log_error(err, 'Internal error')
80
+ return if !request || @done
81
+
82
+ respond(request, 'Internal server error', ':status' => INTERNAL_SERVER_ERROR)
83
+ end
84
+ end
85
+
86
+ # Logs the given err and given message.
87
+ #
88
+ # @param err [Exception] error
89
+ # @param message [String] error message
90
+ # @return [void]
91
+ def log_error(err, message)
92
+ @logger&.error(message: "#{message}, closing connection", error: err)
93
+ end
94
+
95
+ def get_body(req)
96
+ headers = req.headers
97
+ return nil if headers[':body-done-reading']
98
+
99
+ body = @io.http_read_body(headers)
100
+ headers[':body-done-reading'] = true if body
101
+ body
102
+ end
103
+
104
+ def get_body_chunk(req)
105
+ headers = req.headers
106
+ return nil if headers[':body-done-reading']
107
+
108
+ chunk = @io.http_read_body_chunk(headers)
109
+ headers[':body-done-reading'] = true if !chunk
110
+ chunk
111
+ end
112
+
113
+ def complete?(req)
114
+ req.headers[':body-done-reading']
115
+ end
116
+
117
+ # response API
118
+
119
+ # Sets response headers before sending any response. This method is used to
120
+ # add headers such as Set-Cookie or cache control headers to a response
121
+ # before actually responding, specifically in middleware hooks.
122
+ #
123
+ # @param headers [Hash] response headers
124
+ # @return [void]
125
+ def set_response_headers(headers)
126
+ @response_headers ? @response_headers.merge!(headers) : @response_headers = headers
127
+ end
128
+
129
+ def set_cookie(*cookies)
130
+ existing_cookies = @response_headers && @response_headers['Set-Cookie']
131
+ if existing_cookies
132
+ @response_headers['Set-Cookie'] = existing_cookies + cookies
133
+ else
134
+ set_response_headers('Set-Cookie' => cookies)
135
+ end
136
+ end
137
+
138
+ SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
139
+
140
+ EMPTY_CHUNK = "0\r\n\r\n"
141
+ EMPTY_CHUNK_LEN = EMPTY_CHUNK.bytesize
142
+
143
+ CHUNKED_ENCODING_POSTLUDE = "\r\n#{EMPTY_CHUNK}"
144
+
145
+ # Sends response including headers and body. Waits for the request to complete
146
+ # if not yet completed. The body is sent using chunked transfer encoding.
147
+ # @param request [Syntropy::Request] HTTP request
148
+ # @param body [String] response body
149
+ # @param headers
150
+ def respond(request, body, headers)
151
+ headers = @response_headers.merge(headers) if @response_headers
152
+
153
+ formatted_headers = format_headers(headers, body)
154
+ @response_headers = headers
155
+ request&.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
156
+ if body
157
+ chunk_prelude = "#{body.bytesize.to_s(16)}\r\n"
158
+ @machine.sendv(@fd, formatted_headers, chunk_prelude, body, CHUNKED_ENCODING_POSTLUDE)
159
+ else
160
+ @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
161
+ end
162
+ @logger&.info(request: request, response_headers: headers) if request
163
+ @done = true
164
+ end
165
+
166
+ # Sends response headers. If empty_response is truthy, the response status
167
+ # code will default to 204, otherwise to 200.
168
+ # @param request [Syntropy::Request] HTTP request
169
+ # @param headers [Hash] response headers
170
+ # @param empty_response [boolean] whether a response body will be sent
171
+ # @return [void]
172
+ def send_headers(request, headers, empty_response: false)
173
+ formatted_headers = format_headers(headers, !empty_response)
174
+ request.tx_incr(formatted_headers.bytesize)
175
+ @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
176
+ @response_headers = headers
177
+ end
178
+
179
+ # Sends a response body chunk. If no headers were sent, default headers are
180
+ # sent using #send_headers. if the done option is true(thy), an empty chunk
181
+ # will be sent to signal response completion to the client.
182
+ # @param request [Syntropy::Request] HTTP request
183
+ # @param chunk [String] response body chunk
184
+ # @param done [boolean] whether the response is completed
185
+ # @return [void]
186
+ def send_chunk(request, chunk, done: false)
187
+ data = +''
188
+ data << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" if chunk
189
+ data << EMPTY_CHUNK if done
190
+ return if data.empty?
191
+
192
+ request.tx_incr(data.bytesize)
193
+ @machine.send(@fd, data, data.bytesize, SEND_FLAGS)
194
+ return if @done || !done
195
+
196
+ @logger&.info(request: request, response_headers: @response_headers)
197
+ @done = true
198
+ end
199
+
200
+ # Finishes the response to the current request. If no headers were sent,
201
+ # default headers are sent using #send_headers.
202
+ # @return [void]
203
+ def finish(request)
204
+ request.tx_incr(EMPTY_CHUNK_LEN)
205
+ @machine.send(@fd, EMPTY_CHUNK, EMPTY_CHUNK_LEN, SEND_FLAGS)
206
+ return if @done
207
+
208
+ @logger&.info(request, request, response_headers: @response_headers)
209
+ @done = true
210
+ end
211
+
212
+ def respond_with_static_file(req, path, env, cache_headers)
213
+ fd = @machine.open(path, UM::O_RDONLY)
214
+ env ||= {}
215
+ if env[:headers]
216
+ env[:headers].merge!(cache_headers)
217
+ else
218
+ env[:headers] = cache_headers
219
+ end
220
+
221
+ maxlen = env[:max_len] || 65_536
222
+ buf = String.new(capacity: maxlen)
223
+ headers_sent = nil
224
+ loop do
225
+ res = @machine.read(fd, buf, maxlen, 0)
226
+ if res < maxlen && !headers_sent
227
+ return respond(req, buf, env[:headers])
228
+ elsif res == 0
229
+ return finish(req)
230
+ end
231
+
232
+ if !headers_sent
233
+ send_headers(req, env[:headers])
234
+ headers_sent = true
235
+ end
236
+ done = res < maxlen
237
+ send_chunk(req, buf, done: done)
238
+ return if done
239
+ end
240
+ end
241
+
242
+ def close
243
+ return if @closed
244
+
245
+ @closed = true
246
+ @machine.shutdown(@fd, UM::SHUT_WR)
247
+ @machine.close_async(@fd)
248
+ end
249
+
250
+ def with_stream
251
+ yield @io, @fd
252
+ end
253
+
254
+ private
255
+
256
+ RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+http\/([019\.]{1,3})/i
257
+ RE_HEADER_LINE = /^([a-z0-9-]+):\s+(.+)/i
258
+ MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
259
+ MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
260
+ MAX_CHUNK_SIZE_LEN = 16
261
+
262
+ def persist_connection?(headers)
263
+ connection = headers['connection']&.downcase
264
+ return connection != 'close'
265
+ end
266
+
267
+ def parse_headers
268
+ headers = get_request_line(MAX_REQUEST_LINE_LEN)
269
+ return nil if !headers
270
+
271
+ loop do
272
+ line = @io.read_line(MAX_HEADER_LINE_LEN)
273
+ break if line.nil? || line.empty?
274
+
275
+ m = line.match(RE_HEADER_LINE)
276
+ raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
277
+
278
+ headers[m[1].downcase] = m[2]
279
+ end
280
+
281
+ headers
282
+ end
283
+
284
+ def get_request_line(buf)
285
+ line = @io.read_line(MAX_REQUEST_LINE_LEN)
286
+ return nil if !line
287
+
288
+ m = line.match(RE_REQUEST_LINE)
289
+ raise ProtocolError, 'Invalid request line' if !m
290
+
291
+ http_version = m[3]
292
+ raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
293
+
294
+ {
295
+ ':method' => m[1].downcase,
296
+ ':path' => m[2],
297
+ ':protocol' => 'http/1.1'
298
+ }
299
+ end
300
+
301
+ def read_chunk(headers, buffer)
302
+ chunk_size_str = @io.read_line(MAX_CHUNK_SIZE_LEN)
303
+ return nil if !chunk_size_str
304
+
305
+ chunk_size = chunk_size_str.to_i(16)
306
+ if chunk_size == 0
307
+ headers[':body-done-reading'] = true
308
+ @io.read_line(0)
309
+ return nil
310
+ end
311
+
312
+ chunk = @io.read(chunk_size)
313
+ @io.read_line(0)
314
+
315
+ buffer ? (buffer << chunk) : chunk
316
+ end
317
+
318
+ INTERNAL_HEADER_REGEXP = /^:/
319
+
320
+ # Formats response headers into an array. If empty_response is true(thy),
321
+ # the response status code will default to 204, otherwise to 200.
322
+ # @param headers [Hash] response headers
323
+ # @param body [boolean] whether a response body will be sent
324
+ # @return [String] formatted response headers
325
+ def format_headers(headers, body)
326
+ status = headers[':status'] || (body ? OK : NO_CONTENT)
327
+ lines = format_status_line(body, status)
328
+ lines << @env[:server_headers] if @env[:server_headers]
329
+ headers.each do |k, v|
330
+ next if k =~ INTERNAL_HEADER_REGEXP
331
+
332
+ collect_header_lines(lines, k, v)
333
+ end
334
+ lines << "\r\n"
335
+ lines
336
+ end
337
+
338
+ def format_status_line(body, status)
339
+ if !body
340
+ empty_status_line(status)
341
+ else
342
+ with_body_status_line(status, body)
343
+ end
344
+ end
345
+
346
+ def empty_status_line(status)
347
+ if status == 204
348
+ +"HTTP/1.1 #{status}\r\n"
349
+ else
350
+ +"HTTP/1.1 #{status}\r\nContent-Length: 0\r\n"
351
+ end
352
+ end
353
+
354
+ def with_body_status_line(status, body)
355
+ +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
356
+ end
357
+
358
+ def collect_header_lines(lines, key, value)
359
+ if value.is_a?(Array)
360
+ value.inject(lines) { |_, item| lines << "#{key}: #{item}\r\n" }
361
+ else
362
+ lines << "#{key}: #{value}\r\n"
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ module HTTP
5
+ # translated from https://golang.org/pkg/net/http/#pkg-constants
6
+ # https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
7
+
8
+ CONTINUE = 100 # RFC 7231, 6.2.1
9
+ SWITCHING_PROTOCOLS = 101 # RFC 7231, 6.2.2
10
+ PROCESSING = 102 # RFC 2518, 10.1
11
+ EARLY_HINTS = 103 # RFC 8297
12
+
13
+ OK = 200 # RFC 7231, 6.3.1
14
+ CREATED = 201 # RFC 7231, 6.3.2
15
+ ACCEPTED = 202 # RFC 7231, 6.3.3
16
+ NON_AUTHORITATIVE_INFO = 203 # RFC 7231, 6.3.4
17
+ NO_CONTENT = 204 # RFC 7231, 6.3.5
18
+ RESET_CONTENT = 205 # RFC 7231, 6.3.6
19
+ PARTIAL_CONTENT = 206 # RFC 7233, 4.1
20
+ MULTI_STATUS = 207 # RFC 4918, 11.1
21
+ ALREADY_REPORTED = 208 # RFC 5842, 7.1
22
+ IM_USED = 226 # RFC 3229, 10.4.1
23
+
24
+ MULTIPLE_CHOICES = 300 # RFC 7231, 6.4.1
25
+ MOVED_PERMANENTLY = 301 # RFC 7231, 6.4.2
26
+ FOUND = 302 # RFC 7231, 6.4.3
27
+ SEE_OTHER = 303 # RFC 7231, 6.4.4
28
+ NOT_MODIFIED = 304 # RFC 7232, 4.1
29
+ USE_PROXY = 305 # RFC 7231, 6.4.5
30
+
31
+ TEMPORARY_REDIRECT = 307 # RFC 7231, 6.4.7
32
+ PERMANENT_REDIRECT = 308 # RFC 7538, 3
33
+
34
+ BAD_REQUEST = 400 # RFC 7231, 6.5.1
35
+ UNAUTHORIZED = 401 # RFC 7235, 3.1
36
+ PAYMENT_REQUIRED = 402 # RFC 7231, 6.5.2
37
+ FORBIDDEN = 403 # RFC 7231, 6.5.3
38
+ NOT_FOUND = 404 # RFC 7231, 6.5.4
39
+ METHOD_NOT_ALLOWED = 405 # RFC 7231, 6.5.5
40
+ NOT_ACCEPTABLE = 406 # RFC 7231, 6.5.6
41
+ PROXY_AUTH_REQUIRED = 407 # RFC 7235, 3.2
42
+ REQUEST_TIMEOUT = 408 # RFC 7231, 6.5.7
43
+ CONFLICT = 409 # RFC 7231, 6.5.8
44
+ GONE = 410 # RFC 7231, 6.5.9
45
+ LENGTH_REQUIRED = 411 # RFC 7231, 6.5.10
46
+ PRECONDITION_FAILED = 412 # RFC 7232, 4.2
47
+ REQUEST_ENTITY_TOO_LARGE = 413 # RFC 7231, 6.5.11
48
+ REQUEST_URI_TOO_LONG = 414 # RFC 7231, 6.5.12
49
+ UNSUPPORTED_MEDIA_TYPE = 415 # RFC 7231, 6.5.13
50
+ REQUESTED_RANGE_NOT_SATISFIABLE = 416 # RFC 7233, 4.4
51
+ EXPECTATION_FAILED = 417 # RFC 7231, 6.5.14
52
+ TEAPOT = 418 # RFC 7168, 2.3.3
53
+ MISDIRECTED_REQUEST = 421 # RFC 7540, 9.1.2
54
+ UNPROCESSABLE_ENTITY = 422 # RFC 4918, 11.2
55
+ LOCKED = 423 # RFC 4918, 11.3
56
+ FAILED_DEPENDENCY = 424 # RFC 4918, 11.4
57
+ TOO_EARLY = 425 # RFC 8470, 5.2.
58
+ UPGRADE_REQUIRED = 426 # RFC 7231, 6.5.15
59
+ PRECONDITION_REQUIRED = 428 # RFC 6585, 3
60
+ TOO_MANY_REQUESTS = 429 # RFC 6585, 4
61
+ REQUEST_HEADER_FIELDS_TOO_LARGE = 431 # RFC 6585, 5
62
+ UNAVAILABLE_FOR_LEGAL_REASONS = 451 # RFC 7725, 3
63
+
64
+ INTERNAL_SERVER_ERROR = 500 # RFC 7231, 6.6.1
65
+ NOT_IMPLEMENTED = 501 # RFC 7231, 6.6.2
66
+ BAD_GATEWAY = 502 # RFC 7231, 6.6.3
67
+ SERVICE_UNAVAILABLE = 503 # RFC 7231, 6.6.4
68
+ GATEWAY_TIMEOUT = 504 # RFC 7231, 6.6.5
69
+ HTTP_VERSION_NOT_SUPPORTED = 505 # RFC 7231, 6.6.6
70
+ VARIANT_ALSO_NEGOTIATES = 506 # RFC 2295, 8.1
71
+ INSUFFICIENT_STORAGE = 507 # RFC 4918, 11.5
72
+ LOOP_DETECTED = 508 # RFC 5842, 7.2
73
+ NOT_EXTENDED = 510 # RFC 2774, 7
74
+ NETWORK_AUTHENTICATION_REQUIRED = 511 # RFC 6585, 6
75
+ end
76
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/http/status'
4
+ require 'syntropy/http/client_connection'
5
+ require 'syntropy/http/client'
6
+ require 'syntropy/http/server_connection'
7
+ require 'syntropy/http/server'
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'qeweney'
4
3
  require 'syntropy/errors'
5
4
  require 'json'
6
5
 
@@ -31,7 +30,7 @@ module Syntropy
31
30
  else
32
31
  raise Syntropy::Error.method_not_allowed
33
32
  end
34
- [{ status: 'OK', response: response }, Qeweney::Status::OK]
33
+ [{ status: 'OK', response: response }, HTTP::OK]
35
34
  rescue => e
36
35
  if !e.is_a?(Syntropy::Error)
37
36
  p e
@@ -55,10 +54,8 @@ module Syntropy
55
54
  raise err
56
55
  end
57
56
 
58
- INTERNAL_SERVER_ERROR = Qeweney::Status::INTERNAL_SERVER_ERROR
59
-
60
57
  def __error_response__(err)
61
- http_status = err.respond_to?(:http_status) ? err.http_status : INTERNAL_SERVER_ERROR
58
+ http_status = err.respond_to?(:http_status) ? err.http_status : HTTP::INTERNAL_SERVER_ERROR
62
59
  error_name = err.class.name.split('::').last
63
60
  [{ status: error_name, message: err.message }, http_status]
64
61
  end
@@ -67,7 +67,7 @@ module Syntropy
67
67
  request = o[:request]
68
68
  request_headers = request.headers
69
69
  response_headers = o[:response_headers]
70
- elapsed = request.adapter.monotonic_clock - request.start_stamp
70
+ elapsed = monotonic_clock - request.start_stamp
71
71
  {
72
72
  level: level.to_s,
73
73
  ts: (t = Time.now; t.to_i),
@@ -82,6 +82,10 @@ module Syntropy
82
82
  }
83
83
  end
84
84
 
85
+ def monotonic_clock
86
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
87
+ end
88
+
85
89
  def make_hash_entry(level, hash)
86
90
  {
87
91
  level: level.to_s,
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ # File extension to MIME type mapping
5
+ module MimeTypes
6
+ TYPES = {
7
+ 'html' => 'text/html',
8
+ 'css' => 'text/css',
9
+ 'js' => 'application/javascript',
10
+ 'txt' => 'text/plain',
11
+ 'text' => 'text/plain',
12
+ 'gif' => 'image/gif',
13
+ 'jpg' => 'image/jpeg',
14
+ 'jpeg' => 'image/jpeg',
15
+ 'png' => 'image/png',
16
+ 'ico' => 'image/x-icon',
17
+ 'svg' => 'image/svg+xml',
18
+ 'pdf' => 'application/pdf',
19
+ 'json' => 'application/json',
20
+ }.freeze
21
+
22
+ EXT_REGEXP = /\.?([^\.]+)$/.freeze
23
+
24
+ def self.[](ref)
25
+ case ref
26
+ when Symbol
27
+ TYPES[ref.to_s]
28
+ when EXT_REGEXP
29
+ TYPES[Regexp.last_match(1)]
30
+ when ''
31
+ nil
32
+ else
33
+ raise "Invalid argument #{ref.inspect}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -3,7 +3,7 @@
3
3
  require 'papercraft'
4
4
 
5
5
  Papercraft.extension(
6
- 'auto_refresh_watch!': ->(loc = '/.syntropy') {
6
+ 'auto_refresh!': ->(loc = '/.syntropy') {
7
7
  if $syntropy_dev_mode
8
8
  script(src: File.join(loc, 'auto_refresh/watch.js'), type: 'module')
9
9
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ class MockAdapter
5
+ attr_reader :response_body, :response_headers, :calls
6
+
7
+ def get_body_chunk(_req)
8
+ @request_body_chunks.shift
9
+ end
10
+
11
+ def get_body(_req)
12
+ body = @request_body_chunks.join('')
13
+ @request_body_chunks.clear
14
+ body
15
+ end
16
+
17
+ def complete?(_req)
18
+ @request_body_chunks.empty?
19
+ end
20
+
21
+ def initialize(request_body)
22
+ case request_body
23
+ when Array
24
+ @request_body_chunks = request_body
25
+ when nil
26
+ @request_body_chunks = []
27
+ else
28
+ @request_body_chunks = [request_body]
29
+ end
30
+ @calls = []
31
+ end
32
+
33
+ def set_response_headers(headers)
34
+ @response_headers = headers
35
+ end
36
+
37
+ def respond(req, body, headers)
38
+ headers = @response_headers.merge(headers) if @response_headers
39
+ @calls << [:respond, req, body, headers]
40
+ @response_body = body
41
+ @response_headers = headers
42
+ end
43
+
44
+ def status
45
+ raise 'No response' if !response_headers
46
+
47
+ response_headers[':status'] || HTTP::OK
48
+ end
49
+
50
+ def method_missing(sym, *args)
51
+ calls << [sym, *args]
52
+ end
53
+
54
+ def self.mock(headers = {}, request_body = nil)
55
+ headers[':method'] ||= ''
56
+ headers[':path'] ||= ''
57
+ Request.new(headers, new(request_body))
58
+ end
59
+ end
60
+ end