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 +4 -4
- data/.github/workflows/build.yml +1 -1
- data/CHANGELOG.md +15 -1
- data/Rakefile +5 -0
- data/flexirest.gemspec +3 -0
- data/lib/flexirest/associations.rb +8 -4
- data/lib/flexirest/connection.rb +12 -0
- data/lib/flexirest/instrumentation.rb +9 -6
- data/lib/flexirest/json_api_proxy.rb +20 -4
- data/lib/flexirest/logger.rb +32 -38
- data/lib/flexirest/proxy_base.rb +73 -43
- data/lib/flexirest/request.rb +5 -2
- data/lib/flexirest/version.rb +1 -1
- data/lib/flexirest.rb +4 -0
- data/spec/lib/associations_spec.rb +31 -0
- data/spec/lib/callbacks_spec.rb +5 -1
- data/spec/lib/connection_manager_spec.rb +23 -4
- data/spec/lib/json_api_spec.rb +61 -0
- data/spec/lib/proxy_spec.rb +77 -0
- data/spec/lib/request_spec.rb +1 -1
- data/spec/spec_helper.rb +0 -2
- metadata +17 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 152b5d789cff3ee4ccd09c6090d4f5bee5b431a96fb34797d5d189882585c235
|
|
4
|
+
data.tar.gz: 91c220948c05caec4b7f625d618c1b3e180272013a8dee3157bd66cbaf039bbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7440c4c8b5122e296360ece864f8ffd9ba59ae83429badd94c587ce700ecab20e823b3cff166c2623ecde5a5f5697737caf1684ac3237f5b20a63213d2dc42e0
|
|
7
|
+
data.tar.gz: e54a1e79e49dca9a29ed9c6d04ee43ea7a0da4943b6b873ff30d9df8377605d418a84ad391cccaad60d65102f9d43936b191bb2a5f6c4f8889ca011381e7a529
|
data/.github/workflows/build.yml
CHANGED
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
|
|
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
|
-
|
|
60
|
+
(Thread.current[:flexirest_include_associations] ||= {})[self] = keys
|
|
56
61
|
self
|
|
57
62
|
end
|
|
58
63
|
|
|
59
64
|
def _include_associations
|
|
60
|
-
|
|
65
|
+
(Thread.current[:flexirest_include_associations] ||= {})[self] || []
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
def _reset_include_associations!
|
|
64
|
-
|
|
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
|
data/lib/flexirest/connection.rb
CHANGED
|
@@ -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
|
-
|
|
14
|
+
Thread.current[:flexirest_time_spent] = value
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def self.time_spent
|
|
15
|
-
|
|
18
|
+
Thread.current[:flexirest_time_spent] ||= 0
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def self.calls_made=(value)
|
|
19
|
-
|
|
22
|
+
Thread.current[:flexirest_calls_made] = value
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def self.calls_made
|
|
23
|
-
|
|
26
|
+
Thread.current[:flexirest_calls_made] ||= 0
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def self.reset
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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)
|
data/lib/flexirest/logger.rb
CHANGED
|
@@ -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
|
-
@
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
@
|
|
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
|
data/lib/flexirest/proxy_base.rb
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
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
|
-
|
|
45
|
-
|
|
56
|
+
_proxy_state[:body] = value if value
|
|
57
|
+
_proxy_state[:body]
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
def url(value = nil)
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
_proxy_state[:url] = value if value
|
|
62
|
+
_proxy_state[:url]
|
|
51
63
|
end
|
|
52
64
|
|
|
53
65
|
def get_params(value = nil)
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
elsif
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
135
|
+
state = states[self] = {}
|
|
136
|
+
state[:request] = request
|
|
137
|
+
state[:original_handler] = block
|
|
118
138
|
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
state[:original_body] = request.body
|
|
140
|
+
state[:body] = state[:original_body].dup
|
|
121
141
|
|
|
122
|
-
|
|
123
|
-
|
|
142
|
+
state[:original_get_params] = request.get_params
|
|
143
|
+
state[:get_params] = state[:original_get_params].dup
|
|
124
144
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
165
|
+
state = _proxy_state
|
|
166
|
+
uri = URI.parse(state[:original_url])
|
|
137
167
|
@mappings ||= []
|
|
138
|
-
|
|
168
|
+
state[:params] = {}
|
|
139
169
|
@mappings.each do |mapping|
|
|
140
170
|
match = mapping.match
|
|
141
|
-
if (match_data = uri.path.match(match)) &&
|
|
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
|
-
|
|
175
|
+
state[:params][mapping.param_keys[index]] = value
|
|
146
176
|
end
|
|
147
177
|
return mapping
|
|
148
178
|
end
|
data/lib/flexirest/request.rb
CHANGED
|
@@ -426,7 +426,10 @@ module Flexirest
|
|
|
426
426
|
def prepare_url
|
|
427
427
|
missing = []
|
|
428
428
|
if @forced_url && @forced_url.present?
|
|
429
|
-
|
|
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
|
-
|
|
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
|
data/lib/flexirest/version.rb
CHANGED
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
|
|
data/spec/lib/callbacks_spec.rb
CHANGED
|
@@ -42,7 +42,11 @@ class SubClassedCallbacksExample < CallbacksExample
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
describe Flexirest::Callbacks do
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/spec/lib/json_api_spec.rb
CHANGED
|
@@ -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
|
data/spec/lib/proxy_spec.rb
CHANGED
|
@@ -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}"
|
data/spec/lib/request_spec.rb
CHANGED
|
@@ -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(:
|
|
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
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.
|
|
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:
|
|
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:
|
|
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
|