interval_response 0.1.0
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 +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: []
|