dis 2.0.0 → 2.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7c60e34e3af85dabdb54a45edefc614356194ece8f98fefcc85f9245efbba4d
4
- data.tar.gz: e9f450a4147cb7af0812f1755a0ecab8c0aea07c4c6cd425e1bb8fc0a9eb2476
3
+ metadata.gz: d4c3624aed32276988e39638489e694b4755991ec156d6e334013ff3e9f6dcae
4
+ data.tar.gz: 381f0c8bad7c0895f556d08040b2e83f514a8b52deca71db27740802048b5029
5
5
  SHA512:
6
- metadata.gz: ddc5e8de5bb8d432d0c16a96c5d3fdae656dc2c5051dc9f728b2b6e7021679a9c9ec16b6be36d63bdc838a38e6fd965a649d56267fba4ec644b9f57aae1a41e3
7
- data.tar.gz: c82397e472d58508bb953afb5480721ec6f23aa18e70da28b67ba2edcb92fba55315eeeb4879509079ae6883cbc2aab1caa13832f30a4408c76828b13a5d9e98
6
+ metadata.gz: 911c9655763e26056cd0a93e32b37a546983798d24271be047d4c720a8a8fcbc5db0152dafabf147cf3cc788e60e6cf7e53222afc414345b0ac483a470967af0
7
+ data.tar.gz: efe469600850c64269abfbd2fecdf221cb70d8a1de9200022891440cc432d37bde55b0535f88fed462e8fa51c8b90fdd492f0079f7333d28be9b55599487f19a
data/README.md CHANGED
@@ -125,8 +125,7 @@ document.with_data_file { |path| Vips::Image.new_from_file(path.to_s).avg }
125
125
 
126
126
  `open_data` returns an open file, valid until you close it — even if
127
127
  the content is deleted or evicted from a cache layer meanwhile. Use it
128
- when the reader outlives the current call stack, such as a response
129
- body handed to the web server.
128
+ when the reader outlives the current call stack.
130
129
 
131
130
  ```ruby
132
131
  document.open_data { |file| file.read }
@@ -134,6 +133,27 @@ document.open_data { |file| file.read }
134
133
  file = document.open_data # caller closes it
135
134
  ```
136
135
 
136
+ ### Sending data from a controller
137
+
138
+ Include `Dis::Controller` and use `send_dis_data` to stream a record's
139
+ data to the client. It works like `send_file`, but reads through an
140
+ open descriptor rather than a path, so the response is unaffected if
141
+ the content is evicted or deleted while it is being written.
142
+
143
+ ```ruby
144
+ class DocumentsController < ApplicationController
145
+ include Dis::Controller
146
+
147
+ def show
148
+ send_dis_data(Document.find(params[:id]), disposition: "inline")
149
+ end
150
+ end
151
+ ```
152
+
153
+ `filename` and `content_type` default to the record's own metadata.
154
+ The full set of options is `filename`, `content_type`, `disposition`
155
+ and `status`.
156
+
137
157
  ## Layers
138
158
 
139
159
  The underlying storage consists of one or more layers. Each layer
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dis
4
+ # = Dis Controller
5
+ #
6
+ # Adds {#send_dis_data} for serving stored data from a controller.
7
+ #
8
+ # class DocumentsController < ApplicationController
9
+ # include Dis::Controller
10
+ #
11
+ # def show
12
+ # send_dis_data(Document.find(params[:id]))
13
+ # end
14
+ # end
15
+ module Controller
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ include ActionController::DataStreaming
20
+ end
21
+
22
+ private
23
+
24
+ # Sends the record's data to the client. Works like +send_file+,
25
+ # but reads through an open descriptor rather than a path, so the
26
+ # response is unaffected if the content is evicted or deleted
27
+ # while it is being written.
28
+ #
29
+ # +filename+ and +content_type+ default to the record's own
30
+ # metadata.
31
+ #
32
+ # Responds with +206 Partial Content+ to a range request, or +416
33
+ # Range Not Satisfiable+ if the range lies beyond the data. Several
34
+ # ranges are sent as +multipart/byteranges+.
35
+ #
36
+ # @param record [Dis::Model] the record to send data from
37
+ # @param filename [String, nil] suggested filename
38
+ # @param content_type [String, nil] the content type
39
+ # @param disposition [String] +"attachment"+ or +"inline"+
40
+ # @param status [Integer] the HTTP status code
41
+ # @return [void]
42
+ # @raise [Dis::Errors::NotFoundError] if the data is not found
43
+ #
44
+ # @example
45
+ # send_dis_data(document, disposition: "inline")
46
+ def send_dis_data(record, filename: nil, content_type: nil,
47
+ disposition: "attachment", status: 200)
48
+ file = record.open_data
49
+ ranges = dis_byte_ranges(file.size, status)
50
+ response.headers["Accept-Ranges"] = "bytes"
51
+ return dis_unsatisfiable_range(file) if ranges && ranges.empty?
52
+
53
+ type = content_type || dis_metadata(record, :content_type)
54
+ dis_send_file_headers(record, filename, type, disposition)
55
+ dis_send_body(
56
+ Dis::ResponseBody.new(file, ranges:, content_type: type), file.size,
57
+ status
58
+ )
59
+ end
60
+
61
+ def dis_byte_ranges(size, status)
62
+ return unless status == 200
63
+ return unless dis_if_range_matches?
64
+
65
+ Rack::Utils.get_byte_ranges(request.get_header("HTTP_RANGE"), size)
66
+ end
67
+
68
+ def dis_if_range_matches?
69
+ if_range = request.get_header("HTTP_IF_RANGE")
70
+ return true if if_range.blank?
71
+
72
+ [response.etag, response.headers["Last-Modified"]].any? do |validator|
73
+ validator.present? && if_range == validator
74
+ end
75
+ end
76
+
77
+ def dis_unsatisfiable_range(file)
78
+ response.headers["Content-Range"] = "bytes */#{file.size}"
79
+ file.close
80
+ head :range_not_satisfiable
81
+ end
82
+
83
+ def dis_send_file_headers(record, filename, type, disposition)
84
+ send_file_headers!(
85
+ { filename: filename || dis_metadata(record, :filename),
86
+ type:, disposition: }.compact
87
+ )
88
+ end
89
+
90
+ def dis_send_body(body, size, status)
91
+ self.status = body.ranges.any? ? :partial_content : status
92
+ dis_partial_headers(body, size)
93
+ response.headers["Content-Length"] = body.length.to_s
94
+ self.response_body = body
95
+ end
96
+
97
+ def dis_partial_headers(body, size)
98
+ if body.multipart?
99
+ self.content_type =
100
+ "multipart/byteranges; boundary=#{body.boundary}"
101
+ elsif body.range
102
+ response.headers["Content-Range"] =
103
+ "bytes #{body.range.begin}-#{body.range.end}/#{size}"
104
+ end
105
+ end
106
+
107
+ def dis_metadata(record, name)
108
+ attribute = record.class.dis_attributes[name]
109
+ record[attribute] if attribute
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Dis
6
+ # = Dis Response Body
7
+ #
8
+ # Rack body that streams an open file and closes it once the
9
+ # response has been sent. The file may already be unlinked, so it is
10
+ # read through the open descriptor and never by path.
11
+ #
12
+ # Streams only the given ranges when any are given, as a
13
+ # +multipart/byteranges+ payload if there is more than one.
14
+ class ResponseBody
15
+ CHUNK_SIZE = 16_384
16
+
17
+ delegate :closed?, to: :@file
18
+
19
+ # @return [Array<Range>] the ranges being streamed
20
+ attr_reader :ranges
21
+
22
+ # @return [String, nil] the multipart boundary, when multipart
23
+ attr_reader :boundary
24
+
25
+ # @param file [File] an open, readable file
26
+ # @param ranges [Array<Range>, nil] byte ranges, or nil for all of it
27
+ # @param content_type [String, nil] content type of the parts
28
+ def initialize(file, ranges: nil, content_type: nil)
29
+ @file = file
30
+ @ranges = Array(ranges)
31
+ @content_type = content_type
32
+ @boundary = SecureRandom.hex(16) if multipart?
33
+ end
34
+
35
+ # @return [Boolean] whether more than one range is being streamed
36
+ def multipart?
37
+ ranges.length > 1
38
+ end
39
+
40
+ # @return [Range, nil] the range being streamed, unless multipart
41
+ def range
42
+ ranges.first unless multipart?
43
+ end
44
+
45
+ # Returns the number of bytes that will be written.
46
+ #
47
+ # @return [Integer]
48
+ def length
49
+ return @file.size if ranges.empty?
50
+ return multipart_length if multipart?
51
+
52
+ range.size
53
+ end
54
+
55
+ # Returns the contents as a binary string.
56
+ #
57
+ # @return [String]
58
+ def body
59
+ (+"").b.tap { |out| each { |chunk| out << chunk } }
60
+ end
61
+
62
+ # Yields the contents in chunks.
63
+ #
64
+ # @yieldparam chunk [String] a chunk of the contents
65
+ # @return [void]
66
+ def each(&)
67
+ return stream(0, @file.size, &) if ranges.empty?
68
+ return stream(range.begin, range.size, &) unless multipart?
69
+
70
+ each_part(&)
71
+ end
72
+
73
+ # Closes the underlying file.
74
+ #
75
+ # @return [void]
76
+ def close
77
+ @file.close unless @file.closed?
78
+ end
79
+
80
+ private
81
+
82
+ def each_part(&)
83
+ ranges.each do |range|
84
+ yield heading(range)
85
+ stream(range.begin, range.size, &)
86
+ end
87
+ yield terminator
88
+ end
89
+
90
+ def stream(offset, remaining)
91
+ @file.seek(offset)
92
+ while remaining.positive? &&
93
+ (chunk = @file.read([CHUNK_SIZE, remaining].min))
94
+ remaining -= chunk.bytesize
95
+ yield chunk
96
+ end
97
+ end
98
+
99
+ def multipart_length
100
+ ranges.sum { |range| heading(range).bytesize + range.size } +
101
+ terminator.bytesize
102
+ end
103
+
104
+ def heading(range)
105
+ "\r\n--#{boundary}\r\n" \
106
+ "Content-Type: #{@content_type}\r\n" \
107
+ "Content-Range: bytes #{range.begin}-#{range.end}/#{@file.size}\r\n" \
108
+ "\r\n"
109
+ end
110
+
111
+ def terminator
112
+ "\r\n--#{boundary}--\r\n"
113
+ end
114
+ end
115
+ end
data/lib/dis/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dis
4
- VERSION = "2.0.0"
4
+ VERSION = "2.2.0"
5
5
  end
data/lib/dis.rb CHANGED
@@ -6,6 +6,7 @@ require "fog/core"
6
6
  require "fog/local"
7
7
  require "active_job"
8
8
  require "concurrent"
9
+ require "dis/controller"
9
10
  require "dis/engine"
10
11
  require "dis/errors"
11
12
  require "dis/jobs"
@@ -13,6 +14,7 @@ require "dis/logging"
13
14
  require "dis/layer"
14
15
  require "dis/layers"
15
16
  require "dis/model"
17
+ require "dis/response_body"
16
18
  require "dis/storage"
17
19
  require "dis/validations"
18
20
 
@@ -27,6 +29,7 @@ require "dis/validations"
27
29
  # configure layers via {Dis::Storage.layers}.
28
30
  #
29
31
  # @see Dis::Model
32
+ # @see Dis::Controller
30
33
  # @see Dis::Storage
31
34
  # @see Dis::Layer
32
35
  module Dis
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dis
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Inge Jørgensen
@@ -71,6 +71,20 @@ dependencies:
71
71
  - - ">="
72
72
  - !ruby/object:Gem::Version
73
73
  version: '0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rack
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 2.2.4
81
+ type: :runtime
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 2.2.4
74
88
  - !ruby/object:Gem::Dependency
75
89
  name: rails
76
90
  requirement: !ruby/object:Gem::Requirement
@@ -110,6 +124,7 @@ files:
110
124
  - LICENSE
111
125
  - README.md
112
126
  - lib/dis.rb
127
+ - lib/dis/controller.rb
113
128
  - lib/dis/engine.rb
114
129
  - lib/dis/errors.rb
115
130
  - lib/dis/jobs.rb
@@ -123,6 +138,7 @@ files:
123
138
  - lib/dis/model.rb
124
139
  - lib/dis/model/class_methods.rb
125
140
  - lib/dis/model/data.rb
141
+ - lib/dis/response_body.rb
126
142
  - lib/dis/storage.rb
127
143
  - lib/dis/validations.rb
128
144
  - lib/dis/validations/data_presence.rb