servicestack 0.1.0 → 0.1.2

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: 422e88b02d244852ff3d8941817d5fc6c22c58c2a7698458b4d2d96df75af2bf
4
+ data.tar.gz: 9b2941734b9154d258de44f156e2e66b600f6042825535b017cf81699bcaf6da
5
5
  SHA512:
6
- metadata.gz: 204756127fbd7b57d41c521f7f65824ba804c1bbd5c3487fdb9b4561eaba3c91847fe525c96e59092c2058f178948c44e105eb3a2bd21480ce662d719befa220
7
- data.tar.gz: 6d29f98815d56c1dd66eb7e8982226b68288d20799569954b83a55c098aa9a2f5e2cdd5629c8d1bf8f6aa09313649b805ef701c20490a0afa270c8221c0889b8
6
+ metadata.gz: '09d3557ceda3563802cedbdcf7feddcadb4ccf5251994c00ecdadefa5417fab664429502b7a94379c53e4136f76e2337075d76f07a22cb015c990cbffb59ea7d'
7
+ data.tar.gz: 8abcf83d19d0530c7873a4a6993fc11390305379657f8db395e7a60a07e2faede986dc807e585a68bfd399aeb2b4b6829bbcfa08b42347568ecd304e14a19c43
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.2]
6
+
7
+ ### Added
8
+
9
+ - `on_authentication_required` callback for re-authenticating a client before
10
+ automatically retrying a Request that returned 401 Unauthorized
11
+
12
+ ## [0.1.1]
13
+
14
+ ### Added
15
+
16
+ - `post_file_with_request` and `post_files_with_request` for uploading files with
17
+ a Request DTO as a `multipart/form-data` Request, incl. an `UploadFile` that
18
+ accepts file contents as a String or any IO
19
+ - `post_files_with_request_url` for uploading files to a custom URL
20
+
5
21
  ## [0.1.0]
6
22
 
7
23
  ### Added
@@ -17,4 +33,6 @@ All notable changes to this project will be documented in this file.
17
33
  - Built-in ServiceStack DTOs referenced by generated DTOs (`ResponseStatus`,
18
34
  `QueryBase`, `QueryResponse`, `Authenticate`, ...)
19
35
 
36
+ [0.1.2]: https://github.com/ServiceStack/servicestack-ruby/releases/tag/v0.1.2
37
+ [0.1.1]: https://github.com/ServiceStack/servicestack-ruby/releases/tag/v0.1.1
20
38
  [0.1.0]: https://github.com/ServiceStack/servicestack-ruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -162,6 +162,25 @@ refreshed and the failed Request retried:
162
162
  client.set_refresh_token(refresh_token)
163
163
  ```
164
164
 
165
+ ### Transparently handle 401 Unauthorized Responses
166
+
167
+ If the Server returns a 401 Unauthorized Response either because the client was
168
+ unauthenticated or its Bearer Token or API Key had expired, use the
169
+ `on_authentication_required` callback to re-configure the client before the
170
+ original Request is automatically retried:
171
+
172
+ ```ruby
173
+ client.on_authentication_required = lambda { |c|
174
+ c.authenticate(user_name, password)
175
+ }
176
+
177
+ # Automatically retries Requests returning 401 Responses
178
+ res = client.send(Secured.new)
179
+ ```
180
+
181
+ A configured Refresh Token takes precedence over the callback, which is only
182
+ used when no Refresh Token is set or refreshing it failed.
183
+
165
184
  ### Batched Requests
166
185
 
167
186
  ```ruby
@@ -174,6 +193,22 @@ Or send a Request to a one-way endpoint that ignores its Response:
174
193
  client.publish(Hello.new(name: 'World'))
175
194
  ```
176
195
 
196
+ ### Uploading Files
197
+
198
+ Use `post_file_with_request` to upload a file with an API Request, whose contents
199
+ can be a String or any IO:
200
+
201
+ ```ruby
202
+ res = File.open('photo.png', 'rb') do |file|
203
+ client.post_file_with_request(UploadPhoto.new(album: 'Holiday'),
204
+ ServiceStack::UploadFile.new(field_name: 'file', file_name: 'photo.png',
205
+ content_type: 'image/png', stream: file))
206
+ end
207
+ ```
208
+
209
+ The Request DTO's populated properties are sent as form fields alongside the file.
210
+ To upload multiple files use `post_files_with_request`.
211
+
177
212
  ### Custom URLs
178
213
 
179
214
  ```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
@@ -47,7 +71,7 @@ module ServiceStack
47
71
  attr_accessor :base_url, :reply_base_url, :oneway_base_url, :headers,
48
72
  :bearer_token, :refresh_token, :refresh_token_uri,
49
73
  :user_name, :password, :request_filter, :response_filter,
50
- :timeout, :cookies
74
+ :timeout, :cookies, :on_authentication_required
51
75
 
52
76
  class << self
53
77
  # Filters applied to every Request and Response of all clients.
@@ -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)
@@ -249,8 +309,8 @@ module ServiceStack
249
309
  capture_cookies(response)
250
310
 
251
311
  status_code = response.code.to_i
252
- if status_code == 401 && retry_on_auth_failure && refresh_access_token
253
- return execute(method, url, body, retry_on_auth_failure: false)
312
+ if status_code == 401 && retry_on_auth_failure && handle_authentication_required
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
 
@@ -315,6 +375,26 @@ module ServiceStack
315
375
  end
316
376
  end
317
377
 
378
+ # Re-authenticates the client after a Request returned 401 Unauthorized,
379
+ # returning whether the Request should be retried.
380
+ #
381
+ # Uses the Refresh Token when configured, otherwise the
382
+ # `on_authentication_required` callback.
383
+ def handle_authentication_required
384
+ return true if refresh_access_token
385
+ return false if @on_authentication_required.nil?
386
+
387
+ if @on_authentication_required.arity.zero?
388
+ @on_authentication_required.call
389
+ else
390
+ @on_authentication_required.call(self)
391
+ end
392
+ true
393
+ rescue StandardError
394
+ # Re-authenticating failed, return the original 401 Response
395
+ false
396
+ end
397
+
318
398
  def refresh_access_token
319
399
  return false if @refresh_token.to_s.empty?
320
400
 
@@ -402,6 +482,34 @@ module ServiceStack
402
482
  dto
403
483
  end
404
484
 
485
+ # Builds the multipart/form-data body of a file upload Request, sending the
486
+ # populated Request DTO properties as form fields
487
+ def multipart_body(boundary, request, files)
488
+ body = +''
489
+
490
+ to_hash(request).each do |name, value|
491
+ next if value.nil?
492
+
493
+ body << "--#{boundary}\r\n"
494
+ body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
495
+ body << "#{qs_value(value)}\r\n"
496
+ end
497
+
498
+ files.each do |file|
499
+ field_name = file.field_name.to_s.empty? ? 'file' : file.field_name
500
+ file_name = file.file_name.to_s.empty? ? 'file' : file.file_name
501
+
502
+ body << "--#{boundary}\r\n"
503
+ body << "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{file_name}\"\r\n"
504
+ body << "Content-Type: #{file.content_type || 'application/octet-stream'}\r\n\r\n"
505
+ body << file.contents.to_s.dup.force_encoding(Encoding::BINARY)
506
+ body << "\r\n"
507
+ end
508
+
509
+ body << "--#{boundary}--\r\n"
510
+ body.force_encoding(Encoding::BINARY)
511
+ end
512
+
405
513
  def has_request_body?(method)
406
514
  !%w[GET DELETE HEAD OPTIONS].include?(method.to_s.upcase)
407
515
  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.2'
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ServiceStack