flexirest 1.12.4 → 1.13.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: 4b2b1b6afc271ee5ca051d711746396685574b2a7f18ca06919f48dbdd4bbafc
4
- data.tar.gz: 10a84526cef840be253ff7fc36fc1571f7fbb463e11a900a11142b895cd8ebaa
3
+ metadata.gz: 4953df31ca4ca1d055e7a80ce2cd7a0df8e53cc4b67544b80d1a91ee2aa366a4
4
+ data.tar.gz: 0f500061fd78240f0f2c2d8c19934d5c6c5f777f32de82c8bc2330415fd6c768
5
5
  SHA512:
6
- metadata.gz: bb7b781df1bd6f64482c6b672ac397bef31b5a0717f677db8c03988aee79253b95962136e835131fc55794f48828df6f35b3d9b36f9156e7fce5d41d3af9098d
7
- data.tar.gz: 586b813a1a098c15a7c0f56267c044ac46649c97cd0a933960bbcc19e3b27397083cd05802c30ca042d280417ff68d9dcae3eb8b152f9373afd712e794e863d4
6
+ metadata.gz: 6c45c501ef89cc34a66a66ca585088e7af146348b2c42ffd0c36fe4559a957ae4bc5f579b207b238c5d0e5a0625bb1000f937a144a2107ce3eff79272d07286a
7
+ data.tar.gz: 9ea32ee11733ee70ea539d37f094fb8e9f730c82c4a14f84e5322102ab380af0c5dc12a3ac258968e8ec9a6ebd6b84ae780e54ec9a518744888c6ee62be40e7f
@@ -19,7 +19,7 @@ jobs:
19
19
  runs-on: ubuntu-latest
20
20
  strategy:
21
21
  matrix:
22
- ruby-version: ['3.0', '3.1', '3.2', '3.3']
22
+ ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4']
23
23
 
24
24
  steps:
25
25
  - uses: actions/checkout@v4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.13.0
4
+
5
+ Bugfix:
6
+
7
+ - Major changes to internals to better support thread-safe usage. Thanks to Kurtis for the bug report
8
+
9
+ ## 1.12.5
10
+
11
+ Feature:
12
+
13
+ - Some broken APIs require a GET body, even though this is against HTTP spec. Added a `send_get_body` parameter like we already have for DELETE requests (thanks to Jan Schroeder for the request)
14
+
3
15
  ## 1.12.4
4
16
 
5
17
  Bugfix:
data/docs/basic-usage.md CHANGED
@@ -52,6 +52,12 @@ For `delete` requests whether an API can handle a body or not is undefined. The
52
52
  delete :remove, "/people/:id", send_delete_body: true
53
53
  ```
54
54
 
55
+ In a similar way, although it's against the HTTP specification, you can force a GET request to send a request body if your API requires that:
56
+
57
+ ```
58
+ get :people_search, "/people/search", send_get_body: true
59
+ ```
60
+
55
61
  If an API returns an array of results and you have [will_paginate](https://rubygems.org/gems/will_paginate) installed then you can call the paginate method to return a particular page of the results (note: this doesn't reduce the load on the server, but it can help with pagination if you have a cached response).
56
62
 
57
63
  ```ruby
@@ -51,17 +51,22 @@ module Flexirest
51
51
  end
52
52
  end
53
53
 
54
+ # The keys passed to `.includes(...)` travel from this class-method call
55
+ # into the Request built by the subsequent finder call. Storing them in a
56
+ # class instance variable meant concurrent threads calling `.includes` on
57
+ # the same model shared (and corrupted) one slot, so they're held in
58
+ # thread-local storage keyed by the class instead.
54
59
  def includes(*keys)
55
- @_include_associations = keys
60
+ (Thread.current[:flexirest_include_associations] ||= {})[self] = keys
56
61
  self
57
62
  end
58
63
 
59
64
  def _include_associations
60
- @_include_associations
65
+ (Thread.current[:flexirest_include_associations] ||= {})[self] || []
61
66
  end
62
67
 
63
68
  def _reset_include_associations!
64
- @_include_associations = []
69
+ (Thread.current[:flexirest_include_associations] ||= {}).delete(self)
65
70
  end
66
71
 
67
72
  def parse_date(*keys)
@@ -80,7 +85,6 @@ module Flexirest
80
85
  def inherited(subclass)
81
86
  subclass.instance_variable_set(:@_date_fields, [])
82
87
  subclass.instance_variable_set(:@_associations, {})
83
- subclass.instance_variable_set(:@_include_associations, [])
84
88
  super
85
89
  end
86
90
  end
@@ -41,6 +41,18 @@ module Flexirest
41
41
  end
42
42
  end
43
43
 
44
+ def get_with_body(path, data, options={})
45
+ set_defaults(options)
46
+ make_safe_request(path) do
47
+ @session.get(path) do |req|
48
+ set_per_request_timeout(req, options) if options[:timeout]
49
+ req.headers = req.headers.merge(options[:headers])
50
+ req.body = data
51
+ sign_request(req, options[:api_auth])
52
+ end
53
+ end
54
+ end
55
+
44
56
  def get(path, options={})
45
57
  set_defaults(options)
46
58
  make_safe_request(path) do
@@ -7,25 +7,28 @@ module Flexirest
7
7
  Flexirest::Logger.debug " \033[1;4;32m#{name}\033[0m #{event.payload[:name]}" unless event.payload[:quiet]
8
8
  end
9
9
 
10
+ # These counters accumulate per request and are reported/reset per
11
+ # controller action. Held per-thread so concurrent requests each track their
12
+ # own totals instead of sharing (and losing updates to) one class variable.
10
13
  def self.time_spent=(value)
11
- @@time_spent = value
14
+ Thread.current[:flexirest_time_spent] = value
12
15
  end
13
16
 
14
17
  def self.time_spent
15
- @@time_spent ||= 0
18
+ Thread.current[:flexirest_time_spent] ||= 0
16
19
  end
17
20
 
18
21
  def self.calls_made=(value)
19
- @@calls_made = value
22
+ Thread.current[:flexirest_calls_made] = value
20
23
  end
21
24
 
22
25
  def self.calls_made
23
- @@calls_made ||= 0
26
+ Thread.current[:flexirest_calls_made] ||= 0
24
27
  end
25
28
 
26
29
  def self.reset
27
- @@time_spent = 0
28
- @@calls_made = 0
30
+ Thread.current[:flexirest_time_spent] = 0
31
+ Thread.current[:flexirest_calls_made] = 0
29
32
  end
30
33
 
31
34
  def logger
@@ -172,10 +172,18 @@ module Flexirest
172
172
  # Creating JSON API header
173
173
  module Headers
174
174
  extend self
175
+ # `extend self` makes this module a single process-wide object, so the
176
+ # headers used while building a request must not be stashed in an ivar
177
+ # here — concurrent requests would overwrite each other's (and leak
178
+ # authentication headers across requests). Hold them per-thread instead.
175
179
  def save(headers)
176
180
  # Save headers used in a request for building lazy association
177
181
  # loaders when parsing the response
178
- @headers = headers
182
+ Thread.current[:flexirest_jsonapi_headers] = headers
183
+ end
184
+
185
+ def headers
186
+ Thread.current[:flexirest_jsonapi_headers]
179
187
  end
180
188
  end
181
189
 
@@ -185,8 +193,16 @@ module Flexirest
185
193
  extend Flexirest::JsonAPIProxy::Helpers
186
194
  ID_PFIX = '_id_'
187
195
 
196
+ # `extend self` makes Response a single shared object too. The resource
197
+ # class saved here is read again while building lazy association loaders
198
+ # during the same parse, so it must be per-thread: otherwise a concurrent
199
+ # parse of a different model would corrupt association resolution.
188
200
  def save_resource_class(object)
189
- @resource_class = object.is_a?(Class) ? object : object.class
201
+ Thread.current[:flexirest_jsonapi_resource_class] = object.is_a?(Class) ? object : object.class
202
+ end
203
+
204
+ def resource_class
205
+ Thread.current[:flexirest_jsonapi_resource_class]
190
206
  end
191
207
 
192
208
  def parse(body, object)
@@ -387,7 +403,7 @@ module Flexirest
387
403
 
388
404
  def find_association_class(base, name)
389
405
  stack = base + [name]
390
- klass = @resource_class
406
+ klass = resource_class
391
407
 
392
408
  until stack.empty?
393
409
  shift = stack.shift
@@ -424,7 +440,7 @@ module Flexirest
424
440
 
425
441
  # Also add the previous request's header, which may contain
426
442
  # crucial authentication headers (or so), to connect with the service
427
- request.headers = @headers
443
+ request.headers = Flexirest::JsonAPIProxy::Headers.headers
428
444
  request.url = request.forced_url = url
429
445
 
430
446
  Flexirest::LazyAssociationLoader.new(name, url, request)
@@ -2,18 +2,25 @@ module Flexirest
2
2
  class Logger
3
3
  @logfile = nil
4
4
  @messages = []
5
+ # Guards the shared in-memory buffer / logfile handle below, which are
6
+ # written on every log call during request handling. Without this,
7
+ # concurrent requests racing on `@messages << ...` (or a shared IO) can
8
+ # interleave and lose or corrupt entries.
9
+ @mutex = Mutex.new
5
10
 
6
11
  def self.logfile=(value)
7
- @logfile = value
12
+ @mutex.synchronize { @logfile = value }
8
13
  end
9
14
 
10
15
  def self.messages
11
- @messages
16
+ @mutex.synchronize { @messages.dup }
12
17
  end
13
18
 
14
19
  def self.reset!
15
- @logfile = nil
16
- @messages = []
20
+ @mutex.synchronize do
21
+ @logfile = nil
22
+ @messages = []
23
+ end
17
24
  end
18
25
 
19
26
  def self.level
@@ -27,65 +34,52 @@ module Flexirest
27
34
  def self.debug(message)
28
35
  if defined?(Rails) && Rails.logger.present?
29
36
  Rails.logger.debug(message)
30
- elsif @logfile
31
- if @logfile.is_a?(String)
32
- File.open(@logfile, "a") do |f|
33
- f << "#{message}\n"
34
- end
35
- else
36
- @logfile << "#{message}\n"
37
- end
38
37
  else
39
- @messages << message
38
+ write_fallback(message)
40
39
  end
41
40
  end
42
41
 
43
42
  def self.info(message)
44
43
  if defined?(Rails) && Rails.logger.present?
45
44
  Rails.logger.info(message)
46
- elsif @logfile
47
- if @logfile.is_a?(String)
48
- File.open(@logfile, "a") do |f|
49
- f << "#{message}\n"
50
- end
51
- else
52
- @logfile << "#{message}\n"
53
- end
54
45
  else
55
- @messages << message
46
+ write_fallback(message)
56
47
  end
57
48
  end
58
49
 
59
50
  def self.warn(message)
60
51
  if defined?(Rails) && Rails.logger.present?
61
52
  Rails.logger.warn(message)
62
- elsif @logfile
63
- if @logfile.is_a?(String)
64
- File.open(@logfile, "a") do |f|
65
- f << "#{message}\n"
66
- end
67
- else
68
- @logfile << "#{message}\n"
69
- end
70
53
  else
71
- @messages << message
54
+ write_fallback(message)
72
55
  end
73
56
  end
74
57
 
75
58
  def self.error(message)
76
59
  if defined?(Rails) && Rails.logger.present?
77
60
  Rails.logger.error(message)
78
- elsif @logfile
79
- if @logfile.is_a?(String)
80
- File.open(@logfile, "a") do |f|
81
- f << "#{message}\n"
61
+ else
62
+ write_fallback(message)
63
+ end
64
+ end
65
+
66
+ # Writes to the configured logfile, or falls back to the in-memory buffer.
67
+ # Synchronised because both destinations are shared across threads.
68
+ def self.write_fallback(message)
69
+ @mutex.synchronize do
70
+ if @logfile
71
+ if @logfile.is_a?(String)
72
+ File.open(@logfile, "a") do |f|
73
+ f << "#{message}\n"
74
+ end
75
+ else
76
+ @logfile << "#{message}\n"
82
77
  end
83
78
  else
84
- @logfile << "#{message}\n"
79
+ @messages << message
85
80
  end
86
- else
87
- @messages << message
88
81
  end
89
82
  end
83
+ private_class_method :write_fallback
90
84
  end
91
85
  end
@@ -2,10 +2,19 @@ require "uri"
2
2
 
3
3
  module Flexirest
4
4
  class ProxyBase
5
- cattr_accessor :mappings, :request, :original_handler
6
- cattr_accessor :original_body, :original_get_params, :original_post_params, :original_url
7
-
8
5
  module ClassMethods
6
+ # Per-request state used while handling a proxied request is stored in
7
+ # thread-local storage (keyed by the proxy class) rather than on the
8
+ # class itself. The DSL is implemented as class methods run via
9
+ # class_eval, so historically "the current request" was stored in class
10
+ # instance variables/class variables, which are shared across every
11
+ # thread calling through the same proxy class. That caused request data
12
+ # to be dropped or corrupted under concurrency. Keeping it per-thread
13
+ # isolates concurrent requests while preserving the existing DSL.
14
+ def _proxy_state
15
+ states = (Thread.current[:flexirest_proxy_state] ||= {})
16
+ states[self] ||= {}
17
+ end
9
18
  def get(match, &block)
10
19
  add_mapping(:get, match, block)
11
20
  end
@@ -41,33 +50,34 @@ module Flexirest
41
50
  end
42
51
 
43
52
  def body(value = nil)
44
- @body = value if value
45
- @body
53
+ _proxy_state[:body] = value if value
54
+ _proxy_state[:body]
46
55
  end
47
56
 
48
57
  def url(value = nil)
49
- @url = value if value
50
- @url
58
+ _proxy_state[:url] = value if value
59
+ _proxy_state[:url]
51
60
  end
52
61
 
53
62
  def get_params(value = nil)
54
- @get_params = value if value
55
- @get_params
63
+ _proxy_state[:get_params] = value if value
64
+ _proxy_state[:get_params]
56
65
  end
57
66
 
58
67
  def post_params(value = nil)
59
- @post_params = value if value
60
- @post_params
68
+ _proxy_state[:post_params] = value if value
69
+ _proxy_state[:post_params]
61
70
  end
62
71
 
63
72
  def params(value = nil)
64
- @params = value if value
65
- @params
73
+ _proxy_state[:params] = value if value
74
+ _proxy_state[:params]
66
75
  end
67
76
 
68
77
  def passthrough
69
78
  rebuild_request
70
- @original_handler.call(@request)
79
+ state = _proxy_state
80
+ state[:original_handler].call(state[:request])
71
81
  end
72
82
 
73
83
  def result_is_json_or_unspecified?(result)
@@ -93,56 +103,73 @@ module Flexirest
93
103
  end
94
104
 
95
105
  def rebuild_request
96
- if @url != @original_url
97
- @request.forced_url = @request.url = @url
106
+ state = _proxy_state
107
+ request = state[:request]
108
+ if state[:url] != state[:original_url]
109
+ request.forced_url = request.url = state[:url]
98
110
  end
99
- if @body != @original_body
100
- @request.body = @body
101
- elsif @post_params != @original_post_params
102
- @request.body = nil
103
- @request.prepare_request_body(@post_params)
111
+ if state[:body] != state[:original_body]
112
+ request.body = state[:body]
113
+ elsif state[:post_params] != state[:original_post_params]
114
+ request.body = nil
115
+ request.prepare_request_body(state[:post_params])
104
116
  end
105
- if @get_params != @original_get_params
106
- @request.get_params = @get_params
107
- @request.prepare_url
108
- @request.append_get_parameters
117
+ if state[:get_params] != state[:original_get_params]
118
+ request.get_params = state[:get_params]
119
+ request.prepare_url
120
+ request.append_get_parameters
109
121
  end
110
122
  end
111
123
 
112
124
  def handle(request, &block)
113
- @request = request
114
- @original_handler = block
125
+ # Preserve any state already in flight on this thread (e.g. a proxied
126
+ # request that itself triggers another request through the same proxy
127
+ # class) and restore it when we're done, so nested calls can't clobber
128
+ # each other.
129
+ states = (Thread.current[:flexirest_proxy_state] ||= {})
130
+ previous_state = states[self]
131
+
132
+ state = states[self] = {}
133
+ state[:request] = request
134
+ state[:original_handler] = block
115
135
 
116
- @original_body = request.body
117
- @body = @original_body.dup
136
+ state[:original_body] = request.body
137
+ state[:body] = state[:original_body].dup
118
138
 
119
- @original_get_params = request.get_params
120
- @get_params = @original_get_params.dup
139
+ state[:original_get_params] = request.get_params
140
+ state[:get_params] = state[:original_get_params].dup
121
141
 
122
- @original_post_params = request.post_params
123
- @post_params = (@original_post_params || {}).dup
142
+ state[:original_post_params] = request.post_params
143
+ state[:post_params] = (state[:original_post_params] || {}).dup
124
144
 
125
- @original_url = request.url
126
- @url = @original_url.dup
145
+ state[:original_url] = request.url
146
+ state[:url] = state[:original_url].dup
127
147
 
128
148
  if mapping = find_mapping_for_current_request
129
149
  self.class_eval(&mapping.block)
130
150
  else
131
151
  passthrough
132
152
  end
153
+ ensure
154
+ if previous_state
155
+ states[self] = previous_state
156
+ else
157
+ states.delete(self)
158
+ end
133
159
  end
134
160
 
135
161
  def find_mapping_for_current_request
136
- uri = URI.parse(@original_url)
162
+ state = _proxy_state
163
+ uri = URI.parse(state[:original_url])
137
164
  @mappings ||= []
138
- @params = {}
165
+ state[:params] = {}
139
166
  @mappings.each do |mapping|
140
167
  match = mapping.match
141
- if (match_data = uri.path.match(match)) && @request.http_method.to_sym == mapping.http_method
168
+ if (match_data = uri.path.match(match)) && state[:request].http_method.to_sym == mapping.http_method
142
169
  matches = match_data.to_a
143
170
  matches.shift
144
171
  matches.each_with_index do |value, index|
145
- @params[mapping.param_keys[index]] = value
172
+ state[:params][mapping.param_keys[index]] = value
146
173
  end
147
174
  return mapping
148
175
  end
@@ -361,10 +361,10 @@ module Flexirest
361
361
  if @explicit_parameters
362
362
  params = @explicit_parameters
363
363
  end
364
- if http_method == :get || (http_method == :delete && !@method[:options][:send_delete_body] && proxy != :json_api)
364
+ if (http_method == :get && !@method[:options][:send_get_body]) || (http_method == :delete && !@method[:options][:send_delete_body] && proxy != :json_api)
365
365
  @get_params = default_params.merge(params || {})
366
366
  @post_params = nil
367
- elsif http_method == :delete && @method[:options][:send_delete_body]
367
+ elsif (http_method == :get && @method[:options][:send_get_body]) || (http_method == :delete && @method[:options][:send_delete_body])
368
368
  @post_params = default_params.merge(params || {})
369
369
  @get_params = {}
370
370
  elsif params.is_a? String
@@ -476,7 +476,7 @@ module Flexirest
476
476
 
477
477
  headers["Accept"] ||= "application/vnd.api+json"
478
478
  JsonAPIProxy::Headers.save(headers)
479
- elsif http_method == :get || (http_method == :delete && !@method[:options][:send_delete_body])
479
+ elsif (http_method == :get && !@method[:options][:send_get_body]) || (http_method == :delete && !@method[:options][:send_delete_body])
480
480
  if request_body_type == :form_encoded
481
481
  headers["Content-Type"] ||= "application/x-www-form-urlencoded; charset=utf-8"
482
482
  elsif request_body_type == :form_multipart
@@ -607,16 +607,17 @@ module Flexirest
607
607
  request_options[:timeout] = @method[:options][:timeout]
608
608
  end
609
609
 
610
- case http_method
611
- when :get
610
+ if http_method == :get && !@method[:options][:send_get_body]
612
611
  response = connection.get(@url, request_options)
613
- when :put
612
+ elsif http_method == :get
613
+ response = connection.get_with_body(@url, @body, request_options)
614
+ elsif http_method == :put
614
615
  response = connection.put(@url, @body, request_options)
615
- when :post
616
+ elsif http_method == :post
616
617
  response = connection.post(@url, @body, request_options)
617
- when :patch
618
+ elsif http_method == :patch
618
619
  response = connection.patch(@url, @body, request_options)
619
- when :delete
620
+ elsif http_method == :delete
620
621
  response = connection.delete(@url, @body, request_options)
621
622
  else
622
623
  raise InvalidRequestException.new("Invalid method #{http_method}")
@@ -1,3 +1,3 @@
1
1
  module Flexirest
2
- VERSION = "1.12.4"
2
+ VERSION = "1.13.0"
3
3
  end
@@ -84,6 +84,37 @@ describe "Has Many Associations" do
84
84
  end
85
85
  end
86
86
 
87
+ describe "includes association thread safety" do
88
+ it "keeps .includes state isolated across concurrent threads" do
89
+ thread_count = 10
90
+
91
+ # All threads set their own includes, then wait until every thread has done
92
+ # so before reading it back. Before the fix, the shared class instance
93
+ # variable meant each thread read whichever value was written last.
94
+ count = 0
95
+ mutex = Mutex.new
96
+ cond = ConditionVariable.new
97
+ results = {}
98
+
99
+ threads = thread_count.times.map do |i|
100
+ Thread.new do
101
+ AssociationExampleBase.includes(:"assoc_#{i}")
102
+ mutex.synchronize do
103
+ count += 1
104
+ cond.broadcast
105
+ cond.wait(mutex) while count < thread_count
106
+ end
107
+ results[i] = AssociationExampleBase._include_associations
108
+ end
109
+ end
110
+ threads.each { |t| t.join(5) }
111
+
112
+ thread_count.times do |i|
113
+ expect(results[i]).to eq([:"assoc_#{i}"])
114
+ end
115
+ end
116
+ end
117
+
87
118
  describe "Has One Associations" do
88
119
  let(:subject) {AssociationExampleBase.new}
89
120
 
@@ -567,3 +567,64 @@ describe 'JSON API' do
567
567
  end
568
568
  end
569
569
  end
570
+
571
+ # A simple N-party rendezvous forcing concurrent parses to interleave, so the
572
+ # per-request state the JSON:API proxy stashes (resource class + headers) is
573
+ # provably isolated per thread rather than shared on the singleton modules.
574
+ class JsonAPIThreadBarrier
575
+ def initialize(parties)
576
+ @parties = parties
577
+ @count = 0
578
+ @mutex = Mutex.new
579
+ @cond = ConditionVariable.new
580
+ end
581
+
582
+ def wait
583
+ @mutex.synchronize do
584
+ @count += 1
585
+ if @count >= @parties
586
+ @cond.broadcast
587
+ else
588
+ @cond.wait(@mutex) while @count < @parties
589
+ end
590
+ end
591
+ end
592
+ end
593
+
594
+ describe Flexirest::JsonAPIProxy do
595
+ it 'keeps the parsed resource class isolated across concurrent threads' do
596
+ classes = [JsonAPIExampleArticle, JsonAPIAssociationExampleTag, JsonAPIAssociationExampleAuthor]
597
+ thread_count = 9
598
+ barrier = JsonAPIThreadBarrier.new(thread_count)
599
+
600
+ threads = thread_count.times.map do |i|
601
+ klass = classes[i % classes.size]
602
+ Thread.new do
603
+ Flexirest::JsonAPIProxy::Response.save_resource_class(klass)
604
+ # All threads have now saved a class; if it were shared they'd all read
605
+ # whichever was written last.
606
+ barrier.wait
607
+ [i, Flexirest::JsonAPIProxy::Response.resource_class]
608
+ end
609
+ end
610
+ results = threads.to_h(&:value)
611
+
612
+ thread_count.times { |i| expect(results[i]).to eq(classes[i % classes.size]) }
613
+ end
614
+
615
+ it 'keeps the saved request headers isolated across concurrent threads' do
616
+ thread_count = 10
617
+ barrier = JsonAPIThreadBarrier.new(thread_count)
618
+
619
+ threads = thread_count.times.map do |i|
620
+ Thread.new do
621
+ Flexirest::JsonAPIProxy::Headers.save('X-Marker' => i.to_s)
622
+ barrier.wait
623
+ [i, Flexirest::JsonAPIProxy::Headers.headers]
624
+ end
625
+ end
626
+ results = threads.to_h(&:value)
627
+
628
+ thread_count.times { |i| expect(results[i]).to eq('X-Marker' => i.to_s) }
629
+ end
630
+ end
@@ -2,6 +2,29 @@ require 'spec_helper'
2
2
  require 'active_support/core_ext/hash'
3
3
  require 'base64'
4
4
 
5
+ # A simple N-party rendezvous used to force concurrent proxy requests to be
6
+ # interleaved inside `handle` at the same time, deterministically reproducing
7
+ # the thread-safety bug where per-request state was stored on the class.
8
+ class ProxyTestBarrier
9
+ def initialize(parties)
10
+ @parties = parties
11
+ @count = 0
12
+ @mutex = Mutex.new
13
+ @cond = ConditionVariable.new
14
+ end
15
+
16
+ def wait
17
+ @mutex.synchronize do
18
+ @count += 1
19
+ if @count >= @parties
20
+ @cond.broadcast
21
+ else
22
+ @cond.wait(@mutex) while @count < @parties
23
+ end
24
+ end
25
+ end
26
+ end
27
+
5
28
  class ProxyExample < Flexirest::ProxyBase
6
29
  get "/all" do
7
30
  url.gsub!("/all", "/getAll")
@@ -72,6 +95,22 @@ class ProxyExample < Flexirest::ProxyBase
72
95
  get "/fake" do
73
96
  render "{\"id\":1234}"
74
97
  end
98
+
99
+ def self.thread_barrier=(barrier)
100
+ @thread_barrier = barrier
101
+ end
102
+
103
+ def self.thread_barrier
104
+ @thread_barrier
105
+ end
106
+
107
+ get "/thread_test/:marker" do
108
+ url "/thread_result/#{params[:marker]}"
109
+ # Pause every concurrent request here, after it has set its own URL but
110
+ # before it is sent, so all threads are inside `handle` simultaneously.
111
+ ProxyExample.thread_barrier.wait
112
+ passthrough
113
+ end
75
114
  end
76
115
 
77
116
  class ProxyClientExample < Flexirest::Base
@@ -91,6 +130,7 @@ class ProxyClientExample < Flexirest::Base
91
130
  get :not_proxied, "/not_proxied"
92
131
  delete :remove, "/remove"
93
132
  get :hal_test, "/hal_test/:id"
133
+ get :thread_test, "/thread_test/:marker"
94
134
  end
95
135
 
96
136
  describe Flexirest::Base do
@@ -200,6 +240,31 @@ describe Flexirest::Base do
200
240
  expect(ProxyClientExample.hal_test(id:1).test.result).to eq(true)
201
241
  end
202
242
 
243
+ it "keeps proxied request data isolated across concurrent threads" do
244
+ thread_count = 10
245
+ ProxyExample.thread_barrier = ProxyTestBarrier.new(thread_count)
246
+
247
+ requested_paths = Queue.new
248
+ allow_any_instance_of(Flexirest::Connection).to receive(:get) do |_connection, path, _options|
249
+ requested_paths << path
250
+ ::FaradayResponseMock.new(OpenStruct.new(body: "{\"result\":true}", status: 200, response_headers: {}))
251
+ end
252
+
253
+ threads = thread_count.times.map do |i|
254
+ Thread.new { ProxyClientExample.thread_test(marker: i) }
255
+ end
256
+ threads.each { |t| t.join(5) }
257
+
258
+ paths = []
259
+ paths << requested_paths.pop until requested_paths.empty?
260
+
261
+ # Each thread must reach the server with its own marker. Before the fix the
262
+ # shared class-level state meant every thread sent whichever URL was written
263
+ # last, so the paths collided instead of covering 0..9.
264
+ expected = (0...thread_count).map { |i| "/thread_result/#{i}" }
265
+ expect(paths.sort).to eq(expected.sort)
266
+ end
267
+
203
268
  it "properly passes basic HTTP auth credentials" do
204
269
  host, credentials, url_path = 'www.example.com', 'user:pass', '/getAll?id=1'
205
270
  ProxyClientExample.base_url "http://#{credentials}@#{host}"
@@ -54,6 +54,7 @@ describe Flexirest::Request do
54
54
  put :conversion_child, "/put/:id", parse_fields: [:converted_child]
55
55
  delete :remove, "/remove/:id"
56
56
  delete :remove_body, "/remove/:id", send_delete_body: true
57
+ get :get_body, "/get-body", send_get_body: true
57
58
  get :hal, "/hal", fake:"{\"_links\":{\"child\": {\"href\": \"/child/1\"}, \"other\": {\"href\": \"/other/1\"}, \"cars\":[{\"href\": \"/car/1\", \"name\":\"car1\"}, {\"href\": \"/car/2\", \"name\":\"car2\"}, {\"href\": \"/car/not-embed\", \"name\":\"car_not_embed\"} ], \"lazy\": {\"href\": \"/lazy/load\"}, \"invalid\": [{\"href\": \"/invalid/1\"}]}, \"_embedded\":{\"other\":{\"name\":\"Jane\"},\"child\":{\"name\":\"Billy\"}, \"cars\":[{\"_links\": {\"self\": {\"href\": \"/car/1\"} }, \"make\": \"Bugatti\", \"model\": \"Veyron\"}, {\"_links\": {\"self\": {\"href\": \"/car/2\"} }, \"make\": \"Ferrari\", \"model\": \"F458 Italia\"} ], \"invalid\": [{\"present\":true, \"_links\": {} } ] } }", has_many:{other:ExampleOtherClient}
58
59
  get :fake_object, "/fake", fake:"{\"result\":true, \"list\":[1,2,3,{\"test\":true}], \"child\":{\"grandchild\":{\"test\":true}}}"
59
60
  get :fake_proc_object, "/fake", fake:->(request) { "{\"result\":#{request.get_params[:id]}}" }
@@ -451,6 +452,11 @@ describe Flexirest::Request do
451
452
  ExampleClient.all
452
453
  end
453
454
 
455
+ it "should get an HTTP connection when called and call get with a body if send_get_body is specified" do
456
+ expect_any_instance_of(Flexirest::Connection).to receive(:get_with_body).with("/get-body", "something=else", an_instance_of(Hash)).and_return(::FaradayResponseMock.new(OpenStruct.new(body:'{"result":true}', response_headers:{})))
457
+ ExampleClient.get_body(something: "else")
458
+ end
459
+
454
460
  it "should get an HTTP connection when called and call delete on it" do
455
461
  expect_any_instance_of(Flexirest::Connection).to receive(:delete).with("/remove/1", "", an_instance_of(Hash)).and_return(::FaradayResponseMock.new(OpenStruct.new(body:'{"result":true}', response_headers:{})))
456
462
  ExampleClient.remove(id:1)
data/spec/spec_helper.rb CHANGED
@@ -13,8 +13,6 @@ elsif ENV["TRAVIS"]
13
13
  Coveralls.wear!
14
14
  end
15
15
 
16
- ActiveSupport::Deprecation.silenced = true
17
-
18
16
  RSpec.configure do |config|
19
17
  config.color = true
20
18
  # config.formatter = 'documentation'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flexirest
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.4
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Jeffries
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-05-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bundler
@@ -392,7 +391,6 @@ licenses:
392
391
  - MIT
393
392
  metadata:
394
393
  source_code_uri: https://github.com/flexirest/flexirest
395
- post_install_message:
396
394
  rdoc_options: []
397
395
  require_paths:
398
396
  - lib
@@ -407,8 +405,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
407
405
  - !ruby/object:Gem::Version
408
406
  version: '0'
409
407
  requirements: []
410
- rubygems_version: 3.5.6
411
- signing_key:
408
+ rubygems_version: 3.6.9
412
409
  specification_version: 4
413
410
  summary: This gem is for accessing REST services in a flexible way. ActiveResource
414
411
  already exists for this, but it doesn't work where the resource naming doesn't follow