qeweney 0.3 → 0.7.1

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.
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