mockserver-client 9.0.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: 65c675ce8a2644ccefd09d08e036b68676dc481222d4e0e41f8d2a31f5ee4d9e
4
- data.tar.gz: 5ce4a28531387f73bef657eac15879122b9bfce161c29b7ac03fe2e609fea74e
3
+ metadata.gz: 02d995291d2bc1c0c4b875711f568d604c4e6af009952512109b44ff49fa2fed
4
+ data.tar.gz: 455d49792d3fec33d41dd66eade0ca64e5ee4049306566c411da1008a431b30a
5
5
  SHA512:
6
- metadata.gz: 5b3e1a67574cfb21096ded9e3d651ef37f0f0963d077f87829d1ea43940c957f2404a6144772b2d8d72230a568073b8869fd81006e7dde367a046faa436cc83a
7
- data.tar.gz: '09436cda59e502b5b89444e0ca7aa32aa8fdd0196445aebc99e7d984c5f21b3ce259dc86cc31b1e08b1949c15a06eb80300fd3d6eca84e921a8a5fb1cd73c791'
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.0.0')
521
+ handle = MockServer::BinaryLauncher.start(port: 1080, version: '9.2.0')
421
522
  ```
422
523
 
423
524
  ### API reference
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'uri'
5
+ require_relative 'mcp'
5
6
 
6
7
  module MockServer
7
8
  # Fluent builder for mocking an A2A (Agent-to-Agent) agent.
@@ -45,11 +46,11 @@ module MockServer
45
46
  end
46
47
 
47
48
  # Escape Velocity metacharacters so literal +$+ / +#+ survive rendering.
49
+ # Shares the MCP builder's rules (backslash runs before +$+, +#+ or the end
50
+ # of the value are escaped too).
48
51
  # @api private
49
52
  def self.escape_velocity(value)
50
- return value if value.nil?
51
-
52
- value.to_s.gsub('$', '${esc.d}').gsub('#', '${esc.h}')
53
+ MCP.escape_velocity(value)
53
54
  end
54
55
 
55
56
  # @api private
@@ -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
@@ -912,14 +958,15 @@ module MockServer
912
958
  #
913
959
  # @param experiment [Hash, #to_h] the experiment definition
914
960
  # @return [Hash] the started status, e.g. { "status" => "started", "name" => ... }
915
- # @raise [Error] if the experiment definition is invalid or chaos is disabled
916
- # (HTTP 400/403), or on any other failure
961
+ # @raise [Error] if the experiment definition is invalid (HTTP 400), if
962
+ # control-plane authorization rejects the request (HTTP 403), or on any other
963
+ # failure; there is no server setting that disables chaos experiments
917
964
  def start_chaos_experiment(experiment)
918
965
  payload = experiment.respond_to?(:to_h) ? experiment.to_h : experiment
919
966
  body = JSON.generate(payload)
920
967
  status, response_body = request('PUT', '/mockserver/chaosExperiment', body)
921
968
  if status == 403
922
- raise Error, 'Chaos experiment rejected (status=403): chaos is disabled on the server'
969
+ raise Error, "Chaos experiment rejected (status=403): control-plane authorization denied: #{response_body}"
923
970
  end
924
971
  if status >= 400
925
972
  raise Error, "Failed to start chaos experiment (status=#{status}): #{response_body}"
@@ -1173,13 +1220,17 @@ module MockServer
1173
1220
 
1174
1221
  # Retrieve active expectations.
1175
1222
  # @param request [HttpRequest, nil]
1223
+ # @param filter [ExpectationFilter, nil] metadata / unmatched selection and paging
1176
1224
  # @return [Array<Expectation>]
1177
- 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
+
1178
1230
  body = request ? JSON.generate(request.to_h) : ''
1179
- status, response_body = do_request(
1180
- 'PUT', '/mockserver/retrieve', body,
1181
- { 'type' => 'ACTIVE_EXPECTATIONS', 'format' => 'JSON' }
1182
- )
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)
1183
1234
  if status >= 400
1184
1235
  raise Error, "Failed to retrieve active expectations (status=#{status}): #{response_body}"
1185
1236
  end
@@ -1191,14 +1242,47 @@ module MockServer
1191
1242
  []
1192
1243
  end
1193
1244
 
1194
- # 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.
1195
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
1196
1272
  # @return [Array<Expectation>]
1197
- 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?
1198
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?
1199
1284
  status, response_body = do_request(
1200
- 'PUT', '/mockserver/retrieve', body,
1201
- { 'type' => 'RECORDED_EXPECTATIONS', 'format' => 'JSON' }
1285
+ 'PUT', '/mockserver/retrieve', body, query_params.merge(paging.to_query_params)
1202
1286
  )
1203
1287
  if status >= 400
1204
1288
  raise Error, "Failed to retrieve recorded expectations (status=#{status}): #{response_body}"
@@ -1655,7 +1739,8 @@ module MockServer
1655
1739
  context_path: @context_path,
1656
1740
  secure: @secure,
1657
1741
  ca_cert_path: @ca_cert_path,
1658
- tls_verify: @tls_verify
1742
+ tls_verify: @tls_verify,
1743
+ authorization: @control_plane_authorization
1659
1744
  )
1660
1745
  ws_client.instance_variable_set(:@is_breakpoint_ws, true)
1661
1746
  ws_client.listen
@@ -1672,7 +1757,8 @@ module MockServer
1672
1757
  context_path: @context_path,
1673
1758
  secure: @secure,
1674
1759
  ca_cert_path: @ca_cert_path,
1675
- tls_verify: @tls_verify
1760
+ tls_verify: @tls_verify,
1761
+ authorization: @control_plane_authorization
1676
1762
  )
1677
1763
 
1678
1764
  case callback_type
@@ -1687,6 +1773,26 @@ module MockServer
1687
1773
  ws_client.client_id
1688
1774
  end
1689
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
+
1690
1796
  # Perform an HTTP request with optional query parameters.
1691
1797
  # @api private
1692
1798
  def do_request(method, path, body = nil, query_params = nil,
@@ -1784,10 +1890,23 @@ module MockServer
1784
1890
  req = Net::HTTP::Put.new(request_path)
1785
1891
  end
1786
1892
  req['Content-Type'] = content_type
1893
+ req['Authorization'] = @control_plane_authorization if @control_plane_authorization
1787
1894
  req.body = body if body
1788
1895
  req
1789
1896
  end
1790
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
+
1791
1910
  # @api private
1792
1911
  def execute_request(http, req)
1793
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]
@@ -31,20 +31,37 @@ module MockServer
31
31
  quoted[1...-1]
32
32
  end
33
33
 
34
+ VELOCITY_METACHARACTER_ESCAPES = { '$' => '${esc.d}', '#' => '${esc.h}' }.freeze
35
+ private_constant :VELOCITY_METACHARACTER_ESCAPES
36
+
34
37
  # Escape Velocity metacharacters so literal +$+ / +#+ survive rendering.
38
+ # Velocity consumes backslashes written before a reference or directive, so
39
+ # a run of backslashes followed by +$+, +#+ or the end of the value is
40
+ # escaped too; other backslashes are literal to Velocity and left as-is.
35
41
  # @api private
36
42
  def self.escape_velocity(value)
37
43
  return value if value.nil?
38
44
 
39
- value.to_s.gsub('$', '${esc.d}').gsub('#', '${esc.h}')
45
+ # group 2 consumes the metacharacter after a run, is empty at the end and
46
+ # unset (empty alternative) otherwise, so no lookaround is needed
47
+ value.to_s.gsub(/(\\+)(?:([$#]|\z)|)|[$#]/) do |match|
48
+ run = Regexp.last_match(1)
49
+ next VELOCITY_METACHARACTER_ESCAPES[match] if run.nil?
50
+
51
+ following = Regexp.last_match(2)
52
+ next run if following.nil?
53
+
54
+ ('${esc.b}' * run.length) + VELOCITY_METACHARACTER_ESCAPES.fetch(following, '')
55
+ end
40
56
  end
41
57
 
42
- # Escape single quotes for safe inclusion inside a JSONPath string literal.
58
+ # Escape backslashes, then single quotes, for safe inclusion inside a
59
+ # single-quoted JSONPath string literal.
43
60
  # @api private
44
61
  def self.escape_json_path(value)
45
62
  return '' if value.nil?
46
63
 
47
- value.to_s.gsub("'", "\\\\'")
64
+ value.to_s.gsub('\\') { '\\\\' }.gsub("'") { "\\'" }
48
65
  end
49
66
 
50
67
  # Parse + re-serialize JSON (compact) to validate it. Raises on invalid JSON.