mockserver-client 7.1.0 → 7.2.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 +4 -4
- data/README.md +113 -1
- data/lib/mockserver/binary_launcher.rb +31 -1
- data/lib/mockserver/client.rb +311 -7
- data/lib/mockserver/forward_chain_expectation.rb +16 -6
- data/lib/mockserver/llm.rb +855 -0
- data/lib/mockserver/mcp.rb +453 -0
- data/lib/mockserver/models.rb +427 -11
- data/lib/mockserver/rspec.rb +56 -0
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver-client.rb +2 -0
- metadata +5 -2
data/lib/mockserver/models.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'base64'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'set'
|
|
6
|
+
require 'erb'
|
|
6
7
|
|
|
7
8
|
module MockServer
|
|
8
9
|
# Explicit mapping from Ruby snake_case field names to the camelCase
|
|
@@ -30,6 +31,7 @@ module MockServer
|
|
|
30
31
|
'response_callback' => 'responseCallback',
|
|
31
32
|
'drop_connection' => 'dropConnection',
|
|
32
33
|
'response_bytes' => 'responseBytes',
|
|
34
|
+
'stream_error' => 'streamError',
|
|
33
35
|
'http_request' => 'httpRequest',
|
|
34
36
|
'http_response' => 'httpResponse',
|
|
35
37
|
'http_response_template' => 'httpResponseTemplate',
|
|
@@ -84,7 +86,16 @@ module MockServer
|
|
|
84
86
|
'http_class_callback' => 'httpClassCallback',
|
|
85
87
|
'http_object_callback' => 'httpObjectCallback',
|
|
86
88
|
'failure_policy' => 'failurePolicy',
|
|
87
|
-
'http_responses' => 'httpResponses'
|
|
89
|
+
'http_responses' => 'httpResponses',
|
|
90
|
+
'response_mode' => 'responseMode',
|
|
91
|
+
'response_weights' => 'responseWeights',
|
|
92
|
+
'switch_after' => 'switchAfter',
|
|
93
|
+
'cross_protocol_scenarios' => 'crossProtocolScenarios',
|
|
94
|
+
'scenario_name' => 'scenarioName',
|
|
95
|
+
'scenario_state' => 'scenarioState',
|
|
96
|
+
'new_scenario_state' => 'newScenarioState',
|
|
97
|
+
'match_pattern' => 'matchPattern',
|
|
98
|
+
'target_state' => 'targetState'
|
|
88
99
|
}.freeze
|
|
89
100
|
|
|
90
101
|
REVERSE_FIELD_MAP = FIELD_MAP.invert.freeze
|
|
@@ -119,6 +130,30 @@ module MockServer
|
|
|
119
130
|
hash.reject { |_k, v| v.nil? }
|
|
120
131
|
end
|
|
121
132
|
|
|
133
|
+
# @api private
|
|
134
|
+
# Coerce a class-callback value into an {HttpClassCallback}. Accepts:
|
|
135
|
+
# * +nil+ -> +nil+
|
|
136
|
+
# * a +String+ -> +HttpClassCallback.new(callback_class: <string>)+
|
|
137
|
+
# * an HttpClassCallback -> returned unchanged
|
|
138
|
+
# Any other type raises a TypeError. This lets the Expectation setters and the
|
|
139
|
+
# fluent builder accept either a fully-qualified class-name String or a
|
|
140
|
+
# pre-built {HttpClassCallback} (carrying +delay+ / +primary+).
|
|
141
|
+
def self.coerce_class_callback(value)
|
|
142
|
+
return nil if value.nil?
|
|
143
|
+
return HttpClassCallback.new(callback_class: value) if value.is_a?(String)
|
|
144
|
+
return value if value.is_a?(HttpClassCallback)
|
|
145
|
+
|
|
146
|
+
raise TypeError,
|
|
147
|
+
"Expected a class-name String or HttpClassCallback, got #{value.class.name}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @api private
|
|
151
|
+
# Percent-encode a single URL path segment (e.g. a scenario name), encoding
|
|
152
|
+
# spaces as %20 (not +) so the segment is safe inside +/mockserver/scenario/{name}+.
|
|
153
|
+
def self.encode_path_segment(value)
|
|
154
|
+
ERB::Util.url_encode(value.to_s)
|
|
155
|
+
end
|
|
156
|
+
|
|
122
157
|
# @api private
|
|
123
158
|
def self.serialize_value(value)
|
|
124
159
|
case value
|
|
@@ -188,6 +223,29 @@ module MockServer
|
|
|
188
223
|
end
|
|
189
224
|
end
|
|
190
225
|
|
|
226
|
+
# @api private
|
|
227
|
+
# Unlike headers / query parameters, MockServer represents cookies as a
|
|
228
|
+
# single-value {name => value} object map, not a [{name, values}] array.
|
|
229
|
+
def self.serialize_cookies(items)
|
|
230
|
+
return nil if items.nil?
|
|
231
|
+
|
|
232
|
+
items.each_with_object({}) do |item, map|
|
|
233
|
+
map[item.name] = item.values.is_a?(Array) ? item.values.first : item.values
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @api private
|
|
238
|
+
def self.deserialize_cookies(data)
|
|
239
|
+
return nil if data.nil?
|
|
240
|
+
|
|
241
|
+
if data.is_a?(Hash)
|
|
242
|
+
return data.map { |k, v| KeyToMultiValue.new(name: k, values: v.is_a?(Array) ? v : [v]) }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# tolerate the legacy array form on read
|
|
246
|
+
deserialize_key_multi_values(data)
|
|
247
|
+
end
|
|
248
|
+
|
|
191
249
|
# -------------------------------------------------------------------
|
|
192
250
|
# Model classes
|
|
193
251
|
# -------------------------------------------------------------------
|
|
@@ -564,7 +622,7 @@ module MockServer
|
|
|
564
622
|
'path' => @path,
|
|
565
623
|
'queryStringParameters' => MockServer.serialize_key_multi_values(@query_string_parameters),
|
|
566
624
|
'headers' => MockServer.serialize_key_multi_values(@headers),
|
|
567
|
-
'cookies' => MockServer.
|
|
625
|
+
'cookies' => MockServer.serialize_cookies(@cookies),
|
|
568
626
|
'body' => MockServer.serialize_body(@body),
|
|
569
627
|
'secure' => @secure,
|
|
570
628
|
'keepAlive' => @keep_alive,
|
|
@@ -582,7 +640,7 @@ module MockServer
|
|
|
582
640
|
path: data['path'],
|
|
583
641
|
query_string_parameters: MockServer.deserialize_key_multi_values(data['queryStringParameters']),
|
|
584
642
|
headers: MockServer.deserialize_key_multi_values(data['headers']),
|
|
585
|
-
cookies: MockServer.
|
|
643
|
+
cookies: MockServer.deserialize_cookies(data['cookies']),
|
|
586
644
|
body: MockServer.deserialize_body(data['body']),
|
|
587
645
|
secure: data['secure'],
|
|
588
646
|
keep_alive: data['keepAlive'],
|
|
@@ -714,7 +772,7 @@ module MockServer
|
|
|
714
772
|
'statusCode' => @status_code,
|
|
715
773
|
'reasonPhrase' => @reason_phrase,
|
|
716
774
|
'headers' => MockServer.serialize_key_multi_values(@headers),
|
|
717
|
-
'cookies' => MockServer.
|
|
775
|
+
'cookies' => MockServer.serialize_cookies(@cookies),
|
|
718
776
|
'body' => MockServer.serialize_body(@body),
|
|
719
777
|
'delay' => @delay&.to_h,
|
|
720
778
|
'connectionOptions' => @connection_options&.to_h,
|
|
@@ -729,7 +787,7 @@ module MockServer
|
|
|
729
787
|
status_code: data['statusCode'],
|
|
730
788
|
reason_phrase: data['reasonPhrase'],
|
|
731
789
|
headers: MockServer.deserialize_key_multi_values(data['headers']),
|
|
732
|
-
cookies: MockServer.
|
|
790
|
+
cookies: MockServer.deserialize_cookies(data['cookies']),
|
|
733
791
|
body: MockServer.deserialize_body(data['body']),
|
|
734
792
|
delay: Delay.from_hash(data['delay']),
|
|
735
793
|
connection_options: ConnectionOptions.from_hash(data['connectionOptions']),
|
|
@@ -935,11 +993,15 @@ module MockServer
|
|
|
935
993
|
end
|
|
936
994
|
|
|
937
995
|
class HttpError
|
|
938
|
-
|
|
996
|
+
# stream_error: reset the matched request stream with this error code (HTTP/2 RST_STREAM /
|
|
997
|
+
# HTTP/3 RESET_STREAM) instead of returning a response; HTTP/1.1 has no stream concept so this
|
|
998
|
+
# falls back to dropping the connection. Takes precedence over drop_connection when both are set.
|
|
999
|
+
attr_accessor :drop_connection, :response_bytes, :stream_error, :delay, :primary
|
|
939
1000
|
|
|
940
|
-
def initialize(drop_connection: nil, response_bytes: nil, delay: nil, primary: nil)
|
|
1001
|
+
def initialize(drop_connection: nil, response_bytes: nil, stream_error: nil, delay: nil, primary: nil)
|
|
941
1002
|
@drop_connection = drop_connection
|
|
942
1003
|
@response_bytes = response_bytes
|
|
1004
|
+
@stream_error = stream_error
|
|
943
1005
|
@delay = delay
|
|
944
1006
|
@primary = primary
|
|
945
1007
|
end
|
|
@@ -948,6 +1010,7 @@ module MockServer
|
|
|
948
1010
|
MockServer.strip_none({
|
|
949
1011
|
'dropConnection' => @drop_connection,
|
|
950
1012
|
'responseBytes' => @response_bytes,
|
|
1013
|
+
'streamError' => @stream_error,
|
|
951
1014
|
'delay' => @delay&.to_h,
|
|
952
1015
|
'primary' => @primary
|
|
953
1016
|
})
|
|
@@ -959,6 +1022,7 @@ module MockServer
|
|
|
959
1022
|
new(
|
|
960
1023
|
drop_connection: data['dropConnection'],
|
|
961
1024
|
response_bytes: data['responseBytes'],
|
|
1025
|
+
stream_error: data['streamError'],
|
|
962
1026
|
delay: Delay.from_hash(data['delay']),
|
|
963
1027
|
primary: data['primary']
|
|
964
1028
|
)
|
|
@@ -1680,6 +1744,146 @@ module MockServer
|
|
|
1680
1744
|
end
|
|
1681
1745
|
end
|
|
1682
1746
|
|
|
1747
|
+
# The strategy used to pick which of an expectation's +http_responses+ is
|
|
1748
|
+
# returned for each matching request. Mirrors the core +ResponseMode+ enum.
|
|
1749
|
+
module ResponseMode
|
|
1750
|
+
SEQUENTIAL = 'SEQUENTIAL'
|
|
1751
|
+
RANDOM = 'RANDOM'
|
|
1752
|
+
WEIGHTED = 'WEIGHTED'
|
|
1753
|
+
SWITCH = 'SWITCH'
|
|
1754
|
+
end
|
|
1755
|
+
|
|
1756
|
+
# The protocol event that advances a cross-protocol scenario. Mirrors the
|
|
1757
|
+
# core +CrossProtocolTrigger+ enum.
|
|
1758
|
+
module CrossProtocolTrigger
|
|
1759
|
+
DNS_QUERY = 'DNS_QUERY'
|
|
1760
|
+
WEBSOCKET_CONNECT = 'WEBSOCKET_CONNECT'
|
|
1761
|
+
GRPC_REQUEST = 'GRPC_REQUEST'
|
|
1762
|
+
HTTP_REQUEST = 'HTTP_REQUEST'
|
|
1763
|
+
end
|
|
1764
|
+
|
|
1765
|
+
# Describes a cross-protocol scenario correlation: when a protocol event
|
|
1766
|
+
# matching +trigger+ (and optionally +match_pattern+) is observed, the named
|
|
1767
|
+
# scenario +scenario_name+ is advanced to +target_state+.
|
|
1768
|
+
class CrossProtocolScenario
|
|
1769
|
+
attr_accessor :trigger, :match_pattern, :scenario_name, :target_state
|
|
1770
|
+
|
|
1771
|
+
def initialize(trigger: nil, match_pattern: nil, scenario_name: nil, target_state: nil)
|
|
1772
|
+
@trigger = trigger
|
|
1773
|
+
@match_pattern = match_pattern
|
|
1774
|
+
@scenario_name = scenario_name
|
|
1775
|
+
@target_state = target_state
|
|
1776
|
+
end
|
|
1777
|
+
|
|
1778
|
+
def to_h
|
|
1779
|
+
MockServer.strip_none({
|
|
1780
|
+
'trigger' => @trigger,
|
|
1781
|
+
'matchPattern' => @match_pattern,
|
|
1782
|
+
'scenarioName' => @scenario_name,
|
|
1783
|
+
'targetState' => @target_state
|
|
1784
|
+
})
|
|
1785
|
+
end
|
|
1786
|
+
|
|
1787
|
+
def self.from_hash(data)
|
|
1788
|
+
return nil if data.nil?
|
|
1789
|
+
|
|
1790
|
+
new(
|
|
1791
|
+
trigger: data['trigger'],
|
|
1792
|
+
match_pattern: data['matchPattern'],
|
|
1793
|
+
scenario_name: data['scenarioName'],
|
|
1794
|
+
target_state: data['targetState']
|
|
1795
|
+
)
|
|
1796
|
+
end
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
# The state of a single named scenario as reported by the server's scenario
|
|
1800
|
+
# control plane. +scenario_name+ is the state-machine name and +current_state+
|
|
1801
|
+
# is its current state (+nil+ if not yet set).
|
|
1802
|
+
class ScenarioState
|
|
1803
|
+
attr_accessor :scenario_name, :current_state, :next_state, :transition_after_ms
|
|
1804
|
+
|
|
1805
|
+
def initialize(scenario_name: nil, current_state: nil, next_state: nil, transition_after_ms: nil)
|
|
1806
|
+
@scenario_name = scenario_name
|
|
1807
|
+
@current_state = current_state
|
|
1808
|
+
@next_state = next_state
|
|
1809
|
+
@transition_after_ms = transition_after_ms
|
|
1810
|
+
end
|
|
1811
|
+
|
|
1812
|
+
def to_h
|
|
1813
|
+
MockServer.strip_none({
|
|
1814
|
+
'scenarioName' => @scenario_name,
|
|
1815
|
+
'currentState' => @current_state,
|
|
1816
|
+
'nextState' => @next_state,
|
|
1817
|
+
'transitionAfterMs' => @transition_after_ms
|
|
1818
|
+
})
|
|
1819
|
+
end
|
|
1820
|
+
|
|
1821
|
+
def self.from_hash(data)
|
|
1822
|
+
return nil if data.nil?
|
|
1823
|
+
|
|
1824
|
+
new(
|
|
1825
|
+
scenario_name: data['scenarioName'],
|
|
1826
|
+
current_state: data['currentState'],
|
|
1827
|
+
next_state: data['nextState'],
|
|
1828
|
+
transition_after_ms: data['transitionAfterMs']
|
|
1829
|
+
)
|
|
1830
|
+
end
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
# A handle to a single named stateful scenario on the server, wrapping the
|
|
1834
|
+
# +/mockserver/scenario/{name}+ control-plane endpoints.
|
|
1835
|
+
#
|
|
1836
|
+
# Obtained via {Client#scenario}:
|
|
1837
|
+
# client.scenario('Deploy').set('Deploying', transition_after_ms: 5000, next_state: 'Deployed')
|
|
1838
|
+
# client.scenario('Deploy').trigger('Failed')
|
|
1839
|
+
# client.scenario('Deploy').state # => "Failed"
|
|
1840
|
+
class ScenarioHandle
|
|
1841
|
+
attr_reader :name
|
|
1842
|
+
|
|
1843
|
+
def initialize(client, name)
|
|
1844
|
+
@client = client
|
|
1845
|
+
@name = name
|
|
1846
|
+
end
|
|
1847
|
+
|
|
1848
|
+
# GET the current state of this scenario (+nil+ if not yet set).
|
|
1849
|
+
# @return [String, nil]
|
|
1850
|
+
def state
|
|
1851
|
+
result = @client.scenario_request('GET', scenario_path)
|
|
1852
|
+
result['currentState']
|
|
1853
|
+
end
|
|
1854
|
+
|
|
1855
|
+
# PUT to set this scenario's state, optionally scheduling a timed transition
|
|
1856
|
+
# to +next_state+ after +transition_after_ms+ milliseconds.
|
|
1857
|
+
#
|
|
1858
|
+
# @param state [String] the state to set
|
|
1859
|
+
# @param transition_after_ms [Integer, nil] delay before auto-transitioning
|
|
1860
|
+
# @param next_state [String, nil] the state to auto-transition to
|
|
1861
|
+
# @return [ScenarioState]
|
|
1862
|
+
def set(state, transition_after_ms: nil, next_state: nil)
|
|
1863
|
+
payload = { 'state' => state }
|
|
1864
|
+
payload['transitionAfterMs'] = transition_after_ms unless transition_after_ms.nil?
|
|
1865
|
+
payload['nextState'] = next_state unless next_state.nil?
|
|
1866
|
+
result = @client.scenario_request('PUT', scenario_path, JSON.generate(payload))
|
|
1867
|
+
ScenarioState.from_hash(result)
|
|
1868
|
+
end
|
|
1869
|
+
|
|
1870
|
+
# PUT an external trigger advancing this scenario to +new_state+.
|
|
1871
|
+
#
|
|
1872
|
+
# @param new_state [String]
|
|
1873
|
+
# @return [ScenarioState]
|
|
1874
|
+
def trigger(new_state)
|
|
1875
|
+
body = JSON.generate({ 'newState' => new_state })
|
|
1876
|
+
result = @client.scenario_request('PUT', "#{scenario_path}/trigger", body)
|
|
1877
|
+
ScenarioState.from_hash(result)
|
|
1878
|
+
end
|
|
1879
|
+
|
|
1880
|
+
private
|
|
1881
|
+
|
|
1882
|
+
def scenario_path
|
|
1883
|
+
"/mockserver/scenario/#{MockServer.encode_path_segment(@name)}"
|
|
1884
|
+
end
|
|
1885
|
+
end
|
|
1886
|
+
|
|
1683
1887
|
class Expectation
|
|
1684
1888
|
attr_accessor :id, :priority, :percentage, :http_request, :http_response,
|
|
1685
1889
|
:http_response_template, :http_response_class_callback,
|
|
@@ -1691,7 +1895,8 @@ module MockServer
|
|
|
1691
1895
|
:grpc_stream_response, :grpc_bidi_response,
|
|
1692
1896
|
:binary_response, :dns_response,
|
|
1693
1897
|
:before_actions, :after_actions,
|
|
1694
|
-
:http_responses, :response_mode, :
|
|
1898
|
+
:http_responses, :response_mode, :response_weights, :switch_after,
|
|
1899
|
+
:cross_protocol_scenarios, :steps,
|
|
1695
1900
|
:scenario_name, :scenario_state, :new_scenario_state
|
|
1696
1901
|
|
|
1697
1902
|
def initialize(id: nil, priority: nil, percentage: nil, http_request: nil, http_response: nil,
|
|
@@ -1704,7 +1909,8 @@ module MockServer
|
|
|
1704
1909
|
grpc_stream_response: nil, grpc_bidi_response: nil,
|
|
1705
1910
|
binary_response: nil, dns_response: nil,
|
|
1706
1911
|
before_actions: nil, after_actions: nil,
|
|
1707
|
-
http_responses: nil, response_mode: nil,
|
|
1912
|
+
http_responses: nil, response_mode: nil, response_weights: nil,
|
|
1913
|
+
switch_after: nil, cross_protocol_scenarios: nil, steps: nil,
|
|
1708
1914
|
scenario_name: nil, scenario_state: nil, new_scenario_state: nil)
|
|
1709
1915
|
@id = id
|
|
1710
1916
|
@priority = priority
|
|
@@ -1712,11 +1918,14 @@ module MockServer
|
|
|
1712
1918
|
@http_request = http_request
|
|
1713
1919
|
@http_response = http_response
|
|
1714
1920
|
@http_response_template = http_response_template
|
|
1715
|
-
|
|
1921
|
+
# Route the two class-callback fields through their setters so a bare
|
|
1922
|
+
# class-name String passed to the constructor is coerced to an
|
|
1923
|
+
# HttpClassCallback (matching the setter / fluent-builder behaviour).
|
|
1924
|
+
self.http_response_class_callback = http_response_class_callback
|
|
1716
1925
|
@http_response_object_callback = http_response_object_callback
|
|
1717
1926
|
@http_forward = http_forward
|
|
1718
1927
|
@http_forward_template = http_forward_template
|
|
1719
|
-
|
|
1928
|
+
self.http_forward_class_callback = http_forward_class_callback
|
|
1720
1929
|
@http_forward_object_callback = http_forward_object_callback
|
|
1721
1930
|
@http_override_forwarded_request = http_override_forwarded_request
|
|
1722
1931
|
@http_error = http_error
|
|
@@ -1733,12 +1942,31 @@ module MockServer
|
|
|
1733
1942
|
@after_actions = after_actions
|
|
1734
1943
|
@http_responses = http_responses
|
|
1735
1944
|
@response_mode = response_mode
|
|
1945
|
+
@response_weights = response_weights
|
|
1946
|
+
@switch_after = switch_after
|
|
1947
|
+
@cross_protocol_scenarios = cross_protocol_scenarios
|
|
1736
1948
|
@steps = steps
|
|
1737
1949
|
@scenario_name = scenario_name
|
|
1738
1950
|
@scenario_state = scenario_state
|
|
1739
1951
|
@new_scenario_state = new_scenario_state
|
|
1740
1952
|
end
|
|
1741
1953
|
|
|
1954
|
+
# Set the response class-callback action. Accepts either a fully-qualified
|
|
1955
|
+
# class-name String (e.g. "com.example.MyResponseCallback") or a pre-built
|
|
1956
|
+
# {HttpClassCallback} (carrying an optional +delay+ / +primary+). A String is
|
|
1957
|
+
# wrapped into an {HttpClassCallback} so +to_h+ always emits
|
|
1958
|
+
# +httpResponseClassCallback.callbackClass+.
|
|
1959
|
+
def http_response_class_callback=(value)
|
|
1960
|
+
@http_response_class_callback = MockServer.coerce_class_callback(value)
|
|
1961
|
+
end
|
|
1962
|
+
|
|
1963
|
+
# Set the forward class-callback action. Accepts either a fully-qualified
|
|
1964
|
+
# class-name String or a pre-built {HttpClassCallback}; serialized as
|
|
1965
|
+
# +httpForwardClassCallback+.
|
|
1966
|
+
def http_forward_class_callback=(value)
|
|
1967
|
+
@http_forward_class_callback = MockServer.coerce_class_callback(value)
|
|
1968
|
+
end
|
|
1969
|
+
|
|
1742
1970
|
def to_h
|
|
1743
1971
|
before_actions_h = nil
|
|
1744
1972
|
if @before_actions.is_a?(Array)
|
|
@@ -1779,6 +2007,9 @@ module MockServer
|
|
|
1779
2007
|
'afterActions' => after_actions_h,
|
|
1780
2008
|
'httpResponses' => @http_responses&.map(&:to_h),
|
|
1781
2009
|
'responseMode' => @response_mode,
|
|
2010
|
+
'responseWeights' => @response_weights,
|
|
2011
|
+
'switchAfter' => @switch_after,
|
|
2012
|
+
'crossProtocolScenarios' => @cross_protocol_scenarios&.map(&:to_h),
|
|
1782
2013
|
'steps' => @steps&.map(&:to_h),
|
|
1783
2014
|
'times' => @times&.to_h,
|
|
1784
2015
|
'timeToLive' => @time_to_live&.to_h,
|
|
@@ -1831,6 +2062,9 @@ module MockServer
|
|
|
1831
2062
|
after_actions: after_actions,
|
|
1832
2063
|
http_responses: data['httpResponses']&.map { |r| HttpResponse.from_hash(r) },
|
|
1833
2064
|
response_mode: data['responseMode'],
|
|
2065
|
+
response_weights: data['responseWeights'],
|
|
2066
|
+
switch_after: data['switchAfter'],
|
|
2067
|
+
cross_protocol_scenarios: data['crossProtocolScenarios']&.map { |c| CrossProtocolScenario.from_hash(c) },
|
|
1834
2068
|
steps: data['steps']&.map { |s| ExpectationStep.from_hash(s) },
|
|
1835
2069
|
times: Times.from_hash(data['times']),
|
|
1836
2070
|
time_to_live: TimeToLive.from_hash(data['timeToLive']),
|
|
@@ -2053,6 +2287,188 @@ module MockServer
|
|
|
2053
2287
|
end
|
|
2054
2288
|
end
|
|
2055
2289
|
|
|
2290
|
+
# One stage of a {LoadProfile}, run in sequence. Each stage holds or ramps a
|
|
2291
|
+
# setpoint for +duration_millis+:
|
|
2292
|
+
#
|
|
2293
|
+
# * +VU+ (closed model) - hold +vus+ virtual users, or ramp +start_vus+ to
|
|
2294
|
+
# +end_vus+ along +curve+.
|
|
2295
|
+
# * +RATE+ (open model) - hold +rate+ iterations/second, or ramp +start_rate+
|
|
2296
|
+
# to +end_rate+ along +curve+, optionally capping the auto-scaling
|
|
2297
|
+
# virtual-user pool at +max_vus+.
|
|
2298
|
+
# * +PAUSE+ - drive no load for +duration_millis+.
|
|
2299
|
+
#
|
|
2300
|
+
# Prefer the {.vu}, {.rate} and {.pause} factories, which emit only the fields
|
|
2301
|
+
# relevant to the stage type and mode.
|
|
2302
|
+
class LoadStage
|
|
2303
|
+
attr_accessor :type, :duration_millis, :curve, :vus, :start_vus, :end_vus,
|
|
2304
|
+
:rate, :start_rate, :end_rate, :max_vus
|
|
2305
|
+
|
|
2306
|
+
def initialize(type:, duration_millis:, curve: nil, vus: nil, start_vus: nil,
|
|
2307
|
+
end_vus: nil, rate: nil, start_rate: nil, end_rate: nil, max_vus: nil)
|
|
2308
|
+
@type = type
|
|
2309
|
+
@duration_millis = duration_millis
|
|
2310
|
+
@curve = curve
|
|
2311
|
+
@vus = vus
|
|
2312
|
+
@start_vus = start_vus
|
|
2313
|
+
@end_vus = end_vus
|
|
2314
|
+
@rate = rate
|
|
2315
|
+
@start_rate = start_rate
|
|
2316
|
+
@end_rate = end_rate
|
|
2317
|
+
@max_vus = max_vus
|
|
2318
|
+
end
|
|
2319
|
+
|
|
2320
|
+
# A +VU+ (closed-model) stage - hold +vus+ or ramp +start_vus+ to +end_vus+.
|
|
2321
|
+
def self.vu(duration_millis, vus: nil, start_vus: nil, end_vus: nil, curve: nil)
|
|
2322
|
+
new(type: 'VU', duration_millis: duration_millis, vus: vus,
|
|
2323
|
+
start_vus: start_vus, end_vus: end_vus, curve: curve)
|
|
2324
|
+
end
|
|
2325
|
+
|
|
2326
|
+
# A +RATE+ (open-model) stage - hold +rate+ or ramp +start_rate+ to
|
|
2327
|
+
# +end_rate+ (iterations/second).
|
|
2328
|
+
def self.rate(duration_millis, rate: nil, start_rate: nil, end_rate: nil, max_vus: nil, curve: nil)
|
|
2329
|
+
new(type: 'RATE', duration_millis: duration_millis, rate: rate,
|
|
2330
|
+
start_rate: start_rate, end_rate: end_rate, max_vus: max_vus, curve: curve)
|
|
2331
|
+
end
|
|
2332
|
+
|
|
2333
|
+
# A +PAUSE+ stage - drive no load for +duration_millis+.
|
|
2334
|
+
def self.pause(duration_millis)
|
|
2335
|
+
new(type: 'PAUSE', duration_millis: duration_millis)
|
|
2336
|
+
end
|
|
2337
|
+
|
|
2338
|
+
def to_h
|
|
2339
|
+
MockServer.strip_none({
|
|
2340
|
+
'type' => @type,
|
|
2341
|
+
'durationMillis' => @duration_millis,
|
|
2342
|
+
'curve' => @curve,
|
|
2343
|
+
'vus' => @vus,
|
|
2344
|
+
'startVus' => @start_vus,
|
|
2345
|
+
'endVus' => @end_vus,
|
|
2346
|
+
'rate' => @rate,
|
|
2347
|
+
'startRate' => @start_rate,
|
|
2348
|
+
'endRate' => @end_rate,
|
|
2349
|
+
'maxVus' => @max_vus
|
|
2350
|
+
})
|
|
2351
|
+
end
|
|
2352
|
+
|
|
2353
|
+
def self.from_hash(data)
|
|
2354
|
+
return nil if data.nil?
|
|
2355
|
+
|
|
2356
|
+
new(
|
|
2357
|
+
type: data['type'],
|
|
2358
|
+
duration_millis: data['durationMillis'],
|
|
2359
|
+
curve: data['curve'],
|
|
2360
|
+
vus: data['vus'],
|
|
2361
|
+
start_vus: data['startVus'],
|
|
2362
|
+
end_vus: data['endVus'],
|
|
2363
|
+
rate: data['rate'],
|
|
2364
|
+
start_rate: data['startRate'],
|
|
2365
|
+
end_rate: data['endRate'],
|
|
2366
|
+
max_vus: data['maxVus']
|
|
2367
|
+
)
|
|
2368
|
+
end
|
|
2369
|
+
end
|
|
2370
|
+
|
|
2371
|
+
# The traffic-shaping profile of a load scenario: an ordered list of {LoadStage}
|
|
2372
|
+
# objects run in sequence, each holding or ramping a setpoint (virtual users, an
|
|
2373
|
+
# arrival rate, or a pause) for its duration. The total run length is the sum of
|
|
2374
|
+
# the stage durations.
|
|
2375
|
+
class LoadProfile
|
|
2376
|
+
attr_accessor :stages
|
|
2377
|
+
|
|
2378
|
+
def initialize(stages: [])
|
|
2379
|
+
@stages = stages || []
|
|
2380
|
+
end
|
|
2381
|
+
|
|
2382
|
+
def to_h
|
|
2383
|
+
{ 'stages' => @stages.map(&:to_h) }
|
|
2384
|
+
end
|
|
2385
|
+
|
|
2386
|
+
def self.from_hash(data)
|
|
2387
|
+
return nil if data.nil?
|
|
2388
|
+
|
|
2389
|
+
stages_data = data['stages'] || []
|
|
2390
|
+
new(stages: stages_data.map { |s| LoadStage.from_hash(s) })
|
|
2391
|
+
end
|
|
2392
|
+
end
|
|
2393
|
+
|
|
2394
|
+
# A single step within a load scenario. Each step fires +request+ (an HttpRequest)
|
|
2395
|
+
# against the target, optionally pausing for +think_time+ (a Delay) afterwards.
|
|
2396
|
+
class LoadStep
|
|
2397
|
+
attr_accessor :name, :labels, :think_time, :request
|
|
2398
|
+
|
|
2399
|
+
def initialize(request:, name: nil, labels: nil, think_time: nil)
|
|
2400
|
+
@request = request
|
|
2401
|
+
@name = name
|
|
2402
|
+
@labels = labels
|
|
2403
|
+
@think_time = think_time
|
|
2404
|
+
end
|
|
2405
|
+
|
|
2406
|
+
def to_h
|
|
2407
|
+
MockServer.strip_none({
|
|
2408
|
+
'name' => @name,
|
|
2409
|
+
'labels' => @labels,
|
|
2410
|
+
'thinkTime' => @think_time&.to_h,
|
|
2411
|
+
'request' => @request&.to_h
|
|
2412
|
+
})
|
|
2413
|
+
end
|
|
2414
|
+
|
|
2415
|
+
def self.from_hash(data)
|
|
2416
|
+
return nil if data.nil?
|
|
2417
|
+
|
|
2418
|
+
new(
|
|
2419
|
+
name: data['name'],
|
|
2420
|
+
labels: data['labels'],
|
|
2421
|
+
think_time: Delay.from_hash(data['thinkTime']),
|
|
2422
|
+
request: HttpRequest.from_hash(data['request'])
|
|
2423
|
+
)
|
|
2424
|
+
end
|
|
2425
|
+
end
|
|
2426
|
+
|
|
2427
|
+
# A load-injection scenario: a named set of +steps+ driven by a traffic +profile+.
|
|
2428
|
+
# +template_type+ selects the templating engine (+VELOCITY+ or +MUSTACHE+) used to
|
|
2429
|
+
# render step requests; +max_requests+ caps the total requests issued.
|
|
2430
|
+
class LoadScenario
|
|
2431
|
+
attr_accessor :name, :template_type, :labels, :max_requests, :start_delay_millis, :profile, :steps
|
|
2432
|
+
|
|
2433
|
+
def initialize(name:, profile:, steps:, template_type: nil, labels: nil, max_requests: nil,
|
|
2434
|
+
start_delay_millis: nil)
|
|
2435
|
+
@name = name
|
|
2436
|
+
@profile = profile
|
|
2437
|
+
@steps = steps
|
|
2438
|
+
@template_type = template_type
|
|
2439
|
+
@labels = labels
|
|
2440
|
+
@max_requests = max_requests
|
|
2441
|
+
@start_delay_millis = start_delay_millis
|
|
2442
|
+
end
|
|
2443
|
+
|
|
2444
|
+
def to_h
|
|
2445
|
+
MockServer.strip_none({
|
|
2446
|
+
'name' => @name,
|
|
2447
|
+
'templateType' => @template_type,
|
|
2448
|
+
'labels' => @labels,
|
|
2449
|
+
'maxRequests' => @max_requests,
|
|
2450
|
+
'startDelayMillis' => @start_delay_millis,
|
|
2451
|
+
'profile' => @profile&.to_h,
|
|
2452
|
+
'steps' => @steps&.map(&:to_h)
|
|
2453
|
+
})
|
|
2454
|
+
end
|
|
2455
|
+
|
|
2456
|
+
def self.from_hash(data)
|
|
2457
|
+
return nil if data.nil?
|
|
2458
|
+
|
|
2459
|
+
steps_data = data['steps']
|
|
2460
|
+
new(
|
|
2461
|
+
name: data['name'],
|
|
2462
|
+
template_type: data['templateType'],
|
|
2463
|
+
labels: data['labels'],
|
|
2464
|
+
max_requests: data['maxRequests'],
|
|
2465
|
+
start_delay_millis: data['startDelayMillis'],
|
|
2466
|
+
profile: LoadProfile.from_hash(data['profile']),
|
|
2467
|
+
steps: steps_data ? steps_data.map { |s| LoadStep.from_hash(s) } : nil
|
|
2468
|
+
)
|
|
2469
|
+
end
|
|
2470
|
+
end
|
|
2471
|
+
|
|
2056
2472
|
# Alias matching the Python client
|
|
2057
2473
|
RequestDefinition = HttpRequest
|
|
2058
2474
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'client'
|
|
4
|
+
|
|
5
|
+
module MockServer
|
|
6
|
+
# RSpec integration helpers for MockServer.
|
|
7
|
+
#
|
|
8
|
+
# Require this file from your `spec_helper.rb` to get a shared context that
|
|
9
|
+
# provides a fresh {MockServer::Client} per example and automatically resets
|
|
10
|
+
# the server between examples, so recorded requests, expectations and logs
|
|
11
|
+
# never leak from one example to the next:
|
|
12
|
+
#
|
|
13
|
+
# require 'mockserver/rspec'
|
|
14
|
+
#
|
|
15
|
+
# RSpec.describe 'my integration', :mockserver do
|
|
16
|
+
# it 'records the request' do
|
|
17
|
+
# # `mockserver` is the shared, reset client
|
|
18
|
+
# mockserver.when(MockServer::HttpRequest.request(path: '/hello'))
|
|
19
|
+
# .respond(MockServer::HttpResponse.response(body: 'world'))
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# The host and port default to `127.0.0.1:1080` and can be overridden with the
|
|
24
|
+
# `MOCKSERVER_HOST` / `MOCKSERVER_PORT` environment variables, or by defining a
|
|
25
|
+
# `mockserver_host` / `mockserver_port` `let` in your example group.
|
|
26
|
+
module RSpec
|
|
27
|
+
SHARED_CONTEXT_NAME = 'mockserver client'
|
|
28
|
+
|
|
29
|
+
if defined?(::RSpec)
|
|
30
|
+
::RSpec.shared_context SHARED_CONTEXT_NAME do
|
|
31
|
+
let(:mockserver_host) { ENV.fetch('MOCKSERVER_HOST', '127.0.0.1') }
|
|
32
|
+
let(:mockserver_port) { Integer(ENV.fetch('MOCKSERVER_PORT', '1080')) }
|
|
33
|
+
|
|
34
|
+
# A fresh client per example. Memoised so the same instance is returned
|
|
35
|
+
# within a single example, but rebuilt for the next one.
|
|
36
|
+
let(:mockserver) { MockServer::Client.new(mockserver_host, mockserver_port) }
|
|
37
|
+
|
|
38
|
+
# Start each example from a clean server and tear down again afterwards,
|
|
39
|
+
# closing any websocket connections opened by the client.
|
|
40
|
+
before do
|
|
41
|
+
mockserver.reset
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
after do
|
|
45
|
+
mockserver.reset
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Make the shared context available to any example group tagged
|
|
50
|
+
# `:mockserver` without an explicit `include_context`.
|
|
51
|
+
::RSpec.configure do |config|
|
|
52
|
+
config.include_context SHARED_CONTEXT_NAME, :mockserver
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/mockserver/version.rb
CHANGED
data/lib/mockserver-client.rb
CHANGED
|
@@ -6,4 +6,6 @@ require_relative 'mockserver/models'
|
|
|
6
6
|
require_relative 'mockserver/websocket_client'
|
|
7
7
|
require_relative 'mockserver/forward_chain_expectation'
|
|
8
8
|
require_relative 'mockserver/client'
|
|
9
|
+
require_relative 'mockserver/llm'
|
|
10
|
+
require_relative 'mockserver/mcp'
|
|
9
11
|
require_relative 'mockserver/binary_launcher'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mockserver-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 7.
|
|
4
|
+
version: 7.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Bloom
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -95,7 +95,10 @@ files:
|
|
|
95
95
|
- lib/mockserver/client.rb
|
|
96
96
|
- lib/mockserver/errors.rb
|
|
97
97
|
- lib/mockserver/forward_chain_expectation.rb
|
|
98
|
+
- lib/mockserver/llm.rb
|
|
99
|
+
- lib/mockserver/mcp.rb
|
|
98
100
|
- lib/mockserver/models.rb
|
|
101
|
+
- lib/mockserver/rspec.rb
|
|
99
102
|
- lib/mockserver/version.rb
|
|
100
103
|
- lib/mockserver/websocket_client.rb
|
|
101
104
|
- mockserver-client.gemspec
|