flexirest 1.12.5 → 1.13.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: 44aa9166918c27766f3d696c23cc78b08e1f956edb6ae8a7413c9e88f01c48e7
4
- data.tar.gz: 1068bcb89fb8da7af5505177f989997794615703df092d8d27fa55479987565c
3
+ metadata.gz: 152b5d789cff3ee4ccd09c6090d4f5bee5b431a96fb34797d5d189882585c235
4
+ data.tar.gz: 91c220948c05caec4b7f625d618c1b3e180272013a8dee3157bd66cbaf039bbb
5
5
  SHA512:
6
- metadata.gz: 9cddabff55c7531fdb3856c863fc9888fa3731c82135f9a02c4e3d5026514c479815ecaa499fc38b6f50c6579bff7703bcbaef688d76ea067a43f7a26dc8efc5
7
- data.tar.gz: 204f29f4a0baa07661ea497d88799f137ed55114fdea808f5a1ea905a21f9ff51f2d0ce1c9a82d985c140c6e6f4fc29ae26e394a34a63cc51b87907dbef81139
6
+ metadata.gz: 7440c4c8b5122e296360ece864f8ffd9ba59ae83429badd94c587ce700ecab20e823b3cff166c2623ecde5a5f5697737caf1684ac3237f5b20a63213d2dc42e0
7
+ data.tar.gz: e54a1e79e49dca9a29ed9c6d04ee43ea7a0da4943b6b873ff30d9df8377605d418a84ad391cccaad60d65102f9d43936b191bb2a5f6c4f8889ca011381e7a529
@@ -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,10 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.13.1
4
+
5
+ Bugfix:
6
+
7
+ - Ruby 4.0 removed `ostruct` from the standard library, and Flexirest builds OpenStructs internally, so it is now required explicitly and declared as a runtime dependency rather than relying on the host application to provide it
8
+ - Proxy routes with parameters (e.g. `get "/things/:id"`) no longer mutate the string they are given, so declaring one with a frozen string literal works. Previously `ProxyBase.add_mapping` built the regex with `gsub!` on the caller's string, which warns under Ruby 3.4+ chilled string literals and raises `FrozenError` under `--enable=frozen-string-literal`
9
+ - A forced URL is now duplicated before `before_request` callbacks run, so the documented `request.url.gsub!` pattern works when the URL passed to `_request` is a frozen string literal
10
+
11
+ ## 1.13.0
12
+
13
+ Bugfix:
14
+
15
+ - Major changes to internals to better support thread-safe usage. Thanks to Kurtis for the bug report
16
+
3
17
  ## 1.12.5
4
18
 
5
19
  Feature:
6
20
 
7
- - 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)
21
+ - 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)
8
22
 
9
23
  ## 1.12.4
10
24
 
data/Rakefile CHANGED
@@ -1,3 +1,8 @@
1
+ # IMPORTANT: bundler/setup so a bare `rake spec` runs the same suite as CI's
2
+ # `bundle exec rake`. Several specs are defined conditionally on
3
+ # Gem.loaded_specs["api-auth"], which only lists *activated* gems - without Bundler
4
+ # those guards read false and the examples silently vanish rather than fail.
5
+ require "bundler/setup"
1
6
  require "bundler/gem_tasks"
2
7
  require 'rspec/core/rake_task'
3
8
  RSpec::Core::RakeTask.new('spec')
data/flexirest.gemspec CHANGED
@@ -38,6 +38,9 @@ Gem::Specification.new do |spec|
38
38
  spec.add_development_dependency 'rest-client'
39
39
  spec.add_development_dependency 'timecop'
40
40
 
41
+ # A default gem up to Ruby 3.4 and dropped from the stdlib in 4.0, so declare it
42
+ # rather than assume the host application provides it.
43
+ spec.add_runtime_dependency "ostruct"
41
44
  spec.add_runtime_dependency "mime-types"
42
45
  spec.add_runtime_dependency "multi_json"
43
46
  spec.add_runtime_dependency "crack"
@@ -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
@@ -30,44 +39,48 @@ module Flexirest
30
39
  @mappings ||= []
31
40
 
32
41
  if match.is_a?(String) && (param_keys = match.scan(/:\w+/)) && param_keys.any?
33
- param_keys.each do |key|
34
- match.gsub!(key, "([^/]+)")
35
- end
42
+ # Build the pattern from a copy rather than mutating the caller's string. A
43
+ # route is almost always declared with a string literal, and under Ruby 3.4+
44
+ # those are chilled (mutating one warns); with frozen string literals enabled
45
+ # - Ruby 4.0's --enable=frozen-string-literal, or a magic comment - the same
46
+ # mutation raises FrozenError and the proxy class fails to load.
47
+ pattern = param_keys.reduce(match.dup) { |acc, key| acc.gsub(key, "([^/]+)") }
36
48
  param_keys = param_keys.map {|k| k.gsub(":", "").to_sym}
37
- match = Regexp.new(match)
49
+ match = Regexp.new(pattern)
38
50
  end
39
51
 
40
52
  @mappings << OpenStruct.new(http_method:method_type, match:match, block:block, param_keys:param_keys)
41
53
  end
42
54
 
43
55
  def body(value = nil)
44
- @body = value if value
45
- @body
56
+ _proxy_state[:body] = value if value
57
+ _proxy_state[:body]
46
58
  end
47
59
 
48
60
  def url(value = nil)
49
- @url = value if value
50
- @url
61
+ _proxy_state[:url] = value if value
62
+ _proxy_state[:url]
51
63
  end
52
64
 
53
65
  def get_params(value = nil)
54
- @get_params = value if value
55
- @get_params
66
+ _proxy_state[:get_params] = value if value
67
+ _proxy_state[:get_params]
56
68
  end
57
69
 
58
70
  def post_params(value = nil)
59
- @post_params = value if value
60
- @post_params
71
+ _proxy_state[:post_params] = value if value
72
+ _proxy_state[:post_params]
61
73
  end
62
74
 
63
75
  def params(value = nil)
64
- @params = value if value
65
- @params
76
+ _proxy_state[:params] = value if value
77
+ _proxy_state[:params]
66
78
  end
67
79
 
68
80
  def passthrough
69
81
  rebuild_request
70
- @original_handler.call(@request)
82
+ state = _proxy_state
83
+ state[:original_handler].call(state[:request])
71
84
  end
72
85
 
73
86
  def result_is_json_or_unspecified?(result)
@@ -93,56 +106,73 @@ module Flexirest
93
106
  end
94
107
 
95
108
  def rebuild_request
96
- if @url != @original_url
97
- @request.forced_url = @request.url = @url
109
+ state = _proxy_state
110
+ request = state[:request]
111
+ if state[:url] != state[:original_url]
112
+ request.forced_url = request.url = state[:url]
98
113
  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)
114
+ if state[:body] != state[:original_body]
115
+ request.body = state[:body]
116
+ elsif state[:post_params] != state[:original_post_params]
117
+ request.body = nil
118
+ request.prepare_request_body(state[:post_params])
104
119
  end
105
- if @get_params != @original_get_params
106
- @request.get_params = @get_params
107
- @request.prepare_url
108
- @request.append_get_parameters
120
+ if state[:get_params] != state[:original_get_params]
121
+ request.get_params = state[:get_params]
122
+ request.prepare_url
123
+ request.append_get_parameters
109
124
  end
110
125
  end
111
126
 
112
127
  def handle(request, &block)
113
- @request = request
114
- @original_handler = block
128
+ # Preserve any state already in flight on this thread (e.g. a proxied
129
+ # request that itself triggers another request through the same proxy
130
+ # class) and restore it when we're done, so nested calls can't clobber
131
+ # each other.
132
+ states = (Thread.current[:flexirest_proxy_state] ||= {})
133
+ previous_state = states[self]
115
134
 
116
- @original_body = request.body
117
- @body = @original_body.dup
135
+ state = states[self] = {}
136
+ state[:request] = request
137
+ state[:original_handler] = block
118
138
 
119
- @original_get_params = request.get_params
120
- @get_params = @original_get_params.dup
139
+ state[:original_body] = request.body
140
+ state[:body] = state[:original_body].dup
121
141
 
122
- @original_post_params = request.post_params
123
- @post_params = (@original_post_params || {}).dup
142
+ state[:original_get_params] = request.get_params
143
+ state[:get_params] = state[:original_get_params].dup
124
144
 
125
- @original_url = request.url
126
- @url = @original_url.dup
145
+ state[:original_post_params] = request.post_params
146
+ state[:post_params] = (state[:original_post_params] || {}).dup
147
+
148
+ state[:original_url] = request.url
149
+ state[:url] = state[:original_url].dup
127
150
 
128
151
  if mapping = find_mapping_for_current_request
129
152
  self.class_eval(&mapping.block)
130
153
  else
131
154
  passthrough
132
155
  end
156
+ ensure
157
+ if previous_state
158
+ states[self] = previous_state
159
+ else
160
+ states.delete(self)
161
+ end
133
162
  end
134
163
 
135
164
  def find_mapping_for_current_request
136
- uri = URI.parse(@original_url)
165
+ state = _proxy_state
166
+ uri = URI.parse(state[:original_url])
137
167
  @mappings ||= []
138
- @params = {}
168
+ state[:params] = {}
139
169
  @mappings.each do |mapping|
140
170
  match = mapping.match
141
- if (match_data = uri.path.match(match)) && @request.http_method.to_sym == mapping.http_method
171
+ if (match_data = uri.path.match(match)) && state[:request].http_method.to_sym == mapping.http_method
142
172
  matches = match_data.to_a
143
173
  matches.shift
144
174
  matches.each_with_index do |value, index|
145
- @params[mapping.param_keys[index]] = value
175
+ state[:params][mapping.param_keys[index]] = value
146
176
  end
147
177
  return mapping
148
178
  end
@@ -426,7 +426,10 @@ module Flexirest
426
426
  def prepare_url
427
427
  missing = []
428
428
  if @forced_url && @forced_url.present?
429
- @url = @forced_url
429
+ # dup for the same reason as the branch below: before_request callbacks are
430
+ # documented to rewrite the URL in place with gsub!, and a forced URL is
431
+ # usually a string literal from the caller.
432
+ @url = @forced_url.dup
430
433
  else
431
434
  @url = @method[:url].dup
432
435
  matches = @url.scan(/(:[a-z_-]+)/)
@@ -610,7 +613,7 @@ module Flexirest
610
613
  if http_method == :get && !@method[:options][:send_get_body]
611
614
  response = connection.get(@url, request_options)
612
615
  elsif http_method == :get
613
- response = connection.get(@url, @body, request_options)
616
+ response = connection.get_with_body(@url, @body, request_options)
614
617
  elsif http_method == :put
615
618
  response = connection.put(@url, @body, request_options)
616
619
  elsif http_method == :post
@@ -1,3 +1,3 @@
1
1
  module Flexirest
2
- VERSION = "1.12.5"
2
+ VERSION = "1.13.1"
3
3
  end
data/lib/flexirest.rb CHANGED
@@ -1,3 +1,7 @@
1
+ # ostruct stopped being a default gem in Ruby 4.0, and Flexirest builds OpenStructs in
2
+ # Request and ProxyBase, so require it explicitly rather than relying on the host
3
+ # application having pulled it in.
4
+ require "ostruct"
1
5
  require 'active_support/all'
2
6
  require "flexirest/version"
3
7
  require "flexirest/attribute_parsing"
@@ -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
 
@@ -42,7 +42,11 @@ class SubClassedCallbacksExample < CallbacksExample
42
42
  end
43
43
 
44
44
  describe Flexirest::Callbacks do
45
- let(:request) { OpenStruct.new(get_params:{}, post_params:{}, url:"http://www.example.com", headers:Flexirest::HeadersList.new) }
45
+ # The unary + matters: these examples exercise callbacks rewriting the URL in place
46
+ # with gsub!, which is the documented API (docs/using-callbacks.md). A real request
47
+ # hands callbacks a mutable String, so the double has to as well - a frozen literal
48
+ # here would fail under frozen string literals for a reason the library doesn't have.
49
+ let(:request) { OpenStruct.new(get_params:{}, post_params:{}, url:+"http://www.example.com", headers:Flexirest::HeadersList.new) }
46
50
  let(:response) { OpenStruct.new(body:"") }
47
51
 
48
52
  it "should call through to adjust the parameters" do
@@ -5,6 +5,27 @@ describe Flexirest::ConnectionManager do
5
5
  Flexirest::ConnectionManager.reset!
6
6
  end
7
7
 
8
+ # IMPORTANT: The two typhoeus examples below set a *global* adapter. Reset it in an
9
+ # after hook rather than on the last line of each example: if the example raises
10
+ # first that line never runs, every later spec in the suite inherits
11
+ # adapter = :typhoeus, and they all fail with ":typhoeus is not registered on
12
+ # Faraday::Adapter". Specs run in random order, so that turns one local failure into
13
+ # a suite-wide cascade whose size depends on the seed.
14
+ after(:each) do
15
+ Flexirest::Base._reset_configuration!
16
+ end
17
+
18
+ # Gem.loaded_specs only lists gems that have been *activated*, not every gem in the
19
+ # bundle, so it is not a reliable way to ask whether faraday-typhoeus is installed -
20
+ # when it reads as absent the require is skipped and :typhoeus is never registered.
21
+ # Just attempt the require and let the older typhoeus-provided adapter be the
22
+ # fallback.
23
+ def require_typhoeus_adapter
24
+ require 'faraday/typhoeus'
25
+ rescue LoadError
26
+ require 'typhoeus/adapters/faraday'
27
+ end
28
+
8
29
  it "should have a get_connection method" do
9
30
  expect(Flexirest::ConnectionManager).to respond_to("get_connection")
10
31
  end
@@ -35,19 +56,17 @@ describe Flexirest::ConnectionManager do
35
56
  end
36
57
 
37
58
  it "should call 'in_parallel' for a session and yield procedure inside that block" do
38
- require 'faraday/typhoeus' if Gem.loaded_specs["faraday-typhoeus"].present?
59
+ require_typhoeus_adapter
39
60
  Flexirest::Base.adapter = :typhoeus
40
61
  Flexirest::ConnectionManager.get_connection("http://www.example.com").session
41
62
  expect { |b| Flexirest::ConnectionManager.in_parallel("http://www.example.com", &b)}.to yield_control
42
- Flexirest::Base._reset_configuration!
43
63
  end
44
64
 
45
65
  it "should raise Flexirest::MissingOptionalLibraryError if Typhoeus isn't available" do
46
- require 'faraday/typhoeus' if Gem.loaded_specs["faraday-typhoeus"].present?
66
+ require_typhoeus_adapter
47
67
  Flexirest::Base.adapter = :typhoeus
48
68
  Flexirest::ConnectionManager.get_connection("http://www.example.com").session
49
69
  expect(Flexirest::ConnectionManager).to receive(:require).and_raise(LoadError)
50
70
  expect { Flexirest::ConnectionManager.in_parallel("http://www.example.com")}.to raise_error(Flexirest::MissingOptionalLibraryError)
51
- Flexirest::Base._reset_configuration!
52
71
  end
53
72
  end
@@ -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,43 @@ 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
+
268
+ it "builds a parameterised route without mutating the string it was given" do
269
+ route = "/frozen/:id/:name".freeze
270
+ proxy = Class.new(Flexirest::ProxyBase)
271
+
272
+ expect { proxy.get(route) { passthrough } }.to_not raise_error
273
+ expect(route).to eq("/frozen/:id/:name")
274
+
275
+ mapping = proxy.instance_variable_get(:@mappings).last
276
+ expect(mapping.param_keys).to eq(%i[id name])
277
+ expect("/frozen/12/john").to match(mapping.match)
278
+ end
279
+
203
280
  it "properly passes basic HTTP auth credentials" do
204
281
  host, credentials, url_path = 'www.example.com', 'user:pass', '/getAll?id=1'
205
282
  ProxyClientExample.base_url "http://#{credentials}@#{host}"
@@ -453,7 +453,7 @@ describe Flexirest::Request do
453
453
  end
454
454
 
455
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("/get-body", "something=else", an_instance_of(Hash)).and_return(::FaradayResponseMock.new(OpenStruct.new(body:'{"result":true}', response_headers:{})))
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
457
  ExampleClient.get_body(something: "else")
458
458
  end
459
459
 
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.5
4
+ version: 1.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Jeffries
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-02-21 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
@@ -192,6 +191,20 @@ dependencies:
192
191
  - - ">="
193
192
  - !ruby/object:Gem::Version
194
193
  version: '0'
194
+ - !ruby/object:Gem::Dependency
195
+ name: ostruct
196
+ requirement: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - ">="
199
+ - !ruby/object:Gem::Version
200
+ version: '0'
201
+ type: :runtime
202
+ prerelease: false
203
+ version_requirements: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - ">="
206
+ - !ruby/object:Gem::Version
207
+ version: '0'
195
208
  - !ruby/object:Gem::Dependency
196
209
  name: mime-types
197
210
  requirement: !ruby/object:Gem::Requirement
@@ -392,7 +405,6 @@ licenses:
392
405
  - MIT
393
406
  metadata:
394
407
  source_code_uri: https://github.com/flexirest/flexirest
395
- post_install_message:
396
408
  rdoc_options: []
397
409
  require_paths:
398
410
  - lib
@@ -407,8 +419,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
407
419
  - !ruby/object:Gem::Version
408
420
  version: '0'
409
421
  requirements: []
410
- rubygems_version: 3.5.21
411
- signing_key:
422
+ rubygems_version: 4.0.10
412
423
  specification_version: 4
413
424
  summary: This gem is for accessing REST services in a flexible way. ActiveResource
414
425
  already exists for this, but it doesn't work where the resource naming doesn't follow