qeweney 0.3 → 0.4

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: bfb8f3a7995ccb0d35e79ecb1cbc9f2556ee4bc5e0bc7d61d3f1278395ea47df
4
+ data.tar.gz: 04cf0bbb7ebe8a701ab7a7b4e912e461ffc744ece02a1b75ff791eba22036da6
5
5
  SHA512:
6
- metadata.gz: 9d43894a867eaf901cb4cdecf0d907f9257ffcdf369dc89ad1528738f006c854d07bf9850e54fe2fef6e316576593fa8d829f13bc82b5a8ba700a2726daa861b
7
- data.tar.gz: d5f6d8eb6b520200b0864defcf5ef16f53965de0bac7304ca0bdf685faadf3f7528b8fecf0ba9377a3338bffbd993b851eb9e76af22fdd5dd966d4348a5ee28b
6
+ metadata.gz: 4f2d77164b052af1cdae718a2c44ccb3c0313bcc73cc87b637b9a1ccabee6701fecd5b223020b4ca854e74d218fefc0101a6150a4e426a9b5c2f1e41a8d6a951
7
+ data.tar.gz: 0030e7024d6a42f0301ba515e49f1e8da52e3b65368b515d81b520396a187dc0adf8107b32b8f0d08eb0f62d0e3c6f2f9e34f617a69444211b01b4971d950ed2
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.4 2021-02-12
2
+
3
+ - Implement caching and compression for serving static files
4
+
1
5
  ## 0.3 2021-02-12
2
6
 
3
7
  - Implement `Request#serve_io`, `Request#serve_file`, `Request#redirect`
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- qeweney (0.3)
4
+ qeweney (0.4)
5
5
  escape_utils (~> 1.2.1)
6
6
 
7
7
  GEM
data/TODO.md CHANGED
@@ -32,3 +32,68 @@
32
32
  end
33
33
  end
34
34
  ```
35
+
36
+ ## Caching
37
+
38
+ ```ruby
39
+ req.route do
40
+ req.on 'assets' do
41
+ # setting cache to true implies the following:
42
+ # - etag (calculated from file stat)
43
+ # - last-modified (from file stat)
44
+ # - vary: Accept-Encoding
45
+
46
+ # before responding, looks at the following headers
47
+ # if-modified-since: (date from client's cache)
48
+ # if-none-match: (etag from client's cache)
49
+ # cache-control: no-cache will prevent caching
50
+ req.serve_file(path, base_path: STATIC_PATH, cache: true)
51
+
52
+ # We can control this manually instead:
53
+ req.serve_file(path, base_path: STATIC_PATH, cache: {
54
+ etag: 'blahblah',
55
+ last_modified: Time.now - 365*86400,
56
+ vary: 'Accept-Encoding, User-Agent'
57
+ })
58
+ end
59
+ end
60
+ ```
61
+
62
+ So, the algorithm:
63
+
64
+ ```ruby
65
+ def validate_client_cache(path)
66
+ return false if headers['cache-control'] == 'no-cache'
67
+
68
+ stat = File.stat(path)
69
+ etag = file_stat_to_etag(path, stat)
70
+ return false if headers['if-none-match'] != etag
71
+
72
+ modified = file_stat_to_modified_stamp(stat)
73
+ return false if headers['if-modified-since'] != modified
74
+
75
+ true
76
+ end
77
+
78
+ def file_stat_to_etag(path, stat)
79
+ "#{stat.mtime.to_i.to_s(36)}#{stat.size.to_s(36)}"
80
+ end
81
+
82
+ require 'time'
83
+
84
+ def file_stat_to_modified_stamp(stat)
85
+ stat.mtime.httpdate
86
+ end
87
+ ```
88
+
89
+ ## response compression
90
+
91
+ ```ruby
92
+ req.route do
93
+ req.on 'assets' do
94
+ # gzip on the fly
95
+ req.serve_file(path, base_path: STATIC_PATH, gzip: :gzip)
96
+ end
97
+ end
98
+
99
+ ```
@@ -61,6 +61,15 @@ module Qeweney
61
61
  def forwarded_for
62
62
  @headers['x-forwarded-for']
63
63
  end
64
+
65
+ # TODO: should return encodings in client's order of preference (and take
66
+ # into account q weights)
67
+ def accept_encoding
68
+ encoding = @headers['accept-encoding']
69
+ return [] unless encoding
70
+
71
+ encoding.split(',').map { |i| i.strip }
72
+ end
64
73
  end
65
74
 
66
75
  module RequestInfoClassMethods
@@ -1,21 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+ require 'zlib'
5
+ require 'stringio'
6
+
3
7
  require_relative 'status'
4
8
 
5
9
  module Qeweney
10
+ module StaticFileCaching
11
+ class << self
12
+ def file_stat_to_etag(stat)
13
+ "#{stat.mtime.to_i.to_s(36)}#{stat.size.to_s(36)}"
14
+ end
15
+
16
+ def file_stat_to_last_modified(stat)
17
+ stat.mtime.httpdate
18
+ end
19
+ end
20
+ end
21
+
6
22
  module ResponseMethods
7
23
  def redirect(url, status = Status::FOUND)
8
24
  respond(nil, ':status' => status, 'Location' => url)
9
25
  end
10
26
 
11
27
  def serve_file(path, opts)
12
- File.open(file_full_path(path, opts), 'r') do |f|
13
- serve_io(f, opts)
28
+ full_path = file_full_path(path, opts)
29
+ stat = File.stat(full_path)
30
+ etag = StaticFileCaching.file_stat_to_etag(stat)
31
+ last_modified = StaticFileCaching.file_stat_to_last_modified(stat)
32
+
33
+ if validate_static_file_cache(etag, last_modified)
34
+ return respond(nil, {
35
+ ':status' => Status::NOT_MODIFIED,
36
+ 'etag' => etag
37
+ })
14
38
  end
15
- rescue Errno::ENOENT
39
+
40
+ respond_with_static_file(full_path, etag, last_modified, opts)
41
+ rescue Errno::ENOENT => e
16
42
  respond(nil, ':status' => Status::NOT_FOUND)
17
43
  end
18
44
 
45
+ def respond_with_static_file(path, etag, last_modified, opts)
46
+ File.open(path, 'r') do |f|
47
+ opts = opts.merge(headers: {
48
+ 'etag' => etag,
49
+ 'last-modified' => last_modified,
50
+ })
51
+
52
+ # accept_encoding should return encodings in client's order of preference
53
+ accept_encoding.each do |encoding|
54
+ case encoding
55
+ when 'deflate'
56
+ return serve_io_deflate(f, opts)
57
+ when 'gzip'
58
+ return serve_io_gzip(f, opts)
59
+ end
60
+ end
61
+ serve_io(f, opts)
62
+ end
63
+ end
64
+
65
+ def validate_static_file_cache(etag, last_modified)
66
+ if (none_match = headers['if-none-match'])
67
+ return true if none_match == etag
68
+ end
69
+ if (modified_since = headers['if-modified-since'])
70
+ return true if modified_since == last_modified
71
+ end
72
+
73
+ false
74
+ end
75
+
19
76
  def file_full_path(path, opts)
20
77
  if (base_path = opts[:base_path])
21
78
  File.join(opts[:base_path], path)
@@ -25,7 +82,30 @@ module Qeweney
25
82
  end
26
83
 
27
84
  def serve_io(io, opts)
28
- respond(io.read)
85
+ respond(io.read, opts[:headers] || {})
86
+ end
87
+
88
+ def serve_io_deflate(io, opts)
89
+ deflate = Zlib::Deflate.new
90
+ headers = opts[:headers].merge(
91
+ 'content-encoding' => 'deflate',
92
+ 'vary' => 'Accept-Encoding'
93
+ )
94
+
95
+ respond(deflate.deflate(io.read, Zlib::FINISH), headers)
96
+ end
97
+
98
+ def serve_io_gzip(io, opts)
99
+ buf = StringIO.new
100
+ z = Zlib::GzipWriter.new(buf)
101
+ z << io.read
102
+ z.flush
103
+ z.close
104
+ headers = opts[:headers].merge(
105
+ 'content-encoding' => 'gzip',
106
+ 'vary' => 'Accept-Encoding'
107
+ )
108
+ respond(buf.string, headers)
29
109
  end
30
110
  end
31
111
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Qeweney
4
- VERSION = '0.3'
4
+ VERSION = '0.4'
5
5
  end
data/test/helper.rb CHANGED
@@ -11,8 +11,26 @@ 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
+ Request.new(headers, MockAdapter.new)
28
+ end
29
+
30
+ class Request
31
+ def response_calls
32
+ adapter.calls
33
+ end
16
34
  end
17
35
  end
18
36
 
File without changes
@@ -0,0 +1,115 @@
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qeweney
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.3'
4
+ version: '0.4'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
@@ -93,7 +93,8 @@ files:
93
93
  - qeweney.gemspec
94
94
  - test/helper.rb
95
95
  - test/run.rb
96
- - test/test_request.rb
96
+ - test/test_request_info.rb
97
+ - test/test_response.rb
97
98
  homepage: http://github.com/digital-fabric/qeweney
98
99
  licenses:
99
100
  - MIT