tusc 0.6.1

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,26 @@
1
+ require_relative 'responsorial'
2
+
3
+ # Parses the response from a CreationRequest
4
+ #
5
+ # Surfacing important info:
6
+ # - location -> upload_uri
7
+ class TusClient::CreationResponse
8
+ include TusClient::Responsorial
9
+ def initialize(response)
10
+ @response = response
11
+ end
12
+
13
+ def location
14
+ raw.header && raw.header['Location']
15
+ end
16
+
17
+ def success?
18
+ result = status_code == 201
19
+ result &= (location =~ URI::ABS_URI) unless location.blank?
20
+ result
21
+ end
22
+
23
+ def upload_uri
24
+ URI.parse(location) unless location.blank?
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ require_relative '../http_service'
2
+ require_relative 'offset_response'
3
+
4
+ # Asks tus server for appriopriate offset
5
+ # for specific file, via upload_url
6
+ class TusClient::OffsetRequest
7
+ attr_reader :extra_headers, :upload_uri
8
+ def initialize(upload_url:, extra_headers: {})
9
+ upload_uri = upload_url.is_a?(URI) ? upload_url : URI.parse(upload_url)
10
+ unless upload_uri.is_a?(URI::HTTP) && !upload_uri.host.nil?
11
+ raise URI::InvalidURIError, "Could NOT parse host from #{upload_url.inspect}"
12
+ end
13
+
14
+ @upload_uri = upload_uri
15
+ @extra_headers = extra_headers
16
+ end
17
+
18
+ def headers
19
+ {
20
+ 'Tus-Resumable' => supported_tus_resumable_version
21
+ }.merge(extra_headers)
22
+ end
23
+
24
+ def logger
25
+ @logger ||= TusClient.logger
26
+ end
27
+
28
+ # Retrieves offset via a HEAD request to the tus server
29
+ # Returns the offset (in a OffsetResponse)
30
+ def perform
31
+ response = TusClient::HttpService.head(
32
+ uri: upload_uri,
33
+ headers: headers,
34
+ logger: logger
35
+ )
36
+ TusClient::OffsetResponse.new(response)
37
+ end
38
+
39
+ def supported_tus_resumable_version
40
+ '1.0.0'
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ require_relative 'responsorial'
2
+
3
+ # Parses the response from an OffsetRequest
4
+ #
5
+ # Surfacing important info:
6
+ # - offset
7
+ class TusClient::OffsetResponse
8
+ include TusClient::Responsorial
9
+ def initialize(response)
10
+ @response = response
11
+ end
12
+
13
+ def offset
14
+ raw.header['Upload-Offset'].to_i # nil.to_i == 0
15
+ end
16
+
17
+ def success?
18
+ status_code == 204 && (offset >= 0)
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ # shared interface for Response objects
2
+ module TusClient::Responsorial
3
+ def body
4
+ body = raw.body
5
+ body.blank? ? '' : JSON.parse(body)
6
+ end
7
+
8
+ def raw
9
+ @response
10
+ end
11
+
12
+ def status_code
13
+ raw.code.to_i
14
+ end
15
+ end
@@ -0,0 +1,64 @@
1
+ require_relative '../http_service'
2
+ require_relative 'upload_response'
3
+
4
+ module TusClient
5
+ # Asks tus server to upload a chunk of a file, from a specific offset
6
+ class UploadRequest
7
+ attr_reader :chunk_to_upload, :extra_headers, :file_size, :offset, :upload_uri
8
+
9
+ def initialize(upload_uri:, chunk_to_upload:, offset:, file_size:, extra_headers: {})
10
+ upload_uri = upload_uri.is_a?(URI) ? upload_uri : URI.parse(upload_uri)
11
+ unless upload_uri.is_a?(URI::HTTP) && !upload_uri.host.nil?
12
+ raise URI::InvalidURIError, "Could NOT parse host from #{upload_uri.inspect}"
13
+ end
14
+
15
+ unless chunk_to_upload.is_a?(String)
16
+ raise ArgumentError, "chunk_to_upload must be a String, found (#{chunk_to_upload}:#{chunk_to_upload.class.name})"
17
+ end
18
+
19
+ unless file_size.is_a?(Integer)
20
+ raise ArgumentError, "file_size must be an Integer, found (#{file_size}:#{file_size.class.name})"
21
+ end
22
+
23
+ unless offset.is_a?(Integer)
24
+ raise ArgumentError, "offset must be an Integer, found (#{offset}:#{offset.class.name})"
25
+ end
26
+
27
+ @chunk_to_upload = chunk_to_upload
28
+ @extra_headers = extra_headers
29
+ @file_size = file_size
30
+ @offset = offset
31
+ @upload_uri = upload_uri
32
+ end
33
+
34
+ def default_content_type
35
+ 'application/offset+octet-stream'
36
+ end
37
+
38
+ def headers
39
+ {
40
+ 'Content-Type' => default_content_type,
41
+ 'Tus-Resumable' => supported_tus_resumable_version,
42
+ 'Upload-Offset' => offset.to_s
43
+ }.merge(extra_headers)
44
+ end
45
+
46
+ def logger
47
+ @logger ||= TusClient.logger
48
+ end
49
+
50
+ def perform
51
+ response = TusClient::HttpService.patch(
52
+ uri: upload_uri,
53
+ headers: headers,
54
+ body: chunk_to_upload,
55
+ logger: logger
56
+ )
57
+ TusClient::UploadResponse.new(response, file_size)
58
+ end
59
+
60
+ def supported_tus_resumable_version
61
+ '1.0.0'
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,37 @@
1
+ require_relative 'responsorial'
2
+
3
+ # Parses the response from an UploadRequest
4
+ #
5
+ # Surfacing important info:
6
+ # - complete?
7
+ # - incomplete?
8
+ # - offset
9
+ # - success?
10
+ class TusClient::UploadResponse
11
+ include TusClient::Responsorial
12
+ attr_reader :file_size
13
+ def initialize(response, file_size)
14
+ @response = response
15
+ @file_size = file_size
16
+ end
17
+
18
+ def complete?
19
+ offset >= file_size
20
+ end
21
+
22
+ def incomplete?
23
+ offset < file_size
24
+ end
25
+
26
+ def offset
27
+ raw.header['Upload-Offset'].to_i
28
+ end
29
+
30
+ def successful_status_codes
31
+ [200, 204]
32
+ end
33
+
34
+ def success?
35
+ successful_status_codes.include?(status_code)
36
+ end
37
+ end
@@ -0,0 +1,141 @@
1
+ require 'net/http'
2
+ require_relative '../core_ext/string/truncate'
3
+ require_relative 'upload_request'
4
+ require_relative 'upload_response'
5
+ require_relative 'offset_request'
6
+
7
+ # Uploads a file to a tus server
8
+ class TusClient::Uploader
9
+ attr_reader :extra_headers, :io, :upload_url
10
+
11
+ def self.chunk_size
12
+ 10 * TusClient::MEGABYTE
13
+ end
14
+
15
+ def self.default_content_type
16
+ 'application/offset+octet-stream'
17
+ end
18
+
19
+ def self.from_file_path(file_path:, upload_url:, extra_headers: {})
20
+ raise ArgumentError, "file_path is required (#{file_path})" if file_path.blank?
21
+
22
+ file_pathname = Pathname.new(file_path)
23
+ raise ArgumentError, "Passed file does NOT exist: #{file_pathname.inspect}" unless file_pathname.exist?
24
+
25
+ new(io: file_pathname.open, upload_url: upload_url, extra_headers: extra_headers)
26
+ end
27
+
28
+ def initialize(io:, upload_url:, extra_headers: {})
29
+ # fail ArgumentError.new("io must be an IO object") unless io.is_a?(IO) || io.is_a?(StringIO)
30
+ %i[size read close].each do |required_method|
31
+ raise ArgumentError, "io must respond to ##{required_method}" unless io.respond_to?(required_method)
32
+ end
33
+ raise ArgumentError, "upload_url is required (#{upload_url})" if upload_url.blank?
34
+ raise ArgumentError, "upload_url must be a valid url (#{upload_url})" unless upload_url =~ URI::ABS_URI
35
+
36
+ @io = io
37
+ @upload_url = upload_url
38
+ @extra_headers = extra_headers
39
+ end
40
+
41
+ # Optionally, if the client wants to delete an upload because it won’t be needed anymore,
42
+ # a DELETE request can be sent to the upload URL.
43
+ # After this, the upload can be cleaned up by the server and resuming the upload is not possible anymore.
44
+ def cancel_upload
45
+ delete upload_url
46
+ end
47
+
48
+ def chunk_size
49
+ @chunk_size ||= TusClient::Uploader.chunk_size
50
+ end
51
+
52
+ def content_type
53
+ # We used to detect content type, using MimeMagic,
54
+ # but the videos only uploaded if we used the default (octet-stream)
55
+ # So... we just use the default
56
+ default_content_type
57
+ end
58
+
59
+ def default_content_type
60
+ self.class.default_content_type
61
+ end
62
+
63
+ def headers
64
+ {
65
+ 'Content-Type' => content_type,
66
+ 'Tus-Resumable' => tus_resumable_version,
67
+ 'Upload-Offset' => 0.to_s
68
+ }.merge(extra_headers)
69
+ end
70
+
71
+ def logger
72
+ @logger ||= TusClient.logger
73
+ end
74
+
75
+ def offset_requester
76
+ @offset_requester ||= TusClient::OffsetRequest.new(upload_url: upload_uri, extra_headers: extra_headers)
77
+ end
78
+
79
+ # Performs the upload, one chunk at a time
80
+ # Starts by asking for the currnet offset
81
+ # Each follow-on request, returns the current offset.
82
+ def perform
83
+ # TODO: asynch?
84
+ logger.debug { 'Starting upload...' }
85
+
86
+ offset = retrieve_offset
87
+ begin
88
+ logger.debug { ['Reading io...', { size: size, offset: offset, chunk_size: chunk_size }] }
89
+ io.pos = offset
90
+ chunk = io.read(chunk_size)
91
+ upload_response = push_chunk(chunk, offset)
92
+ unless upload_response.success?
93
+ raise "Issue uploading file: #{{ code: upload_response.status_code, message: upload_response.body }}."
94
+ end
95
+
96
+ offset = upload_response.offset
97
+ end while upload_response.success? && upload_response.incomplete?
98
+
99
+ io.close
100
+ upload_response
101
+ end
102
+
103
+ def push_chunk(chunk, offset)
104
+ push_chunk_via_upload_request(chunk, offset)
105
+ end
106
+
107
+ def push_chunk_via_upload_request(chunk, offset)
108
+ upload_request = TusClient::UploadRequest.new(
109
+ upload_uri: upload_url,
110
+ chunk_to_upload: chunk,
111
+ offset: offset,
112
+ file_size: size,
113
+ extra_headers: extra_headers
114
+ )
115
+ upload_request.perform
116
+ end
117
+
118
+ def retrieve_offset
119
+ offset_requester.perform.offset
120
+ end
121
+
122
+ def size
123
+ @size ||= io.size
124
+ end
125
+
126
+ def tus_resumable_version
127
+ '1.0.0'
128
+ end
129
+
130
+ def upload_incomplete
131
+ retrieve_offset < size
132
+ end
133
+
134
+ def upload_uri
135
+ @upload_uri ||= URI.parse(upload_url)
136
+ end
137
+
138
+ def video_url
139
+ upload_response.fetch('uri')
140
+ end
141
+ end
@@ -0,0 +1,3 @@
1
+ module TusClient
2
+ VERSION = '0.6.1'.freeze
3
+ end
@@ -0,0 +1,48 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'tusc/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'tusc'
7
+ spec.version = TusClient::VERSION
8
+ spec.authors = ['Matt Scilipoti']
9
+ spec.email = ['matt@scilipoti.name']
10
+
11
+ spec.summary = 'Ruby client for [tus](https://tus.io/): open protocol for resumable file uploads'
12
+ # spec.description = %q{TODO: Write a longer description or delete this line.}
13
+ spec.homepage = 'https://github.com/mattscilipoti/tusc.rb'
14
+ spec.license = 'MIT'
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ if spec.respond_to?(:metadata)
19
+ # spec.metadata['allowed_push_host'] = 'https://rubygems.org/???'
20
+
21
+ spec.metadata['homepage_uri'] = spec.homepage
22
+ spec.metadata['source_code_uri'] = 'https://github.com/mattscilipoti/tusc.rb'
23
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
24
+ else
25
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
26
+ 'public gem pushes.'
27
+ end
28
+
29
+ # Specify which files should be added to the gem when it is released.
30
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
31
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
32
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
33
+ end
34
+ spec.bindir = 'exe'
35
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ['lib']
37
+
38
+ spec.add_dependency 'ougai', '~> 1.8' # for structured logging, child log per file
39
+
40
+ spec.add_development_dependency 'amazing_print', '~> 1.2'
41
+ spec.add_development_dependency 'bundler', '~> 1.17'
42
+ spec.add_development_dependency 'pry-byebug', '~> 3.9'
43
+ spec.add_development_dependency 'rake', '~> 13.0'
44
+ spec.add_development_dependency 'rspec', '~> 3.0'
45
+ spec.add_development_dependency 'tus-server', '~> 2.3'
46
+ spec.add_development_dependency 'webmock', '~> 3.8'
47
+ spec.add_development_dependency 'yard', '~> 0.9'
48
+ end
metadata ADDED
@@ -0,0 +1,202 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tusc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.1
5
+ platform: ruby
6
+ authors:
7
+ - Matt Scilipoti
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2020-08-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ougai
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: amazing_print
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.2'
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: pry-byebug
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.9'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.9'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: tus-server
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '2.3'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '2.3'
111
+ - !ruby/object:Gem::Dependency
112
+ name: webmock
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.8'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.8'
125
+ - !ruby/object:Gem::Dependency
126
+ name: yard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.9'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.9'
139
+ description:
140
+ email:
141
+ - matt@scilipoti.name
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - ".editorconfig"
147
+ - ".gitignore"
148
+ - ".tool-versions"
149
+ - ".travis.yml"
150
+ - CHANGELOG.md
151
+ - CODE_OF_CONDUCT.md
152
+ - Gemfile
153
+ - Gemfile.lock
154
+ - LICENSE.txt
155
+ - README.md
156
+ - Rakefile
157
+ - bin/console
158
+ - bin/rackup
159
+ - bin/rspec
160
+ - bin/setup
161
+ - config.ru
162
+ - lib/core_ext/object/blank.rb
163
+ - lib/core_ext/string/truncate.rb
164
+ - lib/http_service.rb
165
+ - lib/tusc.rb
166
+ - lib/tusc/creation_request.rb
167
+ - lib/tusc/creation_response.rb
168
+ - lib/tusc/offset_request.rb
169
+ - lib/tusc/offset_response.rb
170
+ - lib/tusc/responsorial.rb
171
+ - lib/tusc/upload_request.rb
172
+ - lib/tusc/upload_response.rb
173
+ - lib/tusc/uploader.rb
174
+ - lib/tusc/version.rb
175
+ - tusc.gemspec
176
+ homepage: https://github.com/mattscilipoti/tusc.rb
177
+ licenses:
178
+ - MIT
179
+ metadata:
180
+ homepage_uri: https://github.com/mattscilipoti/tusc.rb
181
+ source_code_uri: https://github.com/mattscilipoti/tusc.rb
182
+ post_install_message:
183
+ rdoc_options: []
184
+ require_paths:
185
+ - lib
186
+ required_ruby_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ required_rubygems_version: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: '0'
196
+ requirements: []
197
+ rubygems_version: 3.1.2
198
+ signing_key:
199
+ specification_version: 4
200
+ summary: 'Ruby client for [tus](https://tus.io/): open protocol for resumable file
201
+ uploads'
202
+ test_files: []