mockserver-client 9.1.0 → 9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b414bb7cc138b2e2a46b674e67743d1145bc004bc7a62be5d25e75fab95409f
4
- data.tar.gz: 8d11ed79c2d777ab576c4181e9cb2c6b2730c352afc532d5df4070e9accddaf6
3
+ metadata.gz: 02d995291d2bc1c0c4b875711f568d604c4e6af009952512109b44ff49fa2fed
4
+ data.tar.gz: 455d49792d3fec33d41dd66eade0ca64e5ee4049306566c411da1008a431b30a
5
5
  SHA512:
6
- metadata.gz: f0b346b83cbc825456b2dc85b6b1462e1fa3610f779f9037e77ba2f40f4a780e9c64a793d80df95dbceb0b7d67b57805cce281d75fa8ba36114edb7e35851892
7
- data.tar.gz: 5bde30af6fe4e94208ac4a275e52309d77e1d78a94d74ce152c993a656e8db00d98adc88f9556c21f2c1d3939b180e2f7ac9588195a4462a9a99ab035883eaac
6
+ metadata.gz: 71d509505576e64dd73a68b79352bbdd0065519b5775767025340798032832bccffa5806b5bac0cda568286b0202baf7af48f84155a095df301df3c60e6be500
7
+ data.tar.gz: 5d6c9b72e335529472b51013bfbaad1d521be2784267d95a48284d2a777577a19ccd87b5f68eedae3bab6195e774d80880e4712f4f289993384d84309433cd92
data/README.md CHANGED
@@ -56,6 +56,15 @@ end
56
56
  # Client is automatically closed when the block exits
57
57
  ```
58
58
 
59
+ ## Control-Plane Basic Authentication
60
+
61
+ When MockServer accepts control-plane Basic authentication, pass `control_plane_basic_auth: [username, password]` with `secure: true` (MockServer refuses Basic credentials without TLS). They are sent on every control-plane request and on the callback and breakpoint WebSockets.
62
+
63
+ ```ruby
64
+ client = MockServer::Client.new('localhost', 1080, secure: true,
65
+ control_plane_basic_auth: ['admin', ENV.fetch('MOCKSERVER_PASSWORD')])
66
+ ```
67
+
59
68
  ## WebSocket Callbacks
60
69
 
61
70
  ```ruby
@@ -99,6 +108,7 @@ All 27 domain model classes are available under the `MockServer` module:
99
108
  - `Ports`
100
109
  - `RequestDefinition` (alias for `HttpRequest`)
101
110
  - `Jwt` (JWT / bearer-token request matcher)
111
+ - `DateTimeMatcher` (date-time matcher for header, query, path-parameter and cookie values)
102
112
 
103
113
  Every model can also be built from the expectation JSON with `from_hash`. Keys
104
114
  may be strings (`'httpRequest' => {...}`, as `JSON.parse` returns them) or
@@ -129,6 +139,41 @@ MockServer::HttpRequest.new(
129
139
  )
130
140
  ```
131
141
 
142
+ A header, query parameter, path parameter or cookie value can also be a
143
+ date-time matcher. It compares the request's value as a date-time with
144
+ `before`, `after` or `equal_to_date_time`, where the expected value is `now`,
145
+ `now` with an offset such as `now -1 hours`, or a date-time such as
146
+ `2026-10-21T00:00:00Z`. A plain string such as `'before:2026-01-01'` is still
147
+ matched literally.
148
+
149
+ ```ruby
150
+ MockServer::HttpRequest.new(path: '/orders')
151
+ .with_header('X-Issued-At', MockServer::DateTimeMatcher.after('now -1 hours'))
152
+ .with_header('If-Modified-Since',
153
+ MockServer::DateTimeMatcher.before('2026-10-21T00:00:00Z').with_actual_format('rfc1123'))
154
+ .with_cookie('seen', MockServer::DateTimeMatcher.equal_to_date_time('now')
155
+ .with_truncate_expected('startOfDay')
156
+ .with_truncate_actual('startOfDay')
157
+ .with_zone('Europe/London'))
158
+ # headers => [{"name"=>"X-Issued-At", "values"=>[{"after"=>"now -1 hours"}]}, ...]
159
+ ```
160
+
161
+ Each `with_*` option (`with_actual_format`, `with_truncate_expected`,
162
+ `with_truncate_actual`, `with_apply_truncation_last`, `with_zone`, `with_not`)
163
+ returns a new matcher. `from_hash` reads an object holding `before`, `after` or
164
+ `equalToDateTime` as a `DateTimeMatcher`.
165
+
166
+ A JSONPath or XPath body matcher takes a `date_time:` too: the body matches when
167
+ a value the JSONPath selects, or the text of a node the XPath selects, is a
168
+ matching date-time.
169
+
170
+ ```ruby
171
+ MockServer::HttpRequest.new(path: '/orders',
172
+ body: MockServer::Body.json_path('$.order.created',
173
+ date_time: MockServer::DateTimeMatcher.after('now -1 days')))
174
+ # body => {"type"=>"JSON_PATH", "jsonPath"=>"$.order.created", "dateTime"=>{"after"=>"now -1 days"}}
175
+ ```
176
+
132
177
  An `httpRequest` holding `specUrlOrPayload` is read as an `OpenAPIDefinition`
133
178
  matcher, and `HttpOverrideForwardedRequest` carries `request_override`,
134
179
  `response_override` and `response_template`. These need a client released
@@ -185,6 +230,28 @@ body = MockServer::Body.all_of(
185
230
  request = MockServer::HttpRequest.new(method: 'POST', path: '/api').with_body(body)
186
231
  ```
187
232
 
233
+ ## Client IP matcher
234
+
235
+ Match requests by the client IP address or CIDR range, IPv4 or IPv6. The client address is the TCP peer of the connection, not `X-Forwarded-For`; prefix with `!` to match every other address.
236
+
237
+ ```ruby
238
+ MockServer::HttpRequest.new(path: '/internal').with_client_ip('10.0.0.0/8')
239
+ # or MockServer::HttpRequest.new(path: '/internal', client_ip: '!10.0.0.0/8')
240
+ ```
241
+
242
+ ## Any-of (logical OR) matcher
243
+
244
+ `MockServer::AnyOfRequestDefinition` matches a request when any one of its request matchers does, tried in order. Entries may be `HttpRequest`, `OpenAPIDefinition`, `ConditionalRequestDefinition` or a nested `AnyOfRequestDefinition`; `not_any_of: true` matches requests that match none of them. `ExplainedExpectation#any_of` says which entry came closest when none matches.
245
+
246
+ ```ruby
247
+ client.when(
248
+ MockServer::AnyOfRequestDefinition.new(
249
+ any_of: [MockServer::HttpRequest.new(method: 'GET', path: '/pets'),
250
+ MockServer::HttpRequest.new(method: 'GET', path: '/animals')]
251
+ )
252
+ ).respond(MockServer::HttpResponse.new(body: 'pets or animals'))
253
+ ```
254
+
188
255
  ## LLM Mocking
189
256
 
190
257
  Fluent builders under `MockServer::LLM` mock LLM provider endpoints. They produce
@@ -300,6 +367,40 @@ JSON-RPC task response to the caller *and* POSTs the completed task to the
300
367
  webhook URL. As with MCP mocking, `build` returns the raw expectation Hashes if
301
368
  you prefer to register them yourself.
302
369
 
370
+ ## Expectation Metadata
371
+
372
+ Label expectations with `metadata` (String keys, any JSON value), then retrieve or clear them by
373
+ metadata, find the ones that never matched a request, and page large lists:
374
+
375
+ ```ruby
376
+ client.when(MockServer::HttpRequest.new(path: '/checkout'))
377
+ .with_metadata('owner' => 'team-a', 'ticket' => 4211, 'tags' => ['smoke'])
378
+ .respond(MockServer::HttpResponse.new(status_code: 200))
379
+
380
+ client.retrieve_active_expectations_by_metadata('owner', 'team-a')
381
+ client.retrieve_active_expectations_by_metadata_key('ticket')
382
+ client.retrieve_unmatched_expectations
383
+ client.retrieve_active_expectations(
384
+ filter: MockServer::ExpectationFilter.new.with_metadata_json_path("$.tags[?(@ == 'smoke')]").with_limit(10)
385
+ )
386
+ client.retrieve_recorded_expectations(offset: 0, limit: 50)
387
+ # replay changing responses in order, keep only some request headers, move large bodies into files
388
+ client.retrieve_recorded_expectations(
389
+ options: MockServer::RecordedExpectationsOptions.new.with_repeats_as_scenarios
390
+ .with_capture_headers('X-Tenant')
391
+ .with_extract_bodies_larger_than(1024)
392
+ )
393
+
394
+ client.clear_by_metadata('owner', 'team-a')
395
+ client.clear_by_metadata_key('ticket')
396
+ client.clear_unmatched_expectations
397
+ ```
398
+
399
+ A String value is matched as text; any other value (`42`, `true`, an Array or Hash) is matched as
400
+ JSON, and `MockServer::ExpectationFilter::JSON_NULL` selects a stored JSON null. A blank key or
401
+ JSONPath, a `nil` value, negative paging, paging on clear, or a clear with no selection raises
402
+ `ArgumentError` before anything is sent, so a mistake can never clear every expectation.
403
+
303
404
  ## SRE Control Plane (SLO + Chaos)
304
405
 
305
406
  Verify service-level objectives and drive scheduled chaos experiments against
@@ -417,7 +518,7 @@ launcher_path = MockServer::BinaryLauncher.ensure_launcher
417
518
  ### Specify a version
418
519
 
419
520
  ```ruby
420
- handle = MockServer::BinaryLauncher.start(port: 1080, version: '9.1.0')
521
+ handle = MockServer::BinaryLauncher.start(port: 1080, version: '9.2.0')
421
522
  ```
422
523
 
423
524
  ### API reference
@@ -4,6 +4,7 @@ require 'net/http'
4
4
  require 'uri'
5
5
  require 'json'
6
6
  require 'openssl'
7
+ require 'base64'
7
8
 
8
9
  module MockServer
9
10
  # Synchronous MockServer client.
@@ -28,14 +29,28 @@ module MockServer
28
29
  class Client
29
30
  HTTP_TIMEOUT = 60 # seconds, matching Python client
30
31
 
32
+ # Default for clear's +filter:+ so an explicit nil filter is rejected
33
+ # instead of silently clearing everything.
34
+ NO_FILTER = Object.new.freeze
35
+ private_constant :NO_FILTER
36
+
31
37
  # @param host [String]
32
38
  # @param port [Integer]
33
39
  # @param context_path [String]
34
40
  # @param secure [Boolean]
35
41
  # @param ca_cert_path [String, nil]
36
42
  # @param tls_verify [Boolean]
43
+ # @param control_plane_basic_auth [Array(String, String), nil] control-plane HTTP Basic
44
+ # +[username, password]+, sent on every control-plane request and on the callback and
45
+ # breakpoint WebSockets; MockServer refuses Basic credentials without TLS, so use +secure: true+
37
46
  def initialize(host, port, context_path: '', secure: false,
38
- ca_cert_path: nil, tls_verify: true)
47
+ ca_cert_path: nil, tls_verify: true, control_plane_basic_auth: nil)
48
+ @control_plane_authorization = basic_authorization(control_plane_basic_auth)
49
+ if @control_plane_authorization && !secure
50
+ warn 'MockServer::Client: control-plane Basic credentials are configured without secure: true, so the password ' \
51
+ 'can be read on the network, and MockServer refuses them unless controlPlaneBasicAuthenticationAllowInsecure ' \
52
+ 'is set (tests only)'
53
+ end
39
54
  @host = host
40
55
  @port = port
41
56
  @context_path = context_path
@@ -100,10 +115,19 @@ module MockServer
100
115
  end
101
116
 
102
117
  # Clear expectations and/or logs.
118
+ #
119
+ # With +filter:+ only the expectations matching both +request+ (when
120
+ # given) and the filter's metadata / unmatched selection are cleared; the
121
+ # event log is left untouched. A filter must select something and must not
122
+ # page, otherwise ArgumentError is raised and nothing is sent.
123
+ #
103
124
  # @param request [HttpRequest, nil]
104
125
  # @param type [String, nil] "EXPECTATIONS", "LOG", or "ALL"
126
+ # @param filter [ExpectationFilter] selection of expectations to clear
105
127
  # @return [nil]
106
- def clear(request = nil, type: nil)
128
+ def clear(request = nil, type: nil, filter: NO_FILTER)
129
+ return clear_with_filter(request, type, filter) unless filter.equal?(NO_FILTER)
130
+
107
131
  query_params = {}
108
132
  query_params['type'] = type if type
109
133
  body = request ? JSON.generate(request.to_h) : ''
@@ -117,6 +141,25 @@ module MockServer
117
141
  nil
118
142
  end
119
143
 
144
+ # Clear the expectations whose metadata has +key+ with a value equal to
145
+ # +value+ (see {ExpectationFilter#with_metadata}).
146
+ # @return [nil]
147
+ def clear_by_metadata(key, value)
148
+ clear(filter: ExpectationFilter.new.with_metadata(key, value))
149
+ end
150
+
151
+ # Clear the expectations whose metadata has +key+, with any value.
152
+ # @return [nil]
153
+ def clear_by_metadata_key(key)
154
+ clear(filter: ExpectationFilter.new.with_metadata_key(key))
155
+ end
156
+
157
+ # Clear the expectations that have not matched any request.
158
+ # @return [nil]
159
+ def clear_unmatched_expectations
160
+ clear(filter: ExpectationFilter.new.with_unmatched(true))
161
+ end
162
+
120
163
  # Clear by expectation ID.
121
164
  # @param expectation_id [String]
122
165
  # @param type [String, nil]
@@ -265,6 +308,9 @@ module MockServer
265
308
  #
266
309
  # +config_json+ is a ConfigurationDTO JSON document; only the fields present
267
310
  # are applied (partial update). A nil value is sent as an empty body.
311
+ # Properties fixed at startup, such as forwardProxyBlockPrivateNetworks,
312
+ # forwardProxyAllowedHosts and forwardProxyDeniedHosts, are ignored and the
313
+ # server logs a warning.
268
314
  #
269
315
  # @param config_json [String, nil] the ConfigurationDTO JSON
270
316
  # @return [String] the serialized updated configuration JSON
@@ -1174,13 +1220,17 @@ module MockServer
1174
1220
 
1175
1221
  # Retrieve active expectations.
1176
1222
  # @param request [HttpRequest, nil]
1223
+ # @param filter [ExpectationFilter, nil] metadata / unmatched selection and paging
1177
1224
  # @return [Array<Expectation>]
1178
- def retrieve_active_expectations(request: nil)
1225
+ def retrieve_active_expectations(request: nil, filter: nil)
1226
+ unless filter.nil? || filter.is_a?(ExpectationFilter)
1227
+ raise ArgumentError, "filter must be a MockServer::ExpectationFilter, got #{filter.class}"
1228
+ end
1229
+
1179
1230
  body = request ? JSON.generate(request.to_h) : ''
1180
- status, response_body = do_request(
1181
- 'PUT', '/mockserver/retrieve', body,
1182
- { 'type' => 'ACTIVE_EXPECTATIONS', 'format' => 'JSON' }
1183
- )
1231
+ query_params = { 'type' => 'ACTIVE_EXPECTATIONS', 'format' => 'JSON' }
1232
+ query_params.merge!(filter.to_query_params) if filter
1233
+ status, response_body = do_request('PUT', '/mockserver/retrieve', body, query_params)
1184
1234
  if status >= 400
1185
1235
  raise Error, "Failed to retrieve active expectations (status=#{status}): #{response_body}"
1186
1236
  end
@@ -1192,14 +1242,47 @@ module MockServer
1192
1242
  []
1193
1243
  end
1194
1244
 
1195
- # Retrieve recorded expectations.
1245
+ # Retrieve active expectations whose metadata has +key+ with a value
1246
+ # equal to +value+ (see {ExpectationFilter#with_metadata}).
1247
+ # @return [Array<Expectation>]
1248
+ def retrieve_active_expectations_by_metadata(key, value)
1249
+ retrieve_active_expectations(filter: ExpectationFilter.new.with_metadata(key, value))
1250
+ end
1251
+
1252
+ # Retrieve active expectations whose metadata has +key+, with any value.
1253
+ # @return [Array<Expectation>]
1254
+ def retrieve_active_expectations_by_metadata_key(key)
1255
+ retrieve_active_expectations(filter: ExpectationFilter.new.with_metadata_key(key))
1256
+ end
1257
+
1258
+ # Retrieve the active expectations that have not matched any request.
1259
+ # @return [Array<Expectation>]
1260
+ def retrieve_unmatched_expectations
1261
+ retrieve_active_expectations(filter: ExpectationFilter.new.with_unmatched(true))
1262
+ end
1263
+
1264
+ # Retrieve recorded expectations, optionally one page of them in the
1265
+ # order the requests were received.
1196
1266
  # @param request [HttpRequest, nil]
1267
+ # @param offset [Integer, nil] number to skip, zero or more
1268
+ # @param limit [Integer, nil] maximum number to return, zero or more
1269
+ # @param options [RecordedExpectationsOptions, nil] shapes the returned
1270
+ # expectations, for example replaying a request recorded with different
1271
+ # responses in order
1197
1272
  # @return [Array<Expectation>]
1198
- def retrieve_recorded_expectations(request: nil)
1273
+ def retrieve_recorded_expectations(request: nil, offset: nil, limit: nil, options: nil)
1274
+ unless options.nil? || options.is_a?(RecordedExpectationsOptions)
1275
+ raise ArgumentError, "options must be a RecordedExpectationsOptions, got #{options.inspect}"
1276
+ end
1277
+
1278
+ paging = ExpectationFilter.new
1279
+ paging.with_offset(offset) unless offset.nil?
1280
+ paging.with_limit(limit) unless limit.nil?
1199
1281
  body = request ? JSON.generate(request.to_h) : ''
1282
+ query_params = { 'type' => 'RECORDED_EXPECTATIONS', 'format' => 'JSON' }
1283
+ query_params.merge!(options.to_query_params) unless options.nil?
1200
1284
  status, response_body = do_request(
1201
- 'PUT', '/mockserver/retrieve', body,
1202
- { 'type' => 'RECORDED_EXPECTATIONS', 'format' => 'JSON' }
1285
+ 'PUT', '/mockserver/retrieve', body, query_params.merge(paging.to_query_params)
1203
1286
  )
1204
1287
  if status >= 400
1205
1288
  raise Error, "Failed to retrieve recorded expectations (status=#{status}): #{response_body}"
@@ -1656,7 +1739,8 @@ module MockServer
1656
1739
  context_path: @context_path,
1657
1740
  secure: @secure,
1658
1741
  ca_cert_path: @ca_cert_path,
1659
- tls_verify: @tls_verify
1742
+ tls_verify: @tls_verify,
1743
+ authorization: @control_plane_authorization
1660
1744
  )
1661
1745
  ws_client.instance_variable_set(:@is_breakpoint_ws, true)
1662
1746
  ws_client.listen
@@ -1673,7 +1757,8 @@ module MockServer
1673
1757
  context_path: @context_path,
1674
1758
  secure: @secure,
1675
1759
  ca_cert_path: @ca_cert_path,
1676
- tls_verify: @tls_verify
1760
+ tls_verify: @tls_verify,
1761
+ authorization: @control_plane_authorization
1677
1762
  )
1678
1763
 
1679
1764
  case callback_type
@@ -1688,6 +1773,26 @@ module MockServer
1688
1773
  ws_client.client_id
1689
1774
  end
1690
1775
 
1776
+ # @api private
1777
+ def clear_with_filter(request, type, filter)
1778
+ unless filter.is_a?(ExpectationFilter) && filter.selection?
1779
+ raise ArgumentError, 'clear with a filter requires a metadata or unmatched selection; ' \
1780
+ 'use clear(request) to clear without one'
1781
+ end
1782
+ raise ArgumentError, 'offset and limit are not supported for clear' if filter.paging?
1783
+ unless type.nil? || type.to_s.casecmp?('expectations')
1784
+ raise ArgumentError, "clear with a filter only clears expectations, not type #{type.inspect}"
1785
+ end
1786
+
1787
+ body = request ? JSON.generate(request.to_h) : ''
1788
+ status, response_body = do_request(
1789
+ 'PUT', '/mockserver/clear', body, { 'type' => 'expectations' }.merge(filter.to_query_params)
1790
+ )
1791
+ raise Error, "Failed to clear (status=#{status}): #{response_body}" if status >= 400
1792
+
1793
+ nil
1794
+ end
1795
+
1691
1796
  # Perform an HTTP request with optional query parameters.
1692
1797
  # @api private
1693
1798
  def do_request(method, path, body = nil, query_params = nil,
@@ -1785,10 +1890,23 @@ module MockServer
1785
1890
  req = Net::HTTP::Put.new(request_path)
1786
1891
  end
1787
1892
  req['Content-Type'] = content_type
1893
+ req['Authorization'] = @control_plane_authorization if @control_plane_authorization
1788
1894
  req.body = body if body
1789
1895
  req
1790
1896
  end
1791
1897
 
1898
+ # @api private
1899
+ def basic_authorization(credentials)
1900
+ return nil if credentials.nil?
1901
+
1902
+ username, password = credentials
1903
+ if username.nil? || username.to_s.empty? || username.to_s.include?(':') || password.nil?
1904
+ raise ArgumentError, 'control_plane_basic_auth needs [username, password] with a username without ":"'
1905
+ end
1906
+
1907
+ "Basic #{Base64.strict_encode64("#{username}:#{password}".encode('UTF-8'))}"
1908
+ end
1909
+
1792
1910
  # @api private
1793
1911
  def execute_request(http, req)
1794
1912
  # Use the block form of #start so the underlying TCP/TLS connection is
@@ -37,6 +37,14 @@ module MockServer
37
37
  self
38
38
  end
39
39
 
40
+ # Set the expectation metadata, used to retrieve or clear it by metadata.
41
+ # @param metadata [Hash] String (or Symbol) keys to any JSON value
42
+ # @return [self]
43
+ def with_metadata(metadata)
44
+ @expectation.metadata = metadata
45
+ self
46
+ end
47
+
40
48
  # Set the response action. Accepts an HttpResponse, HttpTemplate, or
41
49
  # a Proc/lambda callback.
42
50
  # @param response_or_callback [HttpResponse, HttpTemplate, Proc]