interval_response 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.rubocop.yml +3 -0
- data/.travis.yml +7 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +79 -0
- data/Rakefile +7 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/interval_response.gemspec +46 -0
- data/lib/interval_response.rb +62 -0
- data/lib/interval_response/empty.rb +29 -0
- data/lib/interval_response/full.rb +33 -0
- data/lib/interval_response/invalid.rb +33 -0
- data/lib/interval_response/lazy_file.rb +19 -0
- data/lib/interval_response/multi.rb +72 -0
- data/lib/interval_response/sequence.rb +127 -0
- data/lib/interval_response/single.rb +36 -0
- data/lib/interval_response/to_rack_response_triplet.rb +58 -0
- data/lib/interval_response/version.rb +3 -0
- data/spec/interval_response/sequence_spec.rb +131 -0
- data/spec/interval_response_spec.rb +156 -0
- data/spec/rack_stack_spec.rb +52 -0
- data/spec/spec_helper.rb +14 -0
- metadata +183 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 2ead31e0eeba26a2752056561f041073652cc485aa7d34f9e939792ec990d0c1
|
4
|
+
data.tar.gz: 1bc76de0fe94c1943be22fc9674007021778b80f494f2aeacfc31f81db08e0e9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d217a6cc297e54d1955b76c9919d1ee7259f3a9d157d13a473a2410656824316906e3805b37d7fd14ae9f1c3f7681900740368e619e56bb023d071d2274ed4d2
|
7
|
+
data.tar.gz: 21e8766bf398ef4a00f7e81b34214da43f13bbc20b1ed8b2e89a83c305f0a5ce43764cd7024626c7367e1c40a70d4c73334e23b6d811c67f14a898e736f9cb28
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2019 Julik Tarkhanov
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
# IntervalResponse
|
2
|
+
|
3
|
+
is a little piece of machinery which allows your Rack/Rails application to correctly
|
4
|
+
serve HTTP `Range:` responses. Features:
|
5
|
+
|
6
|
+
* Strong ETags depending on response composition
|
7
|
+
* Correct response codes/headers/offsets
|
8
|
+
* `multipart/byte-range` responses
|
9
|
+
* Segments comprising the body do not have to be materialized into buffers or strings prior to serving
|
10
|
+
* Responds to both `GET` and `HEAD`, to the latter without body
|
11
|
+
* Is [measurometer](https://github.com/WeTransfer/measurometer)-instrumented
|
12
|
+
|
13
|
+
## Usage
|
14
|
+
|
15
|
+
Imagine you have a number of long Strings you want to serve concatenated as a single HTTP resource.
|
16
|
+
Wrap them in an `IntervalResponse` and return it to Rack:
|
17
|
+
|
18
|
+
```
|
19
|
+
verses_app = ->(env) {
|
20
|
+
all_verses = ImportantVerse.all.map(&:verse_text)
|
21
|
+
interval_sequence = IntervalResponse::Sequence.new(*all_verses)
|
22
|
+
response = IntervalResponse.new(interval_sequence, env['HTTP_RANGE'], env['HTTP_IF_RANGE'])
|
23
|
+
response.to_rack_response_triplet
|
24
|
+
}
|
25
|
+
```
|
26
|
+
|
27
|
+
Or imagine you want to serve out a few very large log files, concatenated together
|
28
|
+
|
29
|
+
```
|
30
|
+
log_paths = Dir.glob('/tmp/logs/kafkadoop.*.log.gz').sort
|
31
|
+
# Wrap them with "lazy file" proxies so that the files
|
32
|
+
# do not have to stay open during the entire response output
|
33
|
+
lazy_files = log_paths.map { |path| IntervalResponse::LazyFile.new(path) }
|
34
|
+
interval_sequence = IntervalResponse::Sequence.new(*lazy_files)
|
35
|
+
response = IntervalResponse.new(interval_sequence, env['HTTP_RANGE'], env['HTTP_IF_RANGE'])
|
36
|
+
response.to_rack_response_triplet(headers: {'X-Server' => 'teapot'})
|
37
|
+
```
|
38
|
+
|
39
|
+
Note that the headers `IntervalResponse` generates are _very_ specific and will override your
|
40
|
+
headers. The following headers will be overridden (as they must all be correct for the serving
|
41
|
+
to work):
|
42
|
+
|
43
|
+
```
|
44
|
+
Accept-Ranges
|
45
|
+
Content-Length
|
46
|
+
Content-Type
|
47
|
+
Content-Range
|
48
|
+
ETag
|
49
|
+
```
|
50
|
+
|
51
|
+
## Installation
|
52
|
+
|
53
|
+
Add this line to your application's Gemfile:
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
gem 'interval_response'
|
57
|
+
```
|
58
|
+
|
59
|
+
And then execute:
|
60
|
+
|
61
|
+
$ bundle
|
62
|
+
|
63
|
+
Or install it yourself as:
|
64
|
+
|
65
|
+
$ gem install interval_response
|
66
|
+
|
67
|
+
## Development
|
68
|
+
|
69
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
70
|
+
|
71
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
72
|
+
|
73
|
+
## Contributing
|
74
|
+
|
75
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/julik/interval_response.
|
76
|
+
|
77
|
+
## License
|
78
|
+
|
79
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "interval_response"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "interval_response/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "interval_response"
|
8
|
+
spec.version = IntervalResponse::VERSION
|
9
|
+
spec.authors = ["Julik Tarkhanov"]
|
10
|
+
spec.email = ["me@julik.nl"]
|
11
|
+
|
12
|
+
spec.summary = %q{Assemble HTTP responses from spliced sequences of payloads}
|
13
|
+
spec.description = %q{Assemble HTTP responses from spliced sequences of payloads}
|
14
|
+
spec.homepage = "https://github.com/WeTransfer/interval_response"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
18
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
19
|
+
if spec.respond_to?(:metadata)
|
20
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
21
|
+
spec.metadata["source_code_uri"] = "https://github.com/WeTransfer/interval_response"
|
22
|
+
spec.metadata["changelog_uri"] = "https://github.com/WeTransfer/interval_response"
|
23
|
+
else
|
24
|
+
raise "RubyGems 2.0 or newer is required to protect against " \
|
25
|
+
"public gem pushes."
|
26
|
+
end
|
27
|
+
|
28
|
+
# Specify which files should be added to the gem when it is released.
|
29
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
30
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
31
|
+
`git ls-files -z`.split("\x0")
|
32
|
+
end
|
33
|
+
spec.bindir = "exe"
|
34
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
35
|
+
spec.require_paths = ["lib"]
|
36
|
+
|
37
|
+
spec.add_runtime_dependency "rack"
|
38
|
+
spec.add_runtime_dependency "measurometer"
|
39
|
+
|
40
|
+
spec.add_development_dependency "bundler", "~> 1.17"
|
41
|
+
spec.add_development_dependency "rake", "~> 12"
|
42
|
+
spec.add_development_dependency "rspec", "~> 3"
|
43
|
+
spec.add_development_dependency "complexity_assert"
|
44
|
+
spec.add_development_dependency "rack-test"
|
45
|
+
spec.add_development_dependency "wetransfer_style"
|
46
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'measurometer'
|
2
|
+
require 'rack'
|
3
|
+
|
4
|
+
module IntervalResponse
|
5
|
+
class Error < StandardError; end
|
6
|
+
|
7
|
+
require_relative "interval_response/version"
|
8
|
+
require_relative "interval_response/to_rack_response_triplet"
|
9
|
+
require_relative "interval_response/sequence"
|
10
|
+
require_relative "interval_response/empty"
|
11
|
+
require_relative "interval_response/single"
|
12
|
+
require_relative "interval_response/invalid"
|
13
|
+
require_relative "interval_response/multi"
|
14
|
+
require_relative "interval_response/full"
|
15
|
+
require_relative "interval_response/lazy_file"
|
16
|
+
|
17
|
+
ENTIRE_RESOURCE_RANGE = 'bytes=0-'
|
18
|
+
|
19
|
+
def self.new(interval_map, http_range_header_value_or_nil, http_if_range_header_or_nil)
|
20
|
+
# If the 'If-Range' header is provided but does not match, discard the Range header. It means
|
21
|
+
# that the client is requesting a certain representation of the resource and wants a range
|
22
|
+
# _within_ that representation, but the representation has since changed and the offsets
|
23
|
+
# no longer make sense. In that case we are supposed to answer with a 200 and the full
|
24
|
+
# monty.
|
25
|
+
return new(interval_map, ENTIRE_RESOURCE_RANGE, nil) if http_if_range_header_or_nil && http_if_range_header_or_nil != interval_map.etag
|
26
|
+
|
27
|
+
if http_if_range_header_or_nil
|
28
|
+
Measurometer.increment_counter('interval_response.if_range_provided')
|
29
|
+
elsif http_range_header_value_or_nil
|
30
|
+
Measurometer.increment_counter('interval_response.if_range_not_provided')
|
31
|
+
end
|
32
|
+
|
33
|
+
prepare_response(interval_map, http_range_header_value_or_nil, http_if_range_header_or_nil).tap do |res|
|
34
|
+
response_type_name_for_metric = res.class.to_s.split('::').last.downcase # Some::Module::Empty => empty
|
35
|
+
Measurometer.increment_counter('interval_response.resp_%s' % response_type_name_for_metric)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.prepare_response(interval_map, http_range_header_value_or_nil, _http_if_range_header_or_nil)
|
40
|
+
# Case 1 - response of 0 bytes (empty resource).
|
41
|
+
# We don't even have to parse the Range header for this since
|
42
|
+
# the response will be the same, always.
|
43
|
+
return Empty.new(interval_map) if interval_map.empty?
|
44
|
+
|
45
|
+
# Parse the HTTP Range: header
|
46
|
+
range_request_header = http_range_header_value_or_nil || ENTIRE_RESOURCE_RANGE
|
47
|
+
http_ranges = Rack::Utils.get_byte_ranges(range_request_header, interval_map.size)
|
48
|
+
|
49
|
+
# Case 2 - Client did send us a Range header, but Rack discarded
|
50
|
+
# it because it is invalid and cannot be satisfied
|
51
|
+
return Invalid.new(interval_map) if http_range_header_value_or_nil && http_ranges.empty?
|
52
|
+
|
53
|
+
# Case 3 - entire resource
|
54
|
+
return Full.new(interval_map) if http_ranges.length == 1 && http_ranges.first == (0..(interval_map.size - 1))
|
55
|
+
|
56
|
+
# Case 4 - one content range
|
57
|
+
return Single.new(interval_map, http_ranges) if http_ranges.length == 1
|
58
|
+
|
59
|
+
# Case 5 - MIME multipart with multiple content ranges
|
60
|
+
Multi.new(interval_map, http_ranges)
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# Serves out a response that is of size 0
|
2
|
+
class IntervalResponse::Empty
|
3
|
+
include IntervalResponse::ToRackResponseTriplet
|
4
|
+
|
5
|
+
def initialize(interval_map)
|
6
|
+
@interval_map = interval_map
|
7
|
+
end
|
8
|
+
|
9
|
+
def each
|
10
|
+
# No-op
|
11
|
+
end
|
12
|
+
|
13
|
+
def status_code
|
14
|
+
200
|
15
|
+
end
|
16
|
+
|
17
|
+
def content_length
|
18
|
+
0
|
19
|
+
end
|
20
|
+
|
21
|
+
def headers
|
22
|
+
{
|
23
|
+
'Accept-Ranges' => 'bytes',
|
24
|
+
'Content-Length' => '0',
|
25
|
+
'Content-Type' => 'binary/octet-stream',
|
26
|
+
'ETag' => @interval_map.etag,
|
27
|
+
}
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# Serves out a response that contains the entire resource
|
2
|
+
class IntervalResponse::Full
|
3
|
+
include IntervalResponse::ToRackResponseTriplet
|
4
|
+
|
5
|
+
def initialize(interval_map, *)
|
6
|
+
@interval_map = interval_map
|
7
|
+
end
|
8
|
+
|
9
|
+
def each
|
10
|
+
# serve the part of the interval map
|
11
|
+
full_range = 0..(@interval_map.size - 1)
|
12
|
+
@interval_map.each_in_range(full_range) do |segment, range_in_segment|
|
13
|
+
yield(segment, range_in_segment)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def status_code
|
18
|
+
200
|
19
|
+
end
|
20
|
+
|
21
|
+
def content_length
|
22
|
+
@interval_map.size
|
23
|
+
end
|
24
|
+
|
25
|
+
def headers
|
26
|
+
{
|
27
|
+
'Accept-Ranges' => 'bytes',
|
28
|
+
'Content-Length' => @interval_map.size.to_s,
|
29
|
+
'Content-Type' => 'binary/octet-stream',
|
30
|
+
'ETag' => @interval_map.etag,
|
31
|
+
}
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# Serves out a response that is of size 0
|
2
|
+
class IntervalResponse::Invalid
|
3
|
+
include IntervalResponse::ToRackResponseTriplet
|
4
|
+
|
5
|
+
ERROR_JSON = '{"message": "Ranges cannot be satisfied"}'
|
6
|
+
|
7
|
+
def initialize(segment_map)
|
8
|
+
@interval_map = segment_map
|
9
|
+
end
|
10
|
+
|
11
|
+
def each
|
12
|
+
full_segment_range = (0..(ERROR_JSON.bytesize - 1))
|
13
|
+
yield(ERROR_JSON, full_segment_range)
|
14
|
+
end
|
15
|
+
|
16
|
+
def status_code
|
17
|
+
416
|
18
|
+
end
|
19
|
+
|
20
|
+
def content_length
|
21
|
+
ERROR_JSON.bytesize
|
22
|
+
end
|
23
|
+
|
24
|
+
def headers
|
25
|
+
{
|
26
|
+
'Accept-Ranges' => 'bytes',
|
27
|
+
'Content-Length' => ERROR_JSON.bytesize.to_s,
|
28
|
+
'Content-Type' => 'application/json',
|
29
|
+
'Content-Range' => "bytes */#{@interval_map.size}",
|
30
|
+
'ETag' => @interval_map.etag,
|
31
|
+
}
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# Used so that if a sequence of files
|
2
|
+
# gets served out, the files should not be kept open
|
3
|
+
# during the entire response output - as this might
|
4
|
+
# exhaust the file descriptor table
|
5
|
+
class IntervalResponse::LazyFile
|
6
|
+
def initialize(filesystem_path)
|
7
|
+
@fs_path = filesystem_path
|
8
|
+
end
|
9
|
+
|
10
|
+
def size
|
11
|
+
File.size(@fs_path)
|
12
|
+
end
|
13
|
+
|
14
|
+
def with
|
15
|
+
File.open(@fs_path, 'rb') do |file_handle|
|
16
|
+
yield file_handle
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
class IntervalResponse::Multi
|
4
|
+
include IntervalResponse::ToRackResponseTriplet
|
5
|
+
|
6
|
+
ALPHABET = ('0'..'9').to_a + ('a'..'z').to_a + ('A'..'Z').to_a
|
7
|
+
|
8
|
+
def initialize(interval_map, http_ranges)
|
9
|
+
@interval_map = interval_map
|
10
|
+
@http_ranges = http_ranges
|
11
|
+
# RFC1521 says that a boundary "must be no longer than 70 characters,
|
12
|
+
# not counting the two leading hyphens".
|
13
|
+
# Modulo-based random is biased but it doesn't matter much for us (we do not need to
|
14
|
+
# be extremely secure here)
|
15
|
+
@boundary = SecureRandom.bytes(24).unpack("C*").map { |b| ALPHABET[b % ALPHABET.length] }.join
|
16
|
+
end
|
17
|
+
|
18
|
+
def each
|
19
|
+
# serve the part of the interval map
|
20
|
+
@http_ranges.each_with_index do |http_range, range_i|
|
21
|
+
part_header = part_header(range_i, http_range)
|
22
|
+
entire_header_range = 0..(part_header.bytesize - 1)
|
23
|
+
yield(part_header, entire_header_range)
|
24
|
+
@interval_map.each_in_range(http_range) do |segment, range_in_segment|
|
25
|
+
yield(segment, range_in_segment)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def status_code
|
31
|
+
206
|
32
|
+
end
|
33
|
+
|
34
|
+
def content_length
|
35
|
+
# The Content-Length of a multipart response includes the length
|
36
|
+
# of all the ranges of the resource, but also the lengths of the
|
37
|
+
# multipart part headers - which we need to precompute. To do it
|
38
|
+
# we need to run through all of our ranges and output some strings,
|
39
|
+
# and if a lot of ranges are involved this can get expensive. So
|
40
|
+
# memoize the envelope size (it never changes between calls)
|
41
|
+
@envelope_size ||= compute_envelope_size
|
42
|
+
end
|
43
|
+
|
44
|
+
def headers
|
45
|
+
{
|
46
|
+
'Accept-Ranges' => 'bytes',
|
47
|
+
'Content-Length' => content_length.to_s,
|
48
|
+
'Content-Type' => "multipart/byte-ranges; boundary=#{@boundary}",
|
49
|
+
'ETag' => @interval_map.etag,
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def compute_envelope_size
|
56
|
+
@http_ranges.each_with_index.inject(0) do |size_sum, (http_range, part_index)|
|
57
|
+
header_bytes = part_header(part_index, http_range)
|
58
|
+
range_size = http_range.end - http_range.begin + 1
|
59
|
+
size_sum + header_bytes.bytesize + range_size
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def part_header(part_index, http_r)
|
64
|
+
[
|
65
|
+
part_index > 0 ? "\r\n" : "", # Parts follwing the first have to be delimited "at the top"
|
66
|
+
"--%s\r\n" % @boundary,
|
67
|
+
"Content-Type: binary/octet-stream\r\n",
|
68
|
+
"Content-Range: bytes %d-%d/%d\r\n" % [http_r.begin, http_r.end, @interval_map.size],
|
69
|
+
"\r\n",
|
70
|
+
].join
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
require 'digest'
|
2
|
+
|
3
|
+
# An interval sequence represents a linear sequence of non-overlapping,
|
4
|
+
# joined intervals. For example, an HTTP response which consists of
|
5
|
+
# multiple edge included segments, or a timeline with clips joined together.
|
6
|
+
# Every interval contains a *segment* - an arbitrary object which responds to
|
7
|
+
# `#size` at time of adding to the IntervalSequence.
|
8
|
+
class IntervalResponse::Sequence
|
9
|
+
MULTIPART_GENRATOR_FINGERPRINT = 'boo'
|
10
|
+
Interval = Struct.new(:segment, :size, :offset, :position)
|
11
|
+
|
12
|
+
attr_reader :size
|
13
|
+
|
14
|
+
def initialize(*segments)
|
15
|
+
@intervals = []
|
16
|
+
@size = 0
|
17
|
+
segments.each { |s| self << s }
|
18
|
+
end
|
19
|
+
|
20
|
+
def <<(segment)
|
21
|
+
return self if segment.size == 0
|
22
|
+
segment_size_or_bytesize = segment.respond_to?(:bytesize) ? segment.bytesize : segment.size
|
23
|
+
@intervals << Interval.new(segment, segment_size_or_bytesize, @size, @intervals.length)
|
24
|
+
@size += segment.size
|
25
|
+
self
|
26
|
+
end
|
27
|
+
|
28
|
+
def each_in_range(from_range_in_resource)
|
29
|
+
# Skip empty ranges
|
30
|
+
requested_range_size = (from_range_in_resource.end - from_range_in_resource.begin) + 1
|
31
|
+
return if requested_range_size < 1
|
32
|
+
|
33
|
+
# ...and if the range misses our intervals completely
|
34
|
+
included_intervals = intervals_within_range(from_range_in_resource)
|
35
|
+
|
36
|
+
# And normal case - walk through included intervals
|
37
|
+
included_intervals.each do |interval|
|
38
|
+
int_start = interval.offset
|
39
|
+
int_end = interval.offset + interval.size - 1
|
40
|
+
req_start = from_range_in_resource.begin
|
41
|
+
req_end = from_range_in_resource.end
|
42
|
+
range_within_interval = (max(int_start, req_start) - int_start)..(min(int_end, req_end) - int_start)
|
43
|
+
|
44
|
+
# Allow Sequences to be composed together
|
45
|
+
if interval.segment.respond_to?(:each_in_range)
|
46
|
+
interval.segment.each_in_range(range_within_interval) do |sub_segment, sub_range|
|
47
|
+
yield(sub_segment, sub_range)
|
48
|
+
end
|
49
|
+
else
|
50
|
+
yield(interval.segment, range_within_interval)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def empty?
|
56
|
+
@size == 0
|
57
|
+
end
|
58
|
+
|
59
|
+
# For IE resumes to work, a strong ETag must be set in the response, and a strong
|
60
|
+
# comparison must be performed on it.
|
61
|
+
#
|
62
|
+
# ETags have meaning with Range: requests, because when a client requests
|
63
|
+
# a range it will send the ETag back in the If-Range header. That header
|
64
|
+
# tells the server that "I want to have the ranges as emitted by the
|
65
|
+
# response representation that has output this etag". This is done so that
|
66
|
+
# there is a guarantee that the same resource being requested has the same
|
67
|
+
# resource length (off of which the ranges get computed), and the ranges
|
68
|
+
# can be safely combined by the client. In practice this means that the ETag
|
69
|
+
# must contain some "version handle" which stays unchanged as long as the code
|
70
|
+
# responsible for generating the response does not change. In our case the response
|
71
|
+
# can change due to the following things:
|
72
|
+
#
|
73
|
+
# * The lengths of the segments change
|
74
|
+
# * The contents of the segments changes
|
75
|
+
# * Code that outputs the ranges themselves changes, and outputs different offsets of differently-sized resources.
|
76
|
+
# A resource _can_ be differently sized since the MIME multiplart-byte-range response can have its boundary
|
77
|
+
# or per-part headers change, which affects the _size_ of the MIME part headers. Even though the boundary is
|
78
|
+
# not a part of the resource itself, the sizes of the part headers *do* contribute to the envelope size - that
|
79
|
+
# should stay the same as long as the ETag holds.
|
80
|
+
#
|
81
|
+
# It is important that the returned ETag is a strong ETag (not prefixed with 'W/') and must be
|
82
|
+
# enclosed in double-quotes.
|
83
|
+
#
|
84
|
+
# See for more https://blogs.msdn.microsoft.com/ieinternals/2011/06/03/download-resumption-in-internet-explorer/
|
85
|
+
def etag
|
86
|
+
d = Digest::SHA1.new
|
87
|
+
d << IntervalResponse::VERSION
|
88
|
+
d << Marshal.dump(@intervals.map(&:size))
|
89
|
+
'"%s"' % d.hexdigest
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
def max(a, b)
|
95
|
+
a > b ? a : b
|
96
|
+
end
|
97
|
+
|
98
|
+
def min(a, b)
|
99
|
+
a < b ? a : b
|
100
|
+
end
|
101
|
+
|
102
|
+
def interval_under(offset)
|
103
|
+
@intervals.bsearch do |interval|
|
104
|
+
# bsearch expects a 0 return value for "exact match".
|
105
|
+
# -1 tells it "look to my left" and 1 "look to my right",
|
106
|
+
# which is the output of the <=> operator. If we only needed
|
107
|
+
# to find the exact offset in a sorted list just <=> would be
|
108
|
+
# fine, but since we are looking for offsets within intervals
|
109
|
+
# we will expand the the "match" case with "falls within interval".
|
110
|
+
if offset >= interval.offset && offset < (interval.offset + interval.size)
|
111
|
+
0
|
112
|
+
else
|
113
|
+
offset <=> interval.offset
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def intervals_within_range(http_range)
|
119
|
+
first_touched = interval_under(http_range.begin)
|
120
|
+
|
121
|
+
# The range starts to the right of available range
|
122
|
+
return [] unless first_touched
|
123
|
+
|
124
|
+
last_touched = interval_under(http_range.end) || @intervals.last
|
125
|
+
@intervals[first_touched.position..last_touched.position]
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# Serves out a response that consists of one HTTP Range,
|
2
|
+
# which is always not the entire resource
|
3
|
+
class IntervalResponse::Single
|
4
|
+
include IntervalResponse::ToRackResponseTriplet
|
5
|
+
|
6
|
+
def initialize(interval_map, http_ranges)
|
7
|
+
@interval_map = interval_map
|
8
|
+
@http_range = http_ranges.first
|
9
|
+
end
|
10
|
+
|
11
|
+
# Serve the part of the interval map
|
12
|
+
def each
|
13
|
+
@interval_map.each_in_range(@http_range) do |segment, range_in_segment|
|
14
|
+
yield(segment, range_in_segment)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def status_code
|
19
|
+
206
|
20
|
+
end
|
21
|
+
|
22
|
+
def content_length
|
23
|
+
@http_range.end - @http_range.begin + 1
|
24
|
+
end
|
25
|
+
|
26
|
+
def headers
|
27
|
+
c_range = ('bytes %d-%d/%d' % [@http_range.begin, @http_range.end, @interval_map.size])
|
28
|
+
{
|
29
|
+
'Accept-Ranges' => 'bytes',
|
30
|
+
'Content-Length' => (@http_range.end - @http_range.begin + 1).to_s,
|
31
|
+
'Content-Type' => 'binary/octet-stream',
|
32
|
+
'Content-Range' => c_range,
|
33
|
+
'ETag' => @interval_map.etag,
|
34
|
+
}
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module IntervalResponse::ToRackResponseTriplet
|
2
|
+
CHUNK_SIZE = 65 * 1024 # Roughly one TCP kernel buffer
|
3
|
+
|
4
|
+
class RackBodyWrapper
|
5
|
+
def initialize(with_interval_response, chunk_size:)
|
6
|
+
@chunk_size = chunk_size
|
7
|
+
@interval_response = with_interval_response
|
8
|
+
end
|
9
|
+
|
10
|
+
def each
|
11
|
+
buf = String.new(capacity: @chunk_size)
|
12
|
+
@interval_response.each do |segment, range_in_segment|
|
13
|
+
case segment
|
14
|
+
when IntervalResponse::LazyFile
|
15
|
+
segment.with do |_file_handle|
|
16
|
+
with_each_chunk(range_in_segment) do |offset, read_n|
|
17
|
+
segment.seek(offset, IO::SEEK_SET)
|
18
|
+
yield segment.read_nonblock(read_n, buf)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
when String
|
22
|
+
with_each_chunk(range_in_segment) do |offset, read_n|
|
23
|
+
yield segment.slice(offset, read_n)
|
24
|
+
end
|
25
|
+
when IO, Tempfile
|
26
|
+
with_each_chunk(range_in_segment) do |offset, read_n|
|
27
|
+
segment.seek(offset, IO::SEEK_SET)
|
28
|
+
yield segment.read_nonblock(read_n, buf)
|
29
|
+
end
|
30
|
+
else
|
31
|
+
raise TypeError, "RackBodyWrapper only supports IOs or Strings"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
ensure
|
35
|
+
buf.clear
|
36
|
+
end
|
37
|
+
|
38
|
+
def with_each_chunk(range_in_segment)
|
39
|
+
range_size = range_in_segment.end - range_in_segment.begin + 1
|
40
|
+
start_at_offset = range_in_segment.begin
|
41
|
+
n_whole_segments, remainder = range_size.divmod(@chunk_size)
|
42
|
+
|
43
|
+
n_whole_segments.times do |n|
|
44
|
+
unit_offset = start_at_offset + (n * @chunk_size)
|
45
|
+
yield unit_offset, @chunk_size
|
46
|
+
end
|
47
|
+
|
48
|
+
if remainder > 0
|
49
|
+
unit_offset = start_at_offset + (n_whole_segments * @chunk_size)
|
50
|
+
yield unit_offset, remainder
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def to_rack_response_triplet(headers: nil, chunk_size: CHUNK_SIZE)
|
56
|
+
[status_code, headers.to_h.merge(self.headers), RackBodyWrapper.new(self, chunk_size: chunk_size)]
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'complexity_assert'
|
3
|
+
|
4
|
+
RSpec.describe IntervalResponse::Sequence do
|
5
|
+
context 'with a number of sized segments' do
|
6
|
+
it 'allows interval queries and yields tuples with the given object and the range inside it' do
|
7
|
+
seq = described_class.new
|
8
|
+
expect(seq.size).to eq(0)
|
9
|
+
expect(seq).to be_empty
|
10
|
+
|
11
|
+
a = double(:a, size: 6)
|
12
|
+
b = double(:b, size: 12)
|
13
|
+
c = double(:c, size: 17)
|
14
|
+
seq << a << b << c
|
15
|
+
|
16
|
+
expect(seq).not_to be_empty
|
17
|
+
expect(seq.size).to eq(6 + 12 + 17)
|
18
|
+
expect { |b|
|
19
|
+
seq.each_in_range(0..0, &b)
|
20
|
+
}.to yield_with_args(a, 0..0)
|
21
|
+
|
22
|
+
expect { |b|
|
23
|
+
seq.each_in_range(0..7, &b)
|
24
|
+
}.to yield_successive_args([a, 0..5], [b, 0..1])
|
25
|
+
|
26
|
+
expect { |b|
|
27
|
+
seq.each_in_range(7..27, &b)
|
28
|
+
}.to yield_successive_args([b, 1..11], [c, 0..9])
|
29
|
+
|
30
|
+
expect { |b|
|
31
|
+
seq.each_in_range(0..(6 + 12 - 1), &b)
|
32
|
+
}.to yield_successive_args([a, 0..5], [b, 0..11])
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'generates the ETag for an empty sequence, and the etag contains data' do
|
36
|
+
seq = described_class.new
|
37
|
+
etag_for_sequence = seq.etag
|
38
|
+
expect(etag_for_sequence).to start_with('"')
|
39
|
+
expect(etag_for_sequence).to end_with('"')
|
40
|
+
expect(etag_for_sequence.bytesize).to be > 8
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'generates the ETag dependent on the sequence composition' do
|
44
|
+
a = double(:a, size: 6)
|
45
|
+
b = double(:b, size: 12)
|
46
|
+
c = double(:c, size: 17)
|
47
|
+
seq = described_class.new(a, b, c)
|
48
|
+
etag_for_sequence = seq.etag
|
49
|
+
expect(etag_for_sequence).to start_with('"')
|
50
|
+
expect(etag_for_sequence).to end_with('"')
|
51
|
+
|
52
|
+
seq = described_class.new(a, b, c)
|
53
|
+
etag_for_sequence_of_same_sizes = seq.etag
|
54
|
+
expect(etag_for_sequence_of_same_sizes).to eq(etag_for_sequence)
|
55
|
+
|
56
|
+
seq = described_class.new(a, b, double(size: 7))
|
57
|
+
etag_for_sequence_of_same_sizes = seq.etag
|
58
|
+
expect(etag_for_sequence_of_same_sizes).not_to eq(etag_for_sequence)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'can handle a range that stretches outside of the available range' do
|
62
|
+
a = double('a', size: 3)
|
63
|
+
b = double('b', size: 4)
|
64
|
+
c = double('c', size: 1)
|
65
|
+
|
66
|
+
seq = described_class.new(a, b, c)
|
67
|
+
expect { |b|
|
68
|
+
seq.each_in_range(0..27, &b)
|
69
|
+
}.to yield_successive_args([a, 0..2], [b, 0..3], [c, 0..0])
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'is composable' do
|
73
|
+
a = double('a', size: 3)
|
74
|
+
b = double('b', size: 4)
|
75
|
+
c = double('c', size: 1)
|
76
|
+
|
77
|
+
seq = described_class.new(a, b, described_class.new(c))
|
78
|
+
|
79
|
+
expect { |b|
|
80
|
+
seq.each_in_range(0..27, &b)
|
81
|
+
}.to yield_successive_args([a, 0..2], [b, 0..3], [c, 0..0])
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'has close to linear performance with large number of ranges and intervals' do
|
85
|
+
module RangeIntervalCombinedComplexity
|
86
|
+
ONE_SEGMENT = Struct.new(:size).new(13)
|
87
|
+
def self.generate_args(size)
|
88
|
+
intervals = [ONE_SEGMENT] * size
|
89
|
+
seq = IntervalResponse::Sequence.new(*intervals)
|
90
|
+
http_ranges = size.times.map do |n|
|
91
|
+
range_start = (n * 13) + 4
|
92
|
+
range_end = (n * 13) + 12
|
93
|
+
range_start..range_end
|
94
|
+
end
|
95
|
+
|
96
|
+
[seq, http_ranges]
|
97
|
+
end
|
98
|
+
|
99
|
+
def self.run(seq, http_ranges)
|
100
|
+
http_ranges.each do |r|
|
101
|
+
seq.each_in_range(r) do |double, range|
|
102
|
+
# pass
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
expect(RangeIntervalCombinedComplexity).to be_linear
|
108
|
+
end
|
109
|
+
|
110
|
+
it 'has close to linear performance with a range in the middle' do
|
111
|
+
module SearchInMiddle
|
112
|
+
ONE_SEGMENT = Struct.new(:size).new(13)
|
113
|
+
def self.generate_args(size)
|
114
|
+
intervals = [ONE_SEGMENT] * size
|
115
|
+
seq = IntervalResponse::Sequence.new(*intervals)
|
116
|
+
range_start = ((size / 2) * 13) + 4
|
117
|
+
range_end = ((size / 2) * 13) + 128
|
118
|
+
|
119
|
+
[seq, range_start..range_end]
|
120
|
+
end
|
121
|
+
|
122
|
+
def self.run(seq, r)
|
123
|
+
seq.each_in_range(r) do |double, range|
|
124
|
+
# pass
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
expect(SearchInMiddle).to be_linear
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
RSpec.describe IntervalResponse do
|
2
|
+
it "has a version number" do
|
3
|
+
expect(IntervalResponse::VERSION).not_to be nil
|
4
|
+
end
|
5
|
+
|
6
|
+
context 'with an empty resource' do
|
7
|
+
let(:seq) { IntervalResponse::Sequence.new }
|
8
|
+
it 'always returns the empty response' do
|
9
|
+
response = IntervalResponse.new(seq, _http_range_header = nil, _if_range_header = nil)
|
10
|
+
expect(response.status_code).to eq(200)
|
11
|
+
expect(response.content_length).to eq(0)
|
12
|
+
expect(response.headers).to eq(
|
13
|
+
"Accept-Ranges" => "bytes",
|
14
|
+
"Content-Length" => "0",
|
15
|
+
"Content-Type" => "binary/octet-stream",
|
16
|
+
'ETag' => seq.etag,
|
17
|
+
)
|
18
|
+
expect { |b|
|
19
|
+
response.each(&b)
|
20
|
+
}.not_to yield_control
|
21
|
+
|
22
|
+
response = IntervalResponse.new(seq, 'bytes=0-', _if_range = nil)
|
23
|
+
expect(response.status_code).to eq(200)
|
24
|
+
expect(response.content_length).to eq(0)
|
25
|
+
expect(response.headers).to eq(
|
26
|
+
"Accept-Ranges" => "bytes",
|
27
|
+
"Content-Length" => "0",
|
28
|
+
"Content-Type" => "binary/octet-stream",
|
29
|
+
'ETag' => seq.etag,
|
30
|
+
)
|
31
|
+
expect { |b|
|
32
|
+
response.each(&b)
|
33
|
+
}.not_to yield_control
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'with intervals containing data' do
|
38
|
+
let(:segment_a) { 'yes' }
|
39
|
+
let(:segment_b) { ' we ' }
|
40
|
+
let(:segment_c) { '!' }
|
41
|
+
|
42
|
+
let(:seq) do
|
43
|
+
IntervalResponse::Sequence.new(segment_a, segment_b, segment_c)
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'returns the full response if the client did not ask for a Range' do
|
47
|
+
response = IntervalResponse.new(seq, _http_range_header = nil, _if_range = nil)
|
48
|
+
expect(response.status_code).to eq(200)
|
49
|
+
expect(response.content_length).to eq(3 + 4 + 1)
|
50
|
+
expect(response.headers).to eq(
|
51
|
+
"Accept-Ranges" => "bytes",
|
52
|
+
"Content-Length" => "8",
|
53
|
+
"Content-Type" => "binary/octet-stream",
|
54
|
+
'ETag' => seq.etag,
|
55
|
+
)
|
56
|
+
expect { |b|
|
57
|
+
response.each(&b)
|
58
|
+
}.to yield_successive_args([segment_a, 0..2], [segment_b, 0..3], [segment_c, 0..0])
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'returns a single HTTP range if the client asked for it and it can be satisfied' do
|
62
|
+
response = IntervalResponse.new(seq, "bytes=2-4", _if_range = nil)
|
63
|
+
expect(response.status_code).to eq(206)
|
64
|
+
expect(response.content_length).to eq(3)
|
65
|
+
expect(response.headers).to eq(
|
66
|
+
"Accept-Ranges" => "bytes",
|
67
|
+
"Content-Length" => "3",
|
68
|
+
"Content-Range" => "bytes 2-4/8",
|
69
|
+
"Content-Type" => "binary/octet-stream",
|
70
|
+
'ETag' => seq.etag,
|
71
|
+
)
|
72
|
+
expect { |b|
|
73
|
+
response.each(&b)
|
74
|
+
}.to yield_successive_args([segment_a, 2..2], [segment_b, 0..1])
|
75
|
+
end
|
76
|
+
|
77
|
+
it 'returns a single HTTP range if the client asked for it and it can be satisfied, ETag matches' do
|
78
|
+
response = IntervalResponse.new(seq, "bytes=2-4", _if_range = seq.etag)
|
79
|
+
expect(response.status_code).to eq(206)
|
80
|
+
expect(response.content_length).to eq(3)
|
81
|
+
expect(response.headers).to eq(
|
82
|
+
"Accept-Ranges" => "bytes",
|
83
|
+
"Content-Length" => "3",
|
84
|
+
"Content-Range" => "bytes 2-4/8",
|
85
|
+
"Content-Type" => "binary/octet-stream",
|
86
|
+
'ETag' => seq.etag,
|
87
|
+
)
|
88
|
+
expect { |b|
|
89
|
+
response.each(&b)
|
90
|
+
}.to yield_successive_args([segment_a, 2..2], [segment_b, 0..1])
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'responss with the entier resource if the Range is satisfiable but the If-Range specifies a different ETag than the sequence' do
|
94
|
+
response = IntervalResponse.new(seq, _http_range_header = "bytes=12901-", _http_if_range = '"different"')
|
95
|
+
expect(response.status_code).to eq(200)
|
96
|
+
expect(response.content_length).to eq(8)
|
97
|
+
expect(response.headers).to eq(
|
98
|
+
"Accept-Ranges" => "bytes",
|
99
|
+
"Content-Length" => "8",
|
100
|
+
"Content-Type" => "binary/octet-stream",
|
101
|
+
'ETag' => seq.etag,
|
102
|
+
)
|
103
|
+
end
|
104
|
+
|
105
|
+
it 'responds with the range that can be satisfied if asked for 2 of which one is unsatisfiable' do
|
106
|
+
response = IntervalResponse.new(seq, _http_range_header = "bytes=0-5,12901-", _http_if_range = nil)
|
107
|
+
expect(response.status_code).to eq(206)
|
108
|
+
expect(response.content_length).to eq(6)
|
109
|
+
expect(response.headers).to eq(
|
110
|
+
"Accept-Ranges" => "bytes",
|
111
|
+
"Content-Length" => "6",
|
112
|
+
"Content-Range" => "bytes 0-5/8",
|
113
|
+
"Content-Type" => "binary/octet-stream",
|
114
|
+
'ETag' => seq.etag,
|
115
|
+
)
|
116
|
+
|
117
|
+
expect { |b|
|
118
|
+
response.each(&b)
|
119
|
+
}.to yield_successive_args([segment_a, 0..2], [segment_b, 0..2])
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'responds with MIME multipart of ranges if the client asked for it and it can be satisfied' do
|
123
|
+
response = IntervalResponse.new(seq, _http_range_header = "bytes=0-0,2-2", _http_if_range = nil)
|
124
|
+
response.instance_variable_set('@boundary', 'tcROXEYMdRNXRRYstW296yM1')
|
125
|
+
|
126
|
+
expect(response.status_code).to eq(206)
|
127
|
+
expect(response.content_length).to eq(190)
|
128
|
+
expect(response.headers).to eq(
|
129
|
+
"Accept-Ranges" => "bytes",
|
130
|
+
"Content-Length" => "190",
|
131
|
+
"Content-Type" => "multipart/byte-ranges; boundary=tcROXEYMdRNXRRYstW296yM1",
|
132
|
+
'ETag' => seq.etag,
|
133
|
+
)
|
134
|
+
|
135
|
+
output = StringIO.new
|
136
|
+
response.each do |segment, range|
|
137
|
+
output.write(segment[range])
|
138
|
+
end
|
139
|
+
|
140
|
+
reference = [
|
141
|
+
"--tcROXEYMdRNXRRYstW296yM1\r\n",
|
142
|
+
"Content-Type: binary/octet-stream\r\n",
|
143
|
+
"Content-Range: bytes 0-0/8\r\n",
|
144
|
+
"\r\n",
|
145
|
+
"y\r\n",
|
146
|
+
"--tcROXEYMdRNXRRYstW296yM1\r\n",
|
147
|
+
"Content-Type: binary/octet-stream\r\n",
|
148
|
+
"Content-Range: bytes 2-2/8\r\n",
|
149
|
+
"\r\n",
|
150
|
+
"s",
|
151
|
+
].join
|
152
|
+
expect(output.string).to eq(reference)
|
153
|
+
expect(output.string.bytesize).to eq(190)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'rack/test'
|
3
|
+
|
4
|
+
RSpec.describe 'IntervalResponse used in a Rack application' do
|
5
|
+
include Rack::Test::Methods
|
6
|
+
let(:segments) { @segments || [] }
|
7
|
+
let(:app) do
|
8
|
+
->(env) {
|
9
|
+
interval_sequence = IntervalResponse::Sequence.new(*segments)
|
10
|
+
response = IntervalResponse.new(interval_sequence, env['HTTP_RANGE'], env['HTTP_IF_RANGE'])
|
11
|
+
response.to_rack_response_triplet
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
def tempfile_with_random_bytes(n_bytes)
|
16
|
+
Tempfile.new('segment').tap do |tf|
|
17
|
+
tf.write(Random.new.bytes(n_bytes))
|
18
|
+
tf.flush
|
19
|
+
tf.rewind
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'returns a full response via the Rack adapter' do
|
24
|
+
@segments = ["Mary", " had", " a little", " lamb"]
|
25
|
+
get '/words'
|
26
|
+
expect(last_response).to be_ok
|
27
|
+
expect(last_response.body).to eq("Mary had a little lamb")
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'returns a Range response via the Rack adapter' do
|
31
|
+
@segments = ["Mary", " had", " a little", " lamb"]
|
32
|
+
get '/words', nil, 'HTTP_RANGE' => 'bytes=1-5'
|
33
|
+
expect(last_response.status).to eq(206)
|
34
|
+
expect(last_response.content_length).to eq(5)
|
35
|
+
expect(last_response.body).to eq("ary h")
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'serves from large-ish files' do
|
39
|
+
tiny = "tiny string"
|
40
|
+
file_a = tempfile_with_random_bytes(4 * 1024 * 1024)
|
41
|
+
file_b = tempfile_with_random_bytes(7 * 1024 * 1024)
|
42
|
+
|
43
|
+
@segments = [tiny, file_a, file_b]
|
44
|
+
get '/big', nil, 'HTTP_RANGE' => 'bytes=1-5'
|
45
|
+
expect(last_response.status).to eq(206)
|
46
|
+
expect(last_response.content_length).to eq(5)
|
47
|
+
|
48
|
+
get '/big', nil, 'HTTP_RANGE' => 'bytes=2-56898'
|
49
|
+
expect(last_response.status).to eq(206)
|
50
|
+
expect(last_response.content_length).to eq(56897)
|
51
|
+
end
|
52
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require "bundler/setup"
|
2
|
+
require "interval_response"
|
3
|
+
|
4
|
+
RSpec.configure do |config|
|
5
|
+
# Enable flags like --only-failures and --next-failure
|
6
|
+
config.example_status_persistence_file_path = ".rspec_status"
|
7
|
+
|
8
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
9
|
+
config.disable_monkey_patching!
|
10
|
+
|
11
|
+
config.expect_with :rspec do |c|
|
12
|
+
c.syntax = :expect
|
13
|
+
end
|
14
|
+
end
|
metadata
ADDED
@@ -0,0 +1,183 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: interval_response
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Julik Tarkhanov
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-09-25 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: measurometer
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.17'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.17'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '12'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '12'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: complexity_assert
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rack-test
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: wetransfer_style
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: Assemble HTTP responses from spliced sequences of payloads
|
126
|
+
email:
|
127
|
+
- me@julik.nl
|
128
|
+
executables: []
|
129
|
+
extensions: []
|
130
|
+
extra_rdoc_files: []
|
131
|
+
files:
|
132
|
+
- ".gitignore"
|
133
|
+
- ".rspec"
|
134
|
+
- ".rubocop.yml"
|
135
|
+
- ".travis.yml"
|
136
|
+
- Gemfile
|
137
|
+
- LICENSE.txt
|
138
|
+
- README.md
|
139
|
+
- Rakefile
|
140
|
+
- bin/console
|
141
|
+
- bin/setup
|
142
|
+
- interval_response.gemspec
|
143
|
+
- lib/interval_response.rb
|
144
|
+
- lib/interval_response/empty.rb
|
145
|
+
- lib/interval_response/full.rb
|
146
|
+
- lib/interval_response/invalid.rb
|
147
|
+
- lib/interval_response/lazy_file.rb
|
148
|
+
- lib/interval_response/multi.rb
|
149
|
+
- lib/interval_response/sequence.rb
|
150
|
+
- lib/interval_response/single.rb
|
151
|
+
- lib/interval_response/to_rack_response_triplet.rb
|
152
|
+
- lib/interval_response/version.rb
|
153
|
+
- spec/interval_response/sequence_spec.rb
|
154
|
+
- spec/interval_response_spec.rb
|
155
|
+
- spec/rack_stack_spec.rb
|
156
|
+
- spec/spec_helper.rb
|
157
|
+
homepage: https://github.com/WeTransfer/interval_response
|
158
|
+
licenses:
|
159
|
+
- MIT
|
160
|
+
metadata:
|
161
|
+
homepage_uri: https://github.com/WeTransfer/interval_response
|
162
|
+
source_code_uri: https://github.com/WeTransfer/interval_response
|
163
|
+
changelog_uri: https://github.com/WeTransfer/interval_response
|
164
|
+
post_install_message:
|
165
|
+
rdoc_options: []
|
166
|
+
require_paths:
|
167
|
+
- lib
|
168
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
169
|
+
requirements:
|
170
|
+
- - ">="
|
171
|
+
- !ruby/object:Gem::Version
|
172
|
+
version: '0'
|
173
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
174
|
+
requirements:
|
175
|
+
- - ">="
|
176
|
+
- !ruby/object:Gem::Version
|
177
|
+
version: '0'
|
178
|
+
requirements: []
|
179
|
+
rubygems_version: 3.0.6
|
180
|
+
signing_key:
|
181
|
+
specification_version: 4
|
182
|
+
summary: Assemble HTTP responses from spliced sequences of payloads
|
183
|
+
test_files: []
|