qeweney 0.2 → 0.7

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: 468a536796430b202206a5b2e6fe1e05cdc2b9ff248735ceab4fa7ca458b812e
4
- data.tar.gz: 379a9bdb10b73d2a5fbce967ec10149fd0c36ce58819fe27da847a3e1194ae27
3
+ metadata.gz: 57bbb94d61917bad178ee53693fdecc3f3aa878495c3d53d04d263bd34934792
4
+ data.tar.gz: 5075929ee6a35dc1c1420b6a312ff5c861e6e2b7267993eacb3b55ec09d6f6c6
5
5
  SHA512:
6
- metadata.gz: 17e66c2fbcf0b60c7cf8023ff7c0cda04e3ec3c527da5019838bf66ed454e10c4fada6b45e6ad56de479812a2e215e692fd566dc3167457e92ed8e88fc54de39
7
- data.tar.gz: d46cabcc28e1da7ba15f4603eff436a47198e1c8008e97a3d4c6fb6e7166fddb08f7bea13c537e521784b06c898c9f97d8c9af15677f31516f37a649740ce693
6
+ metadata.gz: '096b6148118776622ba648d9bb4dc8808575b35beecc90b2893518d83edc402a0d257a79db04d698a656f937249d130b56e0e0eeff4df7475b413a98d9012374'
7
+ data.tar.gz: 6b9fe12bd28d762e1116313bf9dfb6f4e4804d75dd02edbf887449bb24100e098e178121401b4061f892d47c1dfee1dc5adc8b4f22376a6c8b08c96f4b3a0302
data/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## 0.7 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
+
18
+ ## 0.3 2021-02-12
19
+
20
+ - Implement `Request#serve_io`, `Request#serve_file`, `Request#redirect`
21
+ - Add basic MIME types
22
+ - Add HTTP status codes
23
+
1
24
  ## 0.2 2021-02-11
2
25
 
3
26
  - Gem renamed to Qeweney
data/Gemfile.lock CHANGED
@@ -1,13 +1,14 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- qeweney (0.2)
4
+ qeweney (0.7)
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/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # QNA
1
+ # Qeweney
2
2
 
3
3
  ## Cross-library feature rich HTTP request / response API
4
4
 
5
- QNA provides a uniform API for dealing with HTTP requests and responses.
5
+ Qeweney provides a uniform API for dealing with HTTP requests and responses.
6
6
 
7
7
  ## Features
8
8
 
data/TODO.md ADDED
@@ -0,0 +1,81 @@
1
+ ## Serve files / arbitrary content
2
+
3
+ - `#serve_file(path, opts)`
4
+ - See here: https://golang.org/pkg/net/http/#ServeFile
5
+ - support for `Range` header
6
+
7
+ - `#serve_content(io, opts)`
8
+ - See here: https://golang.org/pkg/net/http/#ServeContent
9
+ - support for `Range` header
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
@@ -4,3 +4,5 @@ module Qeweney
4
4
  end
5
5
 
6
6
  require_relative 'qeweney/request.rb'
7
+ require_relative 'qeweney/status.rb'
8
+
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qeweney
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
+
12
+ 'gif' => 'image/gif',
13
+ 'jpg' => 'image/jpeg',
14
+ 'jpeg' => 'image/jpeg',
15
+ 'png' => 'image/png',
16
+ 'ico' => 'image/x-icon',
17
+
18
+ 'pdf' => 'application/pdf',
19
+ 'json' => 'application/json',
20
+ }.freeze
21
+
22
+ def [](ref)
23
+ case ref
24
+ when Symbol
25
+ TYPES[ref.to_s]
26
+ when /\.?([^\.]+)$/
27
+ TYPES[Regexp.last_match(1)]
28
+ else
29
+ raise "Invalid argument #{ref.inspect}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -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,9 +1,155 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+ require 'zlib'
5
+ require 'stringio'
6
+ require 'digest/sha1'
7
+
8
+ require_relative 'status'
9
+
3
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
+
4
23
  module ResponseMethods
5
- def redirect(url)
6
- respond(nil, ':status' => 302, 'Location' => url)
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
+
48
+ def redirect(url, status = Status::FOUND)
49
+ respond(nil, ':status' => status, 'Location' => url)
50
+ end
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
+
62
+ def serve_file(path, 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
+ })
73
+ end
74
+
75
+ respond_with_static_file(full_path, etag, last_modified, opts)
76
+ rescue Errno::ENOENT => e
77
+ respond(nil, ':status' => Status::NOT_FOUND)
78
+ end
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
+
111
+ def file_full_path(path, opts)
112
+ if (base_path = opts[:base_path])
113
+ File.join(opts[:base_path], path)
114
+ else
115
+ path
116
+ end
117
+ end
118
+
119
+ def serve_io(io, opts)
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
7
153
  end
8
154
  end
9
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[@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
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qeweney
4
+ # HTTP status codes
5
+ module Status
6
+ # translated from https://golang.org/pkg/net/http/#pkg-constants
7
+ # https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
8
+
9
+ CONTINUE = 100 # RFC 7231, 6.2.1
10
+ SWITCHING_PROTOCOLS = 101 # RFC 7231, 6.2.2
11
+ PROCESSING = 102 # RFC 2518, 10.1
12
+ EARLY_HINTS = 103 # RFC 8297
13
+
14
+ OK = 200 # RFC 7231, 6.3.1
15
+ CREATED = 201 # RFC 7231, 6.3.2
16
+ ACCEPTED = 202 # RFC 7231, 6.3.3
17
+ NON_AUTHORITATIVE_INFO = 203 # RFC 7231, 6.3.4
18
+ NO_CONTENT = 204 # RFC 7231, 6.3.5
19
+ RESET_CONTENT = 205 # RFC 7231, 6.3.6
20
+ PARTIAL_CONTENT = 206 # RFC 7233, 4.1
21
+ MULTI_STATUS = 207 # RFC 4918, 11.1
22
+ ALREADY_REPORTED = 208 # RFC 5842, 7.1
23
+ IM_USED = 226 # RFC 3229, 10.4.1
24
+
25
+ MULTIPLE_CHOICES = 300 # RFC 7231, 6.4.1
26
+ MOVED_PERMANENTLY = 301 # RFC 7231, 6.4.2
27
+ FOUND = 302 # RFC 7231, 6.4.3
28
+ SEE_OTHER = 303 # RFC 7231, 6.4.4
29
+ NOT_MODIFIED = 304 # RFC 7232, 4.1
30
+ USE_PROXY = 305 # RFC 7231, 6.4.5
31
+
32
+ TEMPORARY_REDIRECT = 307 # RFC 7231, 6.4.7
33
+ PERMANENT_REDIRECT = 308 # RFC 7538, 3
34
+
35
+ BAD_REQUEST = 400 # RFC 7231, 6.5.1
36
+ UNAUTHORIZED = 401 # RFC 7235, 3.1
37
+ PAYMENT_REQUIRED = 402 # RFC 7231, 6.5.2
38
+ FORBIDDEN = 403 # RFC 7231, 6.5.3
39
+ NOT_FOUND = 404 # RFC 7231, 6.5.4
40
+ METHOD_NOT_ALLOWED = 405 # RFC 7231, 6.5.5
41
+ NOT_ACCEPTABLE = 406 # RFC 7231, 6.5.6
42
+ PROXY_AUTH_REQUIRED = 407 # RFC 7235, 3.2
43
+ REQUEST_TIMEOUT = 408 # RFC 7231, 6.5.7
44
+ CONFLICT = 409 # RFC 7231, 6.5.8
45
+ GONE = 410 # RFC 7231, 6.5.9
46
+ LENGTH_REQUIRED = 411 # RFC 7231, 6.5.10
47
+ PRECONDITION_FAILED = 412 # RFC 7232, 4.2
48
+ REQUEST_ENTITY_TOO_LARGE = 413 # RFC 7231, 6.5.11
49
+ REQUEST_URI_TOO_LONG = 414 # RFC 7231, 6.5.12
50
+ UNSUPPORTED_MEDIA_TYPE = 415 # RFC 7231, 6.5.13
51
+ REQUESTED_RANGE_NOT_SATISFIABLE = 416 # RFC 7233, 4.4
52
+ EXPECTATION_FAILED = 417 # RFC 7231, 6.5.14
53
+ TEAPOT = 418 # RFC 7168, 2.3.3
54
+ MISDIRECTED_REQUEST = 421 # RFC 7540, 9.1.2
55
+ UNPROCESSABLE_ENTITY = 422 # RFC 4918, 11.2
56
+ LOCKED = 423 # RFC 4918, 11.3
57
+ FAILED_DEPENDENCY = 424 # RFC 4918, 11.4
58
+ TOO_EARLY = 425 # RFC 8470, 5.2.
59
+ UPGRADE_REQUIRED = 426 # RFC 7231, 6.5.15
60
+ PRECONDITION_REQUIRED = 428 # RFC 6585, 3
61
+ TOO_MANY_REQUESTS = 429 # RFC 6585, 4
62
+ REQUEST_HEADER_FIELDS_TOO_LARGE = 431 # RFC 6585, 5
63
+ UNAVAILABLE_FOR_LEGAL_REASONS = 451 # RFC 7725, 3
64
+
65
+ INTERNAL_SERVER_ERROR = 500 # RFC 7231, 6.6.1
66
+ NOT_IMPLEMENTED = 501 # RFC 7231, 6.6.2
67
+ BAD_GATEWAY = 502 # RFC 7231, 6.6.3
68
+ SERVICE_UNAVAILABLE = 503 # RFC 7231, 6.6.4
69
+ GATEWAY_TIMEOUT = 504 # RFC 7231, 6.6.5
70
+ HTTP_VERSION_NOT_SUPPORTED = 505 # RFC 7231, 6.6.6
71
+ VARIANT_ALSO_NEGOTIATES = 506 # RFC 2295, 8.1
72
+ INSUFFICIENT_STORAGE = 507 # RFC 4918, 11.5
73
+ LOOP_DETECTED = 508 # RFC 5842, 7.2
74
+ NOT_EXTENDED = 510 # RFC 2774, 7
75
+ NETWORK_AUTHENTICATION_REQUIRED = 511 # RFC 6585, 6
76
+ end
77
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Qeweney
4
- VERSION = '0.2'
4
+ VERSION = '0.7'
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,98 @@
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
+ app = Qeweney.route do |r|
78
+ r.on_root { r.respond('root') }
79
+ r.on('foo') { r.respond(File.join('FOO', r.route_relative_path)) }
80
+ r.on('bar') {
81
+ r.on('baz') { r.respond(File.join('BAR/BAZ', r.route_relative_path)) }
82
+ r.default { r.respond(File.join('BAR', r.route_relative_path)) }
83
+ }
84
+ end
85
+
86
+ r = Qeweney.mock(':path' => '/foo/bar/baz')
87
+ app.(r)
88
+ assert_equal [[:respond, 'FOO/bar/baz', {}]], r.response_calls
89
+
90
+ r = Qeweney.mock(':path' => '/bar/a/b/c')
91
+ app.(r)
92
+ assert_equal [[:respond, 'BAR/a/b/c', {}]], r.response_calls
93
+
94
+ r = Qeweney.mock(':path' => '/bar/baz/b/c')
95
+ app.(r)
96
+ assert_equal [[:respond, 'BAR/BAZ/b/c', {}]], r.response_calls
97
+ end
98
+ 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.2'
4
+ version: '0.7'
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-11 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: []
@@ -81,16 +95,23 @@ files:
81
95
  - LICENSE
82
96
  - README.md
83
97
  - Rakefile
98
+ - TODO.md
99
+ - examples/routing_benchmark.rb
84
100
  - lib/qeweney.rb
101
+ - lib/qeweney/mime_types.rb
102
+ - lib/qeweney/rack.rb
85
103
  - lib/qeweney/request.rb
86
104
  - lib/qeweney/request_info.rb
87
105
  - lib/qeweney/response.rb
88
106
  - lib/qeweney/routing.rb
107
+ - lib/qeweney/status.rb
89
108
  - lib/qeweney/version.rb
90
109
  - qeweney.gemspec
91
110
  - test/helper.rb
92
111
  - test/run.rb
93
- - test/test_request.rb
112
+ - test/test_request_info.rb
113
+ - test/test_response.rb
114
+ - test/test_routing.rb
94
115
  homepage: http://github.com/digital-fabric/qeweney
95
116
  licenses:
96
117
  - MIT