qeweney 0.3 → 0.4

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