qeweney 0.3 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63226f0b1696e99fb7c6cb322420ddf08fa6b9a0abd3652a5867c9186e0dd5b2
4
- data.tar.gz: abb173aa33639169d3341e553bcbc91a8595727fae03844daebc0cb82e51b881
3
+ metadata.gz: fcbcd99fda88d91935f36f988b58a066c93b3e59e902efc96c332d95502d0ee4
4
+ data.tar.gz: 617337fac70cfb6a7a30dfaa6e52f2b7614fbd6c46da2efd5074bfe736666b6f
5
5
  SHA512:
6
- metadata.gz: 9d43894a867eaf901cb4cdecf0d907f9257ffcdf369dc89ad1528738f006c854d07bf9850e54fe2fef6e316576593fa8d829f13bc82b5a8ba700a2726daa861b
7
- data.tar.gz: d5f6d8eb6b520200b0864defcf5ef16f53965de0bac7304ca0bdf685faadf3f7528b8fecf0ba9377a3338bffbd993b851eb9e76af22fdd5dd966d4348a5ee28b
6
+ metadata.gz: 34b0ebde4bcb035be97f8125afd6cf74f05d90fbb1c1fb62635c6f738d74f9daad5b099a697d189b7a068606dc77f698aac6a9566bdb856800d82d70fe782d88
7
+ data.tar.gz: 78b9df279e496504fb5d36aca3f3ba3c4b6fe6667a14f3afdad6815b08eaa9b582e052ea0ec3a953e15f7be1bfd51c8cbb3ed7c4634c75cbd6d7ae8b83b017ed
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 0.7.1 20921-03-04
2
+
3
+ - Add `#route_relative_path` method
4
+
5
+ ## 0.6 2021-03-03
6
+
7
+ - More work on routing (still WIP)
8
+ - Implement two-way Rack adapters
9
+
10
+ ## 0.5 2021-02-15
11
+
12
+ - Implement upgrade and WebSocket upgrade responses
13
+
14
+ ## 0.4 2021-02-12
15
+
16
+ - Implement caching and compression for serving static files
17
+
1
18
  ## 0.3 2021-02-12
2
19
 
3
20
  - Implement `Request#serve_io`, `Request#serve_file`, `Request#redirect`
data/Gemfile.lock CHANGED
@@ -1,13 +1,14 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- qeweney (0.3)
4
+ qeweney (0.7.1)
5
5
  escape_utils (~> 1.2.1)
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
10
  ansi (1.5.0)
11
+ benchmark-ips (2.8.4)
11
12
  builder (3.2.4)
12
13
  escape_utils (1.2.1)
13
14
  minitest (5.11.3)
@@ -23,6 +24,7 @@ PLATFORMS
23
24
  ruby
24
25
 
25
26
  DEPENDENCIES
27
+ benchmark-ips (~> 2.8.3)
26
28
  minitest (~> 5.11.3)
27
29
  minitest-reporters (~> 1.4.2)
28
30
  qeweney!
data/TODO.md CHANGED
@@ -3,32 +3,79 @@
3
3
  - `#serve_file(path, opts)`
4
4
  - See here: https://golang.org/pkg/net/http/#ServeFile
5
5
  - support for `Range` header
6
- - support for caching (specified in opts)
7
- - support for serving from routing path:
8
-
9
- ```ruby
10
- req.route do
11
- req.on 'assets' do
12
- req.serve_file(req.routing_path, base_path: STATIC_PATH)
13
- end
14
- end
15
-
16
- # or convenience method
17
- req.route do
18
- req.on_static_route 'assets', base_path: STATIC_PATH
19
- end
20
- ```
21
6
 
22
7
  - `#serve_content(io, opts)`
23
8
  - See here: https://golang.org/pkg/net/http/#ServeContent
24
9
  - support for `Range` header
25
- - support for caching (specified in opts)
26
- - usage:
27
-
28
- ```ruby
29
- req.route do
30
- req.on 'mypdf' do
31
- File.open('my.pdf', 'r') { |f| req.serve_content(io) }
32
- end
33
- end
34
- ```
10
+
11
+ ## route on host
12
+
13
+ - `#on_host`:
14
+
15
+ ```ruby
16
+ req.route do
17
+ req.on_host 'example.com' do
18
+ req.redirect "https://www.example.com#{req.uri}"
19
+ end
20
+ end
21
+ ```
22
+
23
+ - `#on_http`:
24
+
25
+ ```ruby
26
+ req.route do
27
+ req.on_http do
28
+ req.redirect "https://#{req.host}#{req.uri}"
29
+ end
30
+ end
31
+ ```
32
+
33
+ - shorthand:
34
+
35
+ ```ruby
36
+ req.route do
37
+ req.on_http { req.redirect_to_https }
38
+ req.on_host 'example.com' do
39
+ req.redirect_to_host('www.example.com')
40
+ end
41
+ end
42
+ ```
43
+
44
+ ## templates
45
+
46
+ - needs to be pluggable - allow any kind of template
47
+
48
+ ```ruby
49
+ WEBSITE_PATH = File.join(__dir__, 'docs')
50
+ STATIC_PATH = File.join(WEBSITE_PATH, 'static')
51
+ LAYOUTS_PATH = File.join(WEBSITE_PATH, '_layouts')
52
+
53
+ PAGES = Tipi::PageManager(
54
+ base_path: WEBSITE_PATH,
55
+ engine: :markdown,
56
+ layouts: LAYOUTS_PATH
57
+ )
58
+
59
+ app = Tipi.app do |r|
60
+ r.on 'static' do
61
+ r.serve_file(r.route_path, base_path: ASSETS_PATH)
62
+ end
63
+
64
+ r.default do
65
+ PAGES.serve(r)
66
+ end
67
+ end
68
+ ```
69
+
70
+ ## Rewriting URLs
71
+
72
+ ```ruby
73
+ app = Tipi.app do |r|
74
+ r.rewrite '/' => '/docs/index.html'
75
+ r.rewrite '/docs' => '/docs/'
76
+ r.rewrite '/docs/' => '/docs/index.html'
77
+
78
+ # or maybe
79
+ r.on '/docs/'
80
+ end
81
+ ```
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'qeweney'
5
+ require 'benchmark/ips'
6
+
7
+ module Qeweney
8
+ class MockAdapter
9
+ attr_reader :calls
10
+
11
+ def initialize
12
+ @calls = []
13
+ end
14
+
15
+ def method_missing(sym, *args)
16
+ calls << [sym, *args]
17
+ end
18
+ end
19
+
20
+ def self.mock(headers = {})
21
+ Request.new(headers, MockAdapter.new)
22
+ end
23
+
24
+ class Request
25
+ def response_calls
26
+ adapter.calls
27
+ end
28
+ end
29
+ end
30
+
31
+ def create_mock_request
32
+ Qeweney.mock(':path' => '/hello/world', ':method' => 'post')
33
+ end
34
+
35
+ CTApp = ->(r) do
36
+ r.route do
37
+ r.on_root { r.redirect '/hello' }
38
+ r.on('hello') do
39
+ r.on_get('world') { r.respond 'Hello world' }
40
+ r.on_get { r.respond 'Hello' }
41
+ r.on_post do
42
+ # puts 'Someone said Hello'
43
+ r.redirect '/'
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ FlowControlApp = ->(r) do
50
+ if r.path == '/'
51
+ return r.redirect '/hello'
52
+ elsif r.current_path_part == 'hello'
53
+ r.enter_route
54
+ if r.method == 'get' && r.current_path_part == 'world'
55
+ return r.respond('Hello world')
56
+ elsif r.method == 'get'
57
+ return r.respond('Hello')
58
+ elsif r.method == 'post'
59
+ return r.redirect('/')
60
+ end
61
+ r.leave_route
62
+ end
63
+ r.respond(nil, ':status' => 404)
64
+ end
65
+
66
+ class Qeweney::Request
67
+ def get?
68
+ method == 'get'
69
+ end
70
+
71
+ def post?
72
+ method == 'post'
73
+ end
74
+ end
75
+
76
+ NicerFlowControlApp = ->(r) do
77
+ case r.current_path_part
78
+ when '/'
79
+ return r.redirect('/hello')
80
+ when 'hello'
81
+ r.enter_route
82
+ if r.get? && r.current_route == 'world'
83
+ return r.respond('Hello world')
84
+ elsif r.get?
85
+ return r.respond('Hello')
86
+ elsif r.post?
87
+ return r.redirect('/')
88
+ end
89
+ r.leave_route
90
+ end
91
+ r.respond(nil, ':status' => 404)
92
+ end
93
+
94
+ OptimizedRubyApp = ->(r) do
95
+ path = r.path
96
+ if path == '/'
97
+ return r.redirect('/hello')
98
+ elsif path =~ /^\/hello(.+)/
99
+ method = r.method
100
+ if method == 'get'
101
+ rest = Regexp.last_match(1)
102
+ if rest == '/world'
103
+ return r.respond('Hello world')
104
+ else
105
+ return r.respond('Hello')
106
+ end
107
+ elsif method == 'post'
108
+ # puts 'Someone said Hello'
109
+ return r.redirect('/')
110
+ end
111
+ end
112
+ r.respond(nil, ':status' => 404)
113
+ end
114
+
115
+ def test
116
+ r = create_mock_request
117
+ puts '* catch/throw'
118
+ CTApp.call(r)
119
+ p r.response_calls
120
+
121
+ r = create_mock_request
122
+ puts '* classic flow control'
123
+ FlowControlApp.call(r)
124
+ p r.response_calls
125
+
126
+ r = create_mock_request
127
+ puts '* nicer flow control'
128
+ NicerFlowControlApp.call(r)
129
+ p r.response_calls
130
+
131
+ r = create_mock_request
132
+ puts '* optimized Ruby'
133
+ OptimizedRubyApp.call(r)
134
+ p r.response_calls
135
+ end
136
+
137
+ def benchmark
138
+ Benchmark.ips do |x|
139
+ x.config(:time => 3, :warmup => 1)
140
+
141
+ x.report("catch/throw") { CTApp.call(create_mock_request) }
142
+ x.report("flow control") { FlowControlApp.call(create_mock_request) }
143
+ x.report("nicer flow control") { NicerFlowControlApp.call(create_mock_request) }
144
+ x.report("hand-optimized") { OptimizedRubyApp.call(create_mock_request) }
145
+
146
+ x.compare!
147
+ end
148
+ end
149
+
150
+ test
151
+ benchmark
data/lib/qeweney.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Qeweney
3
+ module Qeweney
4
4
  end
5
5
 
6
6
  require_relative 'qeweney/request.rb'
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qeweney
4
+ class RackRequestAdapter
5
+ def initialize(env)
6
+ @env = env
7
+ @response_headers = {}
8
+ @response_body = []
9
+ end
10
+
11
+ def request_headers
12
+ request_http_headers.merge(
13
+ ':scheme' => @env['rack.url_scheme'],
14
+ ':method' => @env['REQUEST_METHOD'].downcase,
15
+ ':path' => request_path_from_env
16
+ )
17
+ end
18
+
19
+ def request_path_from_env
20
+ path = File.join(@env['SCRIPT_NAME'], @env['PATH_INFO'])
21
+ path = path + "?#{@env['QUERY_STRING']}" if @env['QUERY_STRING']
22
+ path
23
+ end
24
+
25
+ def request_http_headers
26
+ headers = {}
27
+ @env.each do |k, v|
28
+ next unless k =~ /^HTTP_(.+)$/
29
+
30
+ headers[Regexp.last_match(1).downcase.gsub('_', '-')] = v
31
+ end
32
+ headers
33
+ end
34
+
35
+ def respond(body, headers)
36
+ @response_body << body
37
+ @response_headers = headers
38
+ end
39
+
40
+ def send_headers(headers, empty_response: nil)
41
+ @response_headers = headers
42
+ end
43
+
44
+ def send_chunk(body, done: false)
45
+ @response_body << body
46
+ end
47
+
48
+ def finish
49
+ end
50
+
51
+ def rack_response
52
+ @status = @response_headers.delete(':status')
53
+ [
54
+ @status,
55
+ @response_headers,
56
+ @response_body
57
+ ]
58
+ end
59
+ end
60
+
61
+ def self.rack(&block)
62
+ proc do |env|
63
+ adapter = RackRequestAdapter.new(env)
64
+ req = Request.new(adapter.request_headers, adapter)
65
+ block.(req)
66
+ adapter.rack_response
67
+ end
68
+ end
69
+
70
+ def self.rack_env_from_request(request)
71
+ Hash.new do |h, k|
72
+ h[k] = env_value_from_request(request, k)
73
+ end
74
+ end
75
+
76
+ RACK_ENV = {
77
+ 'SCRIPT_NAME' => '',
78
+ 'rack.version' => [1, 3],
79
+ 'SERVER_PORT' => '80', # ?
80
+ 'rack.url_scheme' => 'http', # ?
81
+ 'rack.errors' => STDERR, # ?
82
+ 'rack.multithread' => false,
83
+ 'rack.run_once' => false,
84
+ 'rack.hijack?' => false,
85
+ 'rack.hijack' => nil,
86
+ 'rack.hijack_io' => nil,
87
+ 'rack.session' => nil,
88
+ 'rack.logger' => nil,
89
+ 'rack.multipart.buffer_size' => nil,
90
+ 'rack.multipar.tempfile_factory' => nil
91
+ }
92
+
93
+ def self.env_value_from_request(request, key)
94
+ case key
95
+ when 'REQUEST_METHOD' then request.method
96
+ when 'PATH_INFO' then request.path
97
+ when 'QUERY_STRING' then request.query_string || ''
98
+ when 'SERVER_NAME' then request.headers['host']
99
+ when 'rack.input' then InputStream.new(request)
100
+ when HTTP_HEADER_RE then request.headers[$1.gsub('_', '-').downcase]
101
+ else RACK_ENV[key]
102
+ end
103
+ end
104
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative './request_info'
4
4
  require_relative './routing'
5
5
  require_relative './response'
6
+ require_relative './rack'
6
7
 
7
8
  module Qeweney
8
9
  # HTTP request
@@ -17,6 +17,10 @@ module Qeweney
17
17
  connection == 'upgrade' && @headers['upgrade']&.downcase
18
18
  end
19
19
 
20
+ def websocket_version
21
+ headers['sec-websocket-version'].to_i
22
+ end
23
+
20
24
  def protocol
21
25
  @protocol ||= @adapter.protocol
22
26
  end
@@ -61,6 +65,15 @@ module Qeweney
61
65
  def forwarded_for
62
66
  @headers['x-forwarded-for']
63
67
  end
68
+
69
+ # TODO: should return encodings in client's order of preference (and take
70
+ # into account q weights)
71
+ def accept_encoding
72
+ encoding = @headers['accept-encoding']
73
+ return [] unless encoding
74
+
75
+ encoding.split(',').map { |i| i.strip }
76
+ end
64
77
  end
65
78
 
66
79
  module RequestInfoClassMethods
@@ -1,21 +1,113 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+ require 'zlib'
5
+ require 'stringio'
6
+ require 'digest/sha1'
7
+
3
8
  require_relative 'status'
4
9
 
5
10
  module Qeweney
11
+ module StaticFileCaching
12
+ class << self
13
+ def file_stat_to_etag(stat)
14
+ "#{stat.mtime.to_i.to_s(36)}#{stat.size.to_s(36)}"
15
+ end
16
+
17
+ def file_stat_to_last_modified(stat)
18
+ stat.mtime.httpdate
19
+ end
20
+ end
21
+ end
22
+
6
23
  module ResponseMethods
24
+ def upgrade(protocol, custom_headers = nil)
25
+ upgrade_headers = {
26
+ ':status' => Status::SWITCHING_PROTOCOLS,
27
+ 'Upgrade' => protocol,
28
+ 'Connection' => 'upgrade'
29
+ }
30
+ upgrade_headers.merge!(custom_headers) if custom_headers
31
+
32
+ respond(nil, upgrade_headers)
33
+ end
34
+
35
+ WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
36
+
37
+ def upgrade_to_websocket(custom_headers = nil)
38
+ key = "#{headers['sec-websocket-key']}#{WEBSOCKET_GUID}"
39
+ upgrade_headers = {
40
+ 'Sec-WebSocket-Accept' => Digest::SHA1.base64digest(key)
41
+ }
42
+ upgrade_headers.merge!(custom_headers) if custom_headers
43
+ upgrade('websocket', upgrade_headers)
44
+
45
+ adapter.websocket_connection(self)
46
+ end
47
+
7
48
  def redirect(url, status = Status::FOUND)
8
49
  respond(nil, ':status' => status, 'Location' => url)
9
50
  end
10
51
 
52
+ def redirect_to_https(status = Status::MOVED_PERMANENTLY)
53
+ secure_uri = "https://#{host}#{uri}"
54
+ redirect(secure_uri, status)
55
+ end
56
+
57
+ def redirect_to_host(new_host, status = Status::FOUND)
58
+ secure_uri = "//#{new_host}#{uri}"
59
+ redirect(secure_uri, status)
60
+ end
61
+
11
62
  def serve_file(path, opts)
12
- File.open(file_full_path(path, opts), 'r') do |f|
13
- serve_io(f, opts)
63
+ full_path = file_full_path(path, opts)
64
+ stat = File.stat(full_path)
65
+ etag = StaticFileCaching.file_stat_to_etag(stat)
66
+ last_modified = StaticFileCaching.file_stat_to_last_modified(stat)
67
+
68
+ if validate_static_file_cache(etag, last_modified)
69
+ return respond(nil, {
70
+ ':status' => Status::NOT_MODIFIED,
71
+ 'etag' => etag
72
+ })
14
73
  end
15
- rescue Errno::ENOENT
74
+
75
+ respond_with_static_file(full_path, etag, last_modified, opts)
76
+ rescue Errno::ENOENT => e
16
77
  respond(nil, ':status' => Status::NOT_FOUND)
17
78
  end
18
79
 
80
+ def respond_with_static_file(path, etag, last_modified, opts)
81
+ File.open(path, 'r') do |f|
82
+ opts = opts.merge(headers: {
83
+ 'etag' => etag,
84
+ 'last-modified' => last_modified,
85
+ })
86
+
87
+ # accept_encoding should return encodings in client's order of preference
88
+ accept_encoding.each do |encoding|
89
+ case encoding
90
+ when 'deflate'
91
+ return serve_io_deflate(f, opts)
92
+ when 'gzip'
93
+ return serve_io_gzip(f, opts)
94
+ end
95
+ end
96
+ serve_io(f, opts)
97
+ end
98
+ end
99
+
100
+ def validate_static_file_cache(etag, last_modified)
101
+ if (none_match = headers['if-none-match'])
102
+ return true if none_match == etag
103
+ end
104
+ if (modified_since = headers['if-modified-since'])
105
+ return true if modified_since == last_modified
106
+ end
107
+
108
+ false
109
+ end
110
+
19
111
  def file_full_path(path, opts)
20
112
  if (base_path = opts[:base_path])
21
113
  File.join(opts[:base_path], path)
@@ -25,7 +117,39 @@ module Qeweney
25
117
  end
26
118
 
27
119
  def serve_io(io, opts)
28
- respond(io.read)
120
+ respond(io.read, opts[:headers] || {})
121
+ end
122
+
123
+ def serve_io_deflate(io, opts)
124
+ deflate = Zlib::Deflate.new
125
+ headers = opts[:headers].merge(
126
+ 'content-encoding' => 'deflate',
127
+ 'vary' => 'Accept-Encoding'
128
+ )
129
+
130
+ respond(deflate.deflate(io.read, Zlib::FINISH), headers)
131
+ end
132
+
133
+ def serve_io_gzip(io, opts)
134
+ buf = StringIO.new
135
+ z = Zlib::GzipWriter.new(buf)
136
+ z << io.read
137
+ z.flush
138
+ z.close
139
+ headers = opts[:headers].merge(
140
+ 'content-encoding' => 'gzip',
141
+ 'vary' => 'Accept-Encoding'
142
+ )
143
+ respond(buf.string, headers)
144
+ end
145
+
146
+ def serve_rack(app)
147
+ response = app.(Qeweney.rack_env_from_request(self))
148
+ headers = (response[1] || {}).merge(':status' => response[0])
149
+ respond(response[2].join, headers)
150
+
151
+ # TODO: send separate chunks for multi-part body
152
+ # TODO: add support for streaming body
29
153
  end
30
154
  end
31
155
  end
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Qeweney
4
+ def self.route(&block)
5
+ ->(r) { r.route(&block) }
6
+ end
7
+
4
8
  module RoutingMethods
5
9
  def route(&block)
10
+ (@path_parts ||= path.split('/'))[@path_parts_idx ||= 1]
6
11
  res = catch(:stop) { yield self }
7
12
  return if res == :found
8
13
 
@@ -11,36 +16,57 @@ module Qeweney
11
16
 
12
17
  def route_found(&block)
13
18
  catch(:stop, &block)
14
- throw :stop, :found
19
+ throw :stop, headers_sent? ? :found : nil
15
20
  end
16
21
 
17
22
  @@regexp_cache = {}
18
23
 
19
- def routing_path
20
- @__routing_path__
24
+ def route_part
25
+ @path_parts[@path_parts_idx]
21
26
  end
22
-
23
- def on(route = nil, &block)
24
- @__routing_path__ ||= path
25
27
 
26
- if route
27
- regexp = (@@regexp_cache[route] ||= /^\/#{route}(\/.*)?/)
28
- return unless @__routing_path__ =~ regexp
28
+ def route_relative_path
29
+ @path_parts.empty? ? '/' : @path_parts[@path_parts_idx..-1].join('/')
30
+ end
31
+
32
+ def enter_route
33
+ @path_parts_idx += 1
34
+ end
35
+
36
+ def leave_route
37
+ @path_parts_idx -= 1
38
+ end
29
39
 
30
- @__routing_path__ = Regexp.last_match(1) || '/'
40
+ def on(route = nil, &block)
41
+ if route
42
+ return unless @path_parts[@path_parts_idx] == route
31
43
  end
32
44
 
45
+ enter_route
33
46
  route_found(&block)
47
+ leave_route
34
48
  end
35
49
 
36
50
  def is(route = '/', &block)
37
- return unless @__routing_path__ == route
51
+ return unless @path_parts[@path_parts_idx] == route && @path_parts_idx >= @path_parts.size
38
52
 
39
53
  route_found(&block)
40
54
  end
41
55
 
42
56
  def on_root(&block)
43
- return unless @__routing_path__ == '/'
57
+ return unless @path_parts_idx > @path_parts.size - 1
58
+
59
+ route_found(&block)
60
+ end
61
+
62
+ def on_host(route, &block)
63
+ return unless host == route
64
+
65
+ route_found(&block)
66
+ end
67
+
68
+ def on_plain_http(route, &block)
69
+ return unless scheme == 'http'
44
70
 
45
71
  route_found(&block)
46
72
  end
@@ -86,8 +112,22 @@ module Qeweney
86
112
  route_found(&block)
87
113
  end
88
114
 
115
+ def on_upgrade(protocol, &block)
116
+ return unless upgrade_protocol == protocol
117
+
118
+ route_found(&block)
119
+ end
120
+
121
+ def on_websocket_upgrade(&block)
122
+ on_upgrade('websocket', &block)
123
+ end
124
+
89
125
  def stop_routing
90
- yield if block_given?
126
+ throw :stop, :found
127
+ end
128
+
129
+ def default
130
+ yield
91
131
  throw :stop, :found
92
132
  end
93
133
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Qeweney
4
- VERSION = '0.3'
4
+ VERSION = '0.7.1'
5
5
  end
data/qeweney.gemspec CHANGED
@@ -22,4 +22,5 @@ Gem::Specification.new do |s|
22
22
  s.add_development_dependency 'rake', '~>12.3.3'
23
23
  s.add_development_dependency 'minitest', '~>5.11.3'
24
24
  s.add_development_dependency 'minitest-reporters', '~>1.4.2'
25
+ s.add_development_dependency 'benchmark-ips', '~>2.8.3'
25
26
  end
data/test/helper.rb CHANGED
@@ -11,8 +11,28 @@ require 'minitest/autorun'
11
11
  require 'minitest/reporters'
12
12
 
13
13
  module Qeweney
14
- def self.mock(headers)
15
- Request.new(headers, nil)
14
+ class MockAdapter
15
+ attr_reader :calls
16
+
17
+ def initialize
18
+ @calls = []
19
+ end
20
+
21
+ def method_missing(sym, *args)
22
+ calls << [sym, *args]
23
+ end
24
+ end
25
+
26
+ def self.mock(headers = {})
27
+ headers[':method'] ||= ''
28
+ headers[':path'] ||= ''
29
+ Request.new(headers, MockAdapter.new)
30
+ end
31
+
32
+ class Request
33
+ def response_calls
34
+ adapter.calls
35
+ end
16
36
  end
17
37
  end
18
38
 
File without changes
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class RedirectTest < MiniTest::Test
6
+ def test_redirect
7
+ r = Qeweney.mock
8
+ r.redirect('/foo')
9
+
10
+ assert_equal [
11
+ [:respond, nil, {":status"=>302, "Location"=>"/foo"}]
12
+ ], r.response_calls
13
+ end
14
+
15
+ def test_redirect_wirth_status
16
+ r = Qeweney.mock
17
+ r.redirect('/bar', Qeweney::Status::MOVED_PERMANENTLY)
18
+
19
+ assert_equal [
20
+ [:respond, nil, {":status"=>301, "Location"=>"/bar"}]
21
+ ], r.response_calls
22
+ end
23
+ end
24
+
25
+ class StaticFileResponeTest < MiniTest::Test
26
+ def setup
27
+ @path = File.join(__dir__, 'helper.rb')
28
+ @stat = File.stat(@path)
29
+
30
+ @etag = Qeweney::StaticFileCaching.file_stat_to_etag(@stat)
31
+ @last_modified = Qeweney::StaticFileCaching.file_stat_to_last_modified(@stat)
32
+ @content = IO.read(@path)
33
+ end
34
+
35
+ def test_serve_file
36
+ r = Qeweney.mock
37
+ r.serve_file('helper.rb', base_path: __dir__)
38
+
39
+ assert_equal [
40
+ [:respond, @content, { 'etag' => @etag, 'last-modified' => @last_modified }]
41
+ ], r.response_calls
42
+ end
43
+
44
+ def test_serve_file_with_cache_headers
45
+ r = Qeweney.mock('if-none-match' => @etag)
46
+ r.serve_file('helper.rb', base_path: __dir__)
47
+ assert_equal [
48
+ [:respond, nil, { 'etag' => @etag, ':status' => Qeweney::Status::NOT_MODIFIED }]
49
+ ], r.response_calls
50
+
51
+ r = Qeweney.mock('if-modified-since' => @last_modified)
52
+ r.serve_file('helper.rb', base_path: __dir__)
53
+ assert_equal [
54
+ [:respond, nil, { 'etag' => @etag, ':status' => Qeweney::Status::NOT_MODIFIED }]
55
+ ], r.response_calls
56
+
57
+ r = Qeweney.mock('if-none-match' => 'foobar')
58
+ r.serve_file('helper.rb', base_path: __dir__)
59
+ assert_equal [
60
+ [:respond, @content, { 'etag' => @etag, 'last-modified' => @last_modified }]
61
+ ], r.response_calls
62
+
63
+ r = Qeweney.mock('if-modified-since' => Time.now.httpdate)
64
+ r.serve_file('helper.rb', base_path: __dir__)
65
+ assert_equal [
66
+ [:respond, @content, { 'etag' => @etag, 'last-modified' => @last_modified }]
67
+ ], r.response_calls
68
+ end
69
+
70
+ def test_serve_file_deflate
71
+ r = Qeweney.mock('accept-encoding' => 'deflate')
72
+ r.serve_file('helper.rb', base_path: __dir__)
73
+
74
+ deflate = Zlib::Deflate.new
75
+ deflated_content = deflate.deflate(@content, Zlib::FINISH)
76
+
77
+ assert_equal [
78
+ [:respond, deflated_content, {
79
+ 'etag' => @etag,
80
+ 'last-modified' => @last_modified,
81
+ 'vary' => 'Accept-Encoding',
82
+ 'content-encoding' => 'deflate'
83
+ }]
84
+ ], r.response_calls
85
+ end
86
+
87
+ def test_serve_file_gzip
88
+ r = Qeweney.mock('accept-encoding' => 'gzip')
89
+ r.serve_file('helper.rb', base_path: __dir__)
90
+
91
+ buf = StringIO.new
92
+ z = Zlib::GzipWriter.new(buf)
93
+ z << @content
94
+ z.flush
95
+ z.close
96
+ gzipped_content = buf.string
97
+
98
+ assert_equal [
99
+ [:respond, gzipped_content, {
100
+ 'etag' => @etag,
101
+ 'last-modified' => @last_modified,
102
+ 'vary' => 'Accept-Encoding',
103
+ 'content-encoding' => 'gzip'
104
+ }]
105
+ ], r.response_calls
106
+ end
107
+
108
+ def test_serve_file_non_existent
109
+ r = Qeweney.mock
110
+ r.serve_file('foo.rb', base_path: __dir__)
111
+ assert_equal [
112
+ [:respond, nil, { ':status' => Qeweney::Status::NOT_FOUND }]
113
+ ], r.response_calls
114
+ end
115
+ end
116
+
117
+ class UpgradeTest < MiniTest::Test
118
+ def test_upgrade
119
+ r = Qeweney.mock
120
+ r.upgrade('df')
121
+
122
+ assert_equal [
123
+ [:respond, nil, {
124
+ ':status' => 101,
125
+ 'Upgrade' => 'df',
126
+ 'Connection' => 'upgrade'
127
+ }]
128
+ ], r.response_calls
129
+
130
+
131
+ r = Qeweney.mock
132
+ r.upgrade('df', { 'foo' => 'bar' })
133
+
134
+ assert_equal [
135
+ [:respond, nil, {
136
+ ':status' => 101,
137
+ 'Upgrade' => 'df',
138
+ 'Connection' => 'upgrade',
139
+ 'foo' => 'bar'
140
+ }]
141
+ ], r.response_calls
142
+ end
143
+
144
+ def test_websocket_upgrade
145
+ r = Qeweney.mock(
146
+ 'connection' => 'upgrade',
147
+ 'upgrade' => 'websocket',
148
+ 'sec-websocket-version' => '23',
149
+ 'sec-websocket-key' => 'abcdefghij'
150
+ )
151
+
152
+ assert_equal 'websocket', r.upgrade_protocol
153
+
154
+ r.upgrade_to_websocket('foo' => 'baz')
155
+ accept = Digest::SHA1.base64digest('abcdefghij258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
156
+
157
+ assert_equal [
158
+ [:respond, nil, {
159
+ ':status' => 101,
160
+ 'Upgrade' => 'websocket',
161
+ 'Connection' => 'upgrade',
162
+ 'foo' => 'baz',
163
+ 'Sec-WebSocket-Accept' => accept
164
+ }],
165
+ [:websocket_connection, r]
166
+ ], r.response_calls
167
+ end
168
+ end
169
+
170
+ class ServeRackTest < MiniTest::Test
171
+ def test_serve_rack
172
+ r = Qeweney.mock(
173
+ ':method' => 'get',
174
+ ':path' => '/foo/bar?a=1&b=2%2F3',
175
+ 'accept' => 'blah'
176
+ )
177
+ r.serve_rack(->(env) {
178
+ [404, {'Foo' => 'Bar'}, ["#{env['REQUEST_METHOD']} #{env['PATH_INFO']}"]]
179
+ })
180
+
181
+ assert_equal [
182
+ [:respond, "get /foo/bar", {':status' => 404, 'Foo' => 'Bar' }]
183
+ ], r.response_calls
184
+ end
185
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class RoutingTest < MiniTest::Test
6
+ App1 = ->(r) do
7
+ r.route do
8
+ r.on_root { r.redirect '/hello' }
9
+ r.on('hello') do
10
+ r.on_get('world') { r.respond 'Hello world' }
11
+ r.on_get { r.respond 'Hello' }
12
+ r.on_post do
13
+ r.redirect '/'
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ def test_app1
20
+ r = Qeweney.mock(':path' => '/foo')
21
+ App1.(r)
22
+ assert_equal [[:respond, nil, { ':status' => 404 }]], r.response_calls
23
+
24
+ r = Qeweney.mock(':path' => '/')
25
+ App1.(r)
26
+ assert_equal [[:respond, nil, { ':status' => 302, 'Location' => '/hello' }]], r.response_calls
27
+
28
+ r = Qeweney.mock(':path' => '/hello', ':method' => 'foo')
29
+ App1.(r)
30
+ assert_equal [[:respond, nil, { ':status' => 404 }]], r.response_calls
31
+
32
+ r = Qeweney.mock(':path' => '/hello', ':method' => 'get')
33
+ App1.(r)
34
+ assert_equal [[:respond, 'Hello', {}]], r.response_calls
35
+
36
+ r = Qeweney.mock(':path' => '/hello', ':method' => 'post')
37
+ App1.(r)
38
+ assert_equal [[:respond, nil, { ':status' => 302, 'Location' => '/' }]], r.response_calls
39
+
40
+ r = Qeweney.mock(':path' => '/hello/world', ':method' => 'get')
41
+ App1.(r)
42
+ assert_equal [[:respond, 'Hello world', {}]], r.response_calls
43
+ end
44
+
45
+ def test_on_root
46
+ app = Qeweney.route do |r|
47
+ r.on_root { r.respond('root') }
48
+ r.on('foo') {
49
+ r.on_root { r.respond('foo root') }
50
+ r.on('bar') {
51
+ r.on_root { r.respond('bar root') }
52
+ r.on('baz') {
53
+ r.on_root { r.respond('baz root') }
54
+ }
55
+ }
56
+ }
57
+ end
58
+
59
+ r = Qeweney.mock(':path' => '/')
60
+ app.(r)
61
+ assert_equal [[:respond, 'root', {}]], r.response_calls
62
+
63
+ r = Qeweney.mock(':path' => '/foo')
64
+ app.(r)
65
+ assert_equal [[:respond, 'foo root', {}]], r.response_calls
66
+
67
+ r = Qeweney.mock(':path' => '/foo/bar')
68
+ app.(r)
69
+ assert_equal [[:respond, 'bar root', {}]], r.response_calls
70
+
71
+ r = Qeweney.mock(':path' => '/foo/bar/baz')
72
+ app.(r)
73
+ assert_equal [[:respond, 'baz root', {}]], r.response_calls
74
+ end
75
+
76
+ def test_relative_path
77
+ default_relative_path = nil
78
+
79
+ app = Qeweney.route do |r|
80
+ default_relative_path = r.route_relative_path
81
+ r.on_root { r.respond(File.join('ROOT', r.route_relative_path)) }
82
+ r.on('foo') { r.respond(File.join('FOO', r.route_relative_path)) }
83
+ r.on('bar') {
84
+ r.on('baz') { r.respond(File.join('BAR/BAZ', r.route_relative_path)) }
85
+ r.default { r.respond(File.join('BAR', r.route_relative_path)) }
86
+ }
87
+ end
88
+
89
+ r = Qeweney.mock(':path' => '/')
90
+ app.(r)
91
+ assert_equal '/', default_relative_path
92
+ assert_equal [[:respond, 'ROOT/', {}]], r.response_calls
93
+
94
+
95
+ r = Qeweney.mock(':path' => '/foo/bar/baz')
96
+ app.(r)
97
+ assert_equal 'foo/bar/baz', default_relative_path
98
+ assert_equal [[:respond, 'FOO/bar/baz', {}]], r.response_calls
99
+
100
+ r = Qeweney.mock(':path' => '/bar/a/b/c')
101
+ app.(r)
102
+ assert_equal 'bar/a/b/c', default_relative_path
103
+ assert_equal [[:respond, 'BAR/a/b/c', {}]], r.response_calls
104
+
105
+ r = Qeweney.mock(':path' => '/bar/baz/b/c')
106
+ app.(r)
107
+ assert_equal 'bar/baz/b/c', default_relative_path
108
+ assert_equal [[:respond, 'BAR/BAZ/b/c', {}]], r.response_calls
109
+ end
110
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qeweney
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.3'
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-02-12 00:00:00.000000000 Z
11
+ date: 2021-03-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: escape_utils
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: 1.4.2
69
+ - !ruby/object:Gem::Dependency
70
+ name: benchmark-ips
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 2.8.3
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 2.8.3
69
83
  description:
70
84
  email: sharon@noteflakes.com
71
85
  executables: []
@@ -82,8 +96,10 @@ files:
82
96
  - README.md
83
97
  - Rakefile
84
98
  - TODO.md
99
+ - examples/routing_benchmark.rb
85
100
  - lib/qeweney.rb
86
101
  - lib/qeweney/mime_types.rb
102
+ - lib/qeweney/rack.rb
87
103
  - lib/qeweney/request.rb
88
104
  - lib/qeweney/request_info.rb
89
105
  - lib/qeweney/response.rb
@@ -93,7 +109,9 @@ files:
93
109
  - qeweney.gemspec
94
110
  - test/helper.rb
95
111
  - test/run.rb
96
- - test/test_request.rb
112
+ - test/test_request_info.rb
113
+ - test/test_response.rb
114
+ - test/test_routing.rb
97
115
  homepage: http://github.com/digital-fabric/qeweney
98
116
  licenses:
99
117
  - MIT