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,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/http/connection'
4
+
5
+ module Syntropy
6
+ module HTTP
7
+ class Server
8
+ PENDING_REQUESTS_GRACE_PERIOD = 0.1
9
+ PENDING_REQUESTS_TIMEOUT_PERIOD = 5
10
+
11
+ def self.syntropy_app(_machine, env)
12
+ if env[:app_location]
13
+ env[:logger]&.info(message: 'Loading web app', location: env[:app_location])
14
+ require env[:app_location]
15
+
16
+ env.merge!(Syntropy.config)
17
+ end
18
+ env[:app]
19
+ end
20
+
21
+ def self.static_app(env); end
22
+
23
+ def initialize(machine, env, &app)
24
+ @machine = machine
25
+ @env = env
26
+ @app = app || app_from_env
27
+ @server_fds = []
28
+ @accept_fibers = []
29
+ end
30
+
31
+ def app_from_env
32
+ case @env[:app_type]
33
+ when nil, :syntropy
34
+ Server.syntropy_app(@machine, @env)
35
+ when :static
36
+ Server.static_app(@env)
37
+ else
38
+ raise "Invalid app type #{@env[:app_type].inspect}"
39
+ end
40
+ end
41
+
42
+ def run
43
+ setup
44
+ @machine.await(@accept_fibers)
45
+ rescue UM::Terminate
46
+ graceful_shutdown
47
+ end
48
+
49
+ def stop!
50
+ graceful_shutdown
51
+ end
52
+
53
+ private
54
+
55
+ def setup
56
+ bind_info = get_bind_entries
57
+ bind_info.each do |(host, port)|
58
+ fd = setup_server_socket(host, port)
59
+ @server_fds << fd
60
+ @accept_fibers << @machine.spin { accept_incoming(fd) }
61
+ end
62
+ bind_string = bind_info.map { it.join(':') }.join(', ')
63
+ @env[:logger]&.info(message: "Listening on #{bind_string}")
64
+ setup_server_extensions
65
+
66
+ # map fibers
67
+ @connection_fibers = Set.new
68
+ end
69
+
70
+ def get_bind_entries
71
+ bind = @env[:bind]
72
+ case bind
73
+ when Array
74
+ bind.map { bind_info(it) }
75
+ when String
76
+ [bind_info(bind)]
77
+ else
78
+ # default
79
+ [['0.0.0.0', 1234]]
80
+ end
81
+ end
82
+
83
+ def bind_info(bind_string)
84
+ parts = bind_string.split(':')
85
+ [parts[0], parts[1].to_i]
86
+ end
87
+
88
+ def setup_server_socket(host, port)
89
+ fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
90
+ @machine.setsockopt(fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
91
+ @machine.setsockopt(fd, UM::SOL_SOCKET, UM::SO_REUSEPORT, true)
92
+ @machine.bind(fd, host, port)
93
+ @machine.listen(fd, UM::SOMAXCONN)
94
+ fd
95
+ end
96
+
97
+ def setup_server_extensions
98
+ extensions = @env[:server_extensions]
99
+ return if !extensions
100
+
101
+ server_name = extensions[:name]
102
+ if extensions[:date]
103
+ @date_header_fiber = @machine.spin {
104
+ @machine.periodically(1) { update_server_headers(server_name) }
105
+ }
106
+ update_server_headers(server_name)
107
+ elsif server_name
108
+ @env[:server_headers] = "Server: #{server_name}\r\n"
109
+ end
110
+ end
111
+
112
+ def update_server_headers(server_name)
113
+ @env[:server_date] = Time.now
114
+ if server_name
115
+ @env[:server_headers] = "Server: #{server_name}\r\nDate: #{@env[:server_date].httpdate}\r\n"
116
+ else
117
+ @env[:server_headers] = "Date: #{Time.now.httpdate}\r\n"
118
+ end
119
+ end
120
+
121
+ def accept_incoming(listen_fd)
122
+ @machine.accept_each(listen_fd) { start_client_connection(it) }
123
+ rescue UM::Terminate
124
+ # terminated
125
+ end
126
+
127
+ def start_client_connection(fd)
128
+ conn = Connection.new(self, @machine, fd, @env, &@app)
129
+ f = @machine.spin(conn) do
130
+ it.run
131
+ ensure
132
+ @connection_fibers.delete(f)
133
+ end
134
+ @connection_fibers << f
135
+ end
136
+
137
+ def close_all_server_fds
138
+ @server_fds.each { @machine.close_async(it) }
139
+ end
140
+
141
+ STOP = UM::Terminate.new
142
+
143
+ def stop_accept_fibers
144
+ @accept_fibers.each { @machine.schedule(it, STOP) if !it.done? }
145
+ @machine.await(@accept_fibers)
146
+ end
147
+
148
+ def graceful_shutdown
149
+ @env[:logger]&.info(message: 'Shutting down gracefully...')
150
+
151
+ # stop listening
152
+ close_all_server_fds
153
+ stop_accept_fibers
154
+ @machine.snooze
155
+
156
+ return if @connection_fibers.empty?
157
+
158
+ # sleep for a bit, let requests finish
159
+ @machine.sleep(PENDING_REQUESTS_GRACE_PERIOD)
160
+ return if @connection_fibers.empty?
161
+
162
+ # terminate pending fibers
163
+ pending = @connection_fibers.to_a
164
+ pending.each { @machine.schedule(it, STOP) }
165
+
166
+ @machine.timeout(PENDING_REQUESTS_TIMEOUT_PERIOD, UM::Terminate) do
167
+ @machine.await(@connection_fibers)
168
+ rescue UM::Terminate
169
+ # timeout on waiting for adapters to finish running, do nothing
170
+ end
171
+ end
172
+ end
173
+ end
174
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/http/status'
4
+ require 'syntropy/http/connection'
5
+ 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
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Syntropy
6
+ class Logger
7
+ def initialize(machine, fd = $stdout.fileno, **opts)
8
+ @machine = machine
9
+ @fd = fd
10
+ @opts = opts
11
+ end
12
+
13
+ def info(o)
14
+ call(:INFO, o)
15
+ end
16
+
17
+ def warn(o)
18
+ call(:WARN, o)
19
+ end
20
+
21
+ def error(o)
22
+ call(:ERROR, o)
23
+ end
24
+
25
+ private
26
+
27
+ # @param level <Symbol> log level
28
+ # @param o <Hash> hash
29
+ def call(level, o)
30
+ emit(make_entry(level, o))
31
+ rescue StandardError => e
32
+ puts 'Uncaught error while emitting log entry:'
33
+ p e: e
34
+ p e.backtrace
35
+ exit
36
+ end
37
+
38
+ def emit(entry)
39
+ @machine.write_async(@fd, "#{entry.to_json}\n")
40
+ end
41
+
42
+ def make_entry(level, o)
43
+ if o[:request]
44
+ make_request_entry(level, o)
45
+ elsif o[:error]
46
+ make_error_entry(level, o)
47
+ else
48
+ make_hash_entry(level, o)
49
+ end
50
+ end
51
+
52
+ def make_error_entry(level, o)
53
+ err = o[:error]
54
+ {
55
+ level: level.to_s,
56
+ ts: (t = Time.now; t.to_i),
57
+ ts_s: t.iso8601
58
+ }
59
+ .merge(o)
60
+ .merge(
61
+ error: "#{err.class}: #{err.message}",
62
+ backtrace: err.backtrace
63
+ )
64
+ end
65
+
66
+ def make_request_entry(level, o)
67
+ request = o[:request]
68
+ request_headers = request.headers
69
+ response_headers = o[:response_headers]
70
+ elapsed = request.adapter.monotonic_clock - request.start_stamp
71
+ {
72
+ level: level.to_s,
73
+ ts: (t = Time.now; t.to_i),
74
+ ts_s: t.iso8601,
75
+ message: o[:message] || 'HTTP request done',
76
+ client_ip: request.forwarded_for || '?',
77
+ http_method: request_headers[':method'].upcase,
78
+ user_agent: request_headers['user-agent'],
79
+ uri: full_uri(request_headers),
80
+ status: response_headers[':status'] || '200',
81
+ elapsed: elapsed
82
+ }
83
+ end
84
+
85
+ def make_hash_entry(level, hash)
86
+ {
87
+ level: level.to_s,
88
+ ts: (t = Time.now; t.to_i),
89
+ ts_s: t.iso8601
90
+ }
91
+ .merge(hash)
92
+ end
93
+
94
+ def full_uri(headers)
95
+ format(
96
+ '%<scheme>s://%<host>s%<path>s',
97
+ scheme: headers['x_forwarded_proto'] || 'http',
98
+ host: headers['host'],
99
+ path: headers[':path']
100
+ )
101
+ end
102
+ end
103
+ end
@@ -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
@@ -0,0 +1,58 @@
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
+ response_headers[':status'] || HTTP::OK
46
+ end
47
+
48
+ def method_missing(sym, *args)
49
+ calls << [sym, *args]
50
+ end
51
+
52
+ def self.mock(headers = {}, request_body = nil)
53
+ headers[':method'] ||= ''
54
+ headers[':path'] ||= ''
55
+ Request.new(headers, new(request_body))
56
+ end
57
+ end
58
+ end