servicestack 0.1.0 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d4c81df4c0df47d185c8f942f3da2e61983d149eb74ac128e03005eb9af30b3
4
- data.tar.gz: 15f64c2c49f10e6803ff8a722edb1faeb20c670d99350103848390ab291239a9
3
+ metadata.gz: 991af3e1951fbd5467476753e01f05db5992e7399cb0ac3ca0acfaf909056b8e
4
+ data.tar.gz: 0e2dcc59cb68a58911ba105d82ff245aff0001a5e41870324c5c2cd12d31e2f9
5
5
  SHA512:
6
- metadata.gz: 204756127fbd7b57d41c521f7f65824ba804c1bbd5c3487fdb9b4561eaba3c91847fe525c96e59092c2058f178948c44e105eb3a2bd21480ce662d719befa220
7
- data.tar.gz: 6d29f98815d56c1dd66eb7e8982226b68288d20799569954b83a55c098aa9a2f5e2cdd5629c8d1bf8f6aa09313649b805ef701c20490a0afa270c8221c0889b8
6
+ metadata.gz: f4c822aad83ce16504e03018f13112a36b3c3added39d5f3d52c3dd5234c9d20bcadf03e8dad3eb8f65c671359c5b7d50e53277ae46304ac8fb68f186bd08c7a
7
+ data.tar.gz: 84eadaa862a26af3237499a7d07682243b61ed5b97314ed3f498002e94c2360c938186ff90b423e7a6b4e3dbf404b5cf1323b5b4e318804e7fbdc48f7ac29b0d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.1]
6
+
7
+ ### Added
8
+
9
+ - `post_file_with_request` and `post_files_with_request` for uploading files with
10
+ a Request DTO as a `multipart/form-data` Request, incl. an `UploadFile` that
11
+ accepts file contents as a String or any IO
12
+ - `post_files_with_request_url` for uploading files to a custom URL
13
+
5
14
  ## [0.1.0]
6
15
 
7
16
  ### Added
@@ -17,4 +26,5 @@ All notable changes to this project will be documented in this file.
17
26
  - Built-in ServiceStack DTOs referenced by generated DTOs (`ResponseStatus`,
18
27
  `QueryBase`, `QueryResponse`, `Authenticate`, ...)
19
28
 
29
+ [0.1.1]: https://github.com/ServiceStack/servicestack-ruby/releases/tag/v0.1.1
20
30
  [0.1.0]: https://github.com/ServiceStack/servicestack-ruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -174,6 +174,22 @@ Or send a Request to a one-way endpoint that ignores its Response:
174
174
  client.publish(Hello.new(name: 'World'))
175
175
  ```
176
176
 
177
+ ### Uploading Files
178
+
179
+ Use `post_file_with_request` to upload a file with an API Request, whose contents
180
+ can be a String or any IO:
181
+
182
+ ```ruby
183
+ res = File.open('photo.png', 'rb') do |file|
184
+ client.post_file_with_request(UploadPhoto.new(album: 'Holiday'),
185
+ ServiceStack::UploadFile.new(field_name: 'file', file_name: 'photo.png',
186
+ content_type: 'image/png', stream: file))
187
+ end
188
+ ```
189
+
190
+ The Request DTO's populated properties are sent as form fields alongside the file.
191
+ To upload multiple files use `post_files_with_request`.
192
+
177
193
  ### Custom URLs
178
194
 
179
195
  ```ruby
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'net/http'
5
+ require 'securerandom'
5
6
  require 'uri'
6
7
  require_relative 'dto'
7
8
  require_relative 'types'
@@ -19,6 +20,29 @@ module ServiceStack
19
20
  HEAD = 'HEAD'
20
21
  end
21
22
 
23
+ # A file uploaded in a multipart/form-data Request.
24
+ #
25
+ # Its contents can be supplied as a String or any IO that responds to `read`,
26
+ # e.g. a File or StringIO.
27
+ class UploadFile
28
+ attr_accessor :field_name, :file_name, :content_type, :stream
29
+
30
+ def initialize(field_name: 'file', file_name: nil, content_type: nil, stream: nil)
31
+ @field_name = field_name
32
+ @file_name = file_name
33
+ @content_type = content_type
34
+ @stream = stream
35
+ end
36
+
37
+ # The file contents to upload.
38
+ def contents
39
+ return @stream unless @stream.respond_to?(:read)
40
+
41
+ @stream.binmode if @stream.respond_to?(:binmode)
42
+ @stream.read
43
+ end
44
+ end
45
+
22
46
  # Either the typed Response of a successful API Request or the structured
23
47
  # ResponseStatus error of a failed one, returned by `api`.
24
48
  class ApiResult
@@ -203,6 +227,42 @@ module ServiceStack
203
227
  execute(method, to_absolute_url(path), body, args: args)
204
228
  end
205
229
 
230
+ # ── File Uploads ──
231
+
232
+ # Uploads a file with a Request DTO as a multipart/form-data Request,
233
+ # returning its typed Response, e.g:
234
+ #
235
+ # File.open('photo.png', 'rb') do |file|
236
+ # client.post_file_with_request(UploadPhoto.new(album: 'Holiday'),
237
+ # ServiceStack::UploadFile.new(field_name: 'file', file_name: 'photo.png',
238
+ # content_type: 'image/png', stream: file))
239
+ # end
240
+ def post_file_with_request(request, file, args: nil)
241
+ post_files_with_request(request, [file], args: args)
242
+ end
243
+
244
+ # Uploads multiple files with a Request DTO as a multipart/form-data Request.
245
+ def post_files_with_request(request, files, args: nil)
246
+ url = create_url_from_dto(HttpMethods::POST, request)
247
+ post_files_with_request_url(url, request, files, response_as: resolve_response_type(request), args: args)
248
+ end
249
+
250
+ # Uploads files with a Request DTO to a custom relative path or absolute URL.
251
+ def post_files_with_request_url(path, request, files, response_as: nil, args: nil)
252
+ boundary = "----ServiceStackFormBoundary#{SecureRandom.hex(12)}"
253
+ body = multipart_body(boundary, request, files)
254
+
255
+ json = execute(HttpMethods::POST, to_absolute_url(path), body, args: args,
256
+ content_type: "multipart/form-data; boundary=#{boundary}")
257
+ return nil if response_as.nil?
258
+ return json if response_as == String
259
+
260
+ parsed = json.to_s.strip.empty? ? {} : JSON.parse(json)
261
+ return parsed unless response_as.respond_to?(:from_hash)
262
+
263
+ response_as.from_hash(parsed)
264
+ end
265
+
206
266
  # Converts a relative path into an absolute URL of this client.
207
267
  def to_absolute_url(path_or_url)
208
268
  return path_or_url if path_or_url.to_s.start_with?('http://', 'https://')
@@ -232,11 +292,11 @@ module ServiceStack
232
292
  response_type.from_hash(parsed)
233
293
  end
234
294
 
235
- def execute(method, url, body, args: nil, retry_on_auth_failure: true)
295
+ def execute(method, url, body, args: nil, retry_on_auth_failure: true, content_type: nil)
236
296
  url = append_query_string(url, args) if args && !args.empty?
237
297
 
238
298
  uri = URI.parse(url)
239
- request = new_http_request(method, uri, body)
299
+ request = new_http_request(method, uri, body, content_type: content_type)
240
300
 
241
301
  @request_filter&.call(request)
242
302
  self.class.global_request_filter&.call(request)
@@ -250,7 +310,7 @@ module ServiceStack
250
310
 
251
311
  status_code = response.code.to_i
252
312
  if status_code == 401 && retry_on_auth_failure && refresh_access_token
253
- return execute(method, url, body, retry_on_auth_failure: false)
313
+ return execute(method, url, body, retry_on_auth_failure: false, content_type: content_type)
254
314
  end
255
315
 
256
316
  # Redirects aren't followed, e.g. Services that redirect to a HTML sign in
@@ -264,7 +324,7 @@ module ServiceStack
264
324
  raise WebServiceException.new(e.message, inner_exception: e)
265
325
  end
266
326
 
267
- def new_http_request(method, uri, body)
327
+ def new_http_request(method, uri, body, content_type: nil)
268
328
  request_class = case method.to_s.upcase
269
329
  when HttpMethods::GET then Net::HTTP::Get
270
330
  when HttpMethods::POST then Net::HTTP::Post
@@ -288,7 +348,7 @@ module ServiceStack
288
348
  request['Cookie'] = @cookies.map { |k, v| "#{k}=#{v}" }.join('; ') unless @cookies.empty?
289
349
 
290
350
  if body && has_request_body?(method)
291
- request['Content-Type'] ||= MIME_TYPE_JSON
351
+ request['Content-Type'] = content_type || request['Content-Type'] || MIME_TYPE_JSON
292
352
  request.body = body.is_a?(String) ? body : JSON.generate(to_hash(body))
293
353
  end
294
354
 
@@ -402,6 +462,34 @@ module ServiceStack
402
462
  dto
403
463
  end
404
464
 
465
+ # Builds the multipart/form-data body of a file upload Request, sending the
466
+ # populated Request DTO properties as form fields
467
+ def multipart_body(boundary, request, files)
468
+ body = +''
469
+
470
+ to_hash(request).each do |name, value|
471
+ next if value.nil?
472
+
473
+ body << "--#{boundary}\r\n"
474
+ body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
475
+ body << "#{qs_value(value)}\r\n"
476
+ end
477
+
478
+ files.each do |file|
479
+ field_name = file.field_name.to_s.empty? ? 'file' : file.field_name
480
+ file_name = file.file_name.to_s.empty? ? 'file' : file.file_name
481
+
482
+ body << "--#{boundary}\r\n"
483
+ body << "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{file_name}\"\r\n"
484
+ body << "Content-Type: #{file.content_type || 'application/octet-stream'}\r\n\r\n"
485
+ body << file.contents.to_s.dup.force_encoding(Encoding::BINARY)
486
+ body << "\r\n"
487
+ end
488
+
489
+ body << "--#{boundary}--\r\n"
490
+ body.force_encoding(Encoding::BINARY)
491
+ end
492
+
405
493
  def has_request_body?(method)
406
494
  !%w[GET DELETE HEAD OPTIONS].include?(method.to_s.upcase)
407
495
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ServiceStack
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: servicestack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ServiceStack