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.
@@ -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
@@ -0,0 +1,13 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ Gemfile.lock
11
+
12
+ # rspec failure tracking
13
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,3 @@
1
+ inherit_gem:
2
+ wetransfer_style: ruby/default.yml
3
+
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.4.1
7
+ before_install: gem install bundler -v 1.17.3
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in interval_response.gemspec
6
+ gemspec
@@ -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.
@@ -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).
@@ -0,0 +1,7 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+ require 'rubocop/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new(:rubocop)
7
+ task default: [:spec, :rubocop]
@@ -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__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,3 @@
1
+ module IntervalResponse
2
+ VERSION = "0.1.0"
3
+ 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
@@ -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: []