mockserver-client 6.1.0 → 7.1.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 +104 -0
- data/lib/mockserver/binary_launcher.rb +634 -0
- data/lib/mockserver/client.rb +263 -6
- data/lib/mockserver/forward_chain_expectation.rb +95 -0
- data/lib/mockserver/models.rb +589 -38
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver/websocket_client.rb +129 -2
- data/lib/mockserver-client.rb +1 -0
- metadata +3 -2
data/lib/mockserver/client.rb
CHANGED
|
@@ -148,13 +148,126 @@ module MockServer
|
|
|
148
148
|
close
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
#
|
|
152
|
-
#
|
|
151
|
+
# -------------------------------------------------------------------
|
|
152
|
+
# Clock Control
|
|
153
|
+
# -------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
# Freeze the server clock at the given ISO-8601 instant.
|
|
156
|
+
# If +instant+ is nil, the clock freezes at the current real time.
|
|
157
|
+
# @param instant [String, nil] ISO-8601 instant (e.g. "2025-01-15T09:30:00Z")
|
|
158
|
+
# @return [Hash] response with status, currentInstant, currentEpochMillis
|
|
159
|
+
def freeze_clock(instant = nil)
|
|
160
|
+
payload = { 'action' => 'freeze' }
|
|
161
|
+
payload['instant'] = instant if instant
|
|
162
|
+
body = JSON.generate(payload)
|
|
163
|
+
status, response_body = request('PUT', '/mockserver/clock', body)
|
|
164
|
+
if status >= 400
|
|
165
|
+
raise Error, "Failed to freeze clock (status=#{status}): #{response_body}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Advance the frozen clock by +duration_millis+ milliseconds.
|
|
172
|
+
# @param duration_millis [Integer]
|
|
173
|
+
# @return [Hash] response with status, currentInstant, currentEpochMillis
|
|
174
|
+
def advance_clock(duration_millis)
|
|
175
|
+
body = JSON.generate({ 'action' => 'advance', 'durationMillis' => duration_millis })
|
|
176
|
+
status, response_body = request('PUT', '/mockserver/clock', body)
|
|
177
|
+
if status >= 400
|
|
178
|
+
raise Error, "Failed to advance clock (status=#{status}): #{response_body}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Reset the server clock to real wall-clock time.
|
|
185
|
+
# @return [Hash] response with status, currentInstant, currentEpochMillis
|
|
186
|
+
def reset_clock
|
|
187
|
+
body = JSON.generate({ 'action' => 'reset' })
|
|
188
|
+
status, response_body = request('PUT', '/mockserver/clock', body)
|
|
189
|
+
if status >= 400
|
|
190
|
+
raise Error, "Failed to reset clock (status=#{status}): #{response_body}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Query the current clock status.
|
|
197
|
+
# @return [Hash] with currentInstant, currentEpochMillis, frozen
|
|
198
|
+
def clock_status
|
|
199
|
+
status, response_body = request('GET', '/mockserver/clock')
|
|
200
|
+
if status >= 400
|
|
201
|
+
raise Error, "Failed to get clock status (status=#{status}): #{response_body}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Register a service-scoped HTTP chaos profile for an upstream host. The profile
|
|
208
|
+
# is applied to every matched forward expectation to that host that does not
|
|
209
|
+
# define its own chaos (an expectation's own chaos always wins). The host is
|
|
210
|
+
# matched case-insensitively, ignoring any +:port+.
|
|
211
|
+
# @param host [String] the upstream host to break
|
|
212
|
+
# @param chaos [HttpChaosProfile] the chaos profile to apply
|
|
213
|
+
# @param ttl_millis [Integer, nil] if set, the chaos auto-reverts after this many ms
|
|
214
|
+
# @return [Hash] response with status and host
|
|
215
|
+
def set_service_chaos(host, chaos, ttl_millis = nil)
|
|
216
|
+
payload = { 'host' => host, 'chaos' => chaos.to_h }
|
|
217
|
+
payload['ttlMillis'] = ttl_millis unless ttl_millis.nil?
|
|
218
|
+
body = JSON.generate(payload)
|
|
219
|
+
status, response_body = request('PUT', '/mockserver/serviceChaos', body)
|
|
220
|
+
if status >= 400
|
|
221
|
+
raise Error, "Failed to set service chaos (status=#{status}): #{response_body}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Remove the service-scoped chaos profile registered for +host+.
|
|
228
|
+
# @param host [String]
|
|
229
|
+
# @return [Hash]
|
|
230
|
+
def remove_service_chaos(host)
|
|
231
|
+
body = JSON.generate({ 'host' => host, 'remove' => true })
|
|
232
|
+
status, response_body = request('PUT', '/mockserver/serviceChaos', body)
|
|
233
|
+
if status >= 400
|
|
234
|
+
raise Error, "Failed to remove service chaos (status=#{status}): #{response_body}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Clear all service-scoped chaos profiles.
|
|
241
|
+
# @return [Hash]
|
|
242
|
+
def clear_service_chaos
|
|
243
|
+
body = JSON.generate({ 'clear' => true })
|
|
244
|
+
status, response_body = request('PUT', '/mockserver/serviceChaos', body)
|
|
245
|
+
if status >= 400
|
|
246
|
+
raise Error, "Failed to clear service chaos (status=#{status}): #{response_body}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Query the current service-scoped chaos registrations.
|
|
253
|
+
# @return [Hash] of the form { "services" => { host => profile, ... } }
|
|
254
|
+
def service_chaos_status
|
|
255
|
+
status, response_body = request('GET', '/mockserver/serviceChaos')
|
|
256
|
+
if status >= 400
|
|
257
|
+
raise Error, "Failed to get service chaos (status=#{status}): #{response_body}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Verify that a request (and optionally a response) was received.
|
|
264
|
+
# @param request [HttpRequest, nil]
|
|
153
265
|
# @param times [VerificationTimes, nil]
|
|
266
|
+
# @param response [HttpResponse, nil]
|
|
154
267
|
# @return [nil]
|
|
155
268
|
# @raise [VerificationError] if verification fails (HTTP 406)
|
|
156
|
-
def verify(request, times: nil)
|
|
157
|
-
verification = Verification.new(http_request: request, times: times)
|
|
269
|
+
def verify(request = nil, times: nil, response: nil)
|
|
270
|
+
verification = Verification.new(http_request: request, http_response: response, times: times)
|
|
158
271
|
body = JSON.generate(verification.to_h)
|
|
159
272
|
status, response_body = do_request('PUT', '/mockserver/verify', body)
|
|
160
273
|
if status == 406
|
|
@@ -170,10 +283,14 @@ module MockServer
|
|
|
170
283
|
|
|
171
284
|
# Verify that requests were received in sequence.
|
|
172
285
|
# @param requests [Array<HttpRequest>]
|
|
286
|
+
# @param responses [Array<HttpResponse>, nil] index-aligned response matchers
|
|
173
287
|
# @return [nil]
|
|
174
288
|
# @raise [VerificationError] if verification fails (HTTP 406)
|
|
175
|
-
def verify_sequence(*requests)
|
|
176
|
-
verification = VerificationSequence.new(
|
|
289
|
+
def verify_sequence(*requests, responses: nil)
|
|
290
|
+
verification = VerificationSequence.new(
|
|
291
|
+
http_requests: requests.empty? ? nil : requests.to_a,
|
|
292
|
+
http_responses: responses
|
|
293
|
+
)
|
|
177
294
|
body = JSON.generate(verification.to_h)
|
|
178
295
|
status, response_body = request('PUT', '/mockserver/verifySequence', body)
|
|
179
296
|
if status == 406
|
|
@@ -364,6 +481,120 @@ module MockServer
|
|
|
364
481
|
ForwardChainExpectation.new(self, expectation)
|
|
365
482
|
end
|
|
366
483
|
|
|
484
|
+
# -------------------------------------------------------------------
|
|
485
|
+
# Breakpoint matcher management
|
|
486
|
+
# -------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
# Register a breakpoint matcher with callback handlers.
|
|
489
|
+
# The callback WebSocket is opened lazily and reused.
|
|
490
|
+
#
|
|
491
|
+
# @param matcher [HttpRequest] the request definition to match
|
|
492
|
+
# @param phases [Array<String>] e.g. ["REQUEST", "RESPONSE"]
|
|
493
|
+
# @param request_handler [Proc, nil] handler for REQUEST phase
|
|
494
|
+
# @param response_handler [Proc, nil] handler for RESPONSE phase
|
|
495
|
+
# @param stream_frame_handler [Proc, nil] handler for streaming phases
|
|
496
|
+
# @return [String] the server-assigned breakpoint matcher id
|
|
497
|
+
def add_breakpoint(matcher, phases,
|
|
498
|
+
request_handler: nil, response_handler: nil,
|
|
499
|
+
stream_frame_handler: nil)
|
|
500
|
+
raise ArgumentError, 'add_breakpoint requires a non-nil matcher' if matcher.nil?
|
|
501
|
+
raise ArgumentError, 'add_breakpoint requires a non-empty phases array' if phases.nil? || phases.empty?
|
|
502
|
+
|
|
503
|
+
ws_client = ensure_breakpoint_websocket
|
|
504
|
+
client_id = ws_client.client_id
|
|
505
|
+
|
|
506
|
+
body = JSON.generate({
|
|
507
|
+
'httpRequest' => matcher.to_h,
|
|
508
|
+
'phases' => phases,
|
|
509
|
+
'clientId' => client_id
|
|
510
|
+
})
|
|
511
|
+
status, response_body = request('PUT', '/mockserver/breakpoint/matcher', body)
|
|
512
|
+
if status >= 400
|
|
513
|
+
raise Error, "Failed to register breakpoint matcher (status=#{status}): #{response_body}"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
parsed = response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
517
|
+
breakpoint_id = parsed['id']
|
|
518
|
+
raise Error, 'Server did not return a breakpoint id' unless breakpoint_id
|
|
519
|
+
|
|
520
|
+
# Install per-breakpoint-id handlers
|
|
521
|
+
ws_client.set_breakpoint_request_handler(breakpoint_id, request_handler) if request_handler
|
|
522
|
+
ws_client.set_breakpoint_response_handler(breakpoint_id, response_handler) if response_handler
|
|
523
|
+
ws_client.set_breakpoint_stream_frame_handler(breakpoint_id, stream_frame_handler) if stream_frame_handler
|
|
524
|
+
|
|
525
|
+
breakpoint_id
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Convenience: register a REQUEST-only breakpoint.
|
|
529
|
+
# @param matcher [HttpRequest]
|
|
530
|
+
# @param request_handler [Proc]
|
|
531
|
+
# @return [String]
|
|
532
|
+
def add_request_breakpoint(matcher, request_handler)
|
|
533
|
+
add_breakpoint(matcher, ['REQUEST'], request_handler: request_handler)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Convenience: register a REQUEST+RESPONSE breakpoint.
|
|
537
|
+
# @param matcher [HttpRequest]
|
|
538
|
+
# @param request_handler [Proc]
|
|
539
|
+
# @param response_handler [Proc]
|
|
540
|
+
# @return [String]
|
|
541
|
+
def add_request_and_response_breakpoint(matcher, request_handler, response_handler)
|
|
542
|
+
add_breakpoint(matcher, %w[REQUEST RESPONSE],
|
|
543
|
+
request_handler: request_handler,
|
|
544
|
+
response_handler: response_handler)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# List all registered breakpoint matchers.
|
|
548
|
+
# @return [Hash] e.g. {"matchers" => [{...}, ...]}
|
|
549
|
+
def list_breakpoint_matchers
|
|
550
|
+
status, response_body = request('GET', '/mockserver/breakpoint/matchers')
|
|
551
|
+
if status >= 400
|
|
552
|
+
raise Error, "Failed to list breakpoint matchers (status=#{status}): #{response_body}"
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Remove a breakpoint matcher by id.
|
|
559
|
+
# @param breakpoint_id [String]
|
|
560
|
+
# @return [Hash]
|
|
561
|
+
def remove_breakpoint_matcher(breakpoint_id)
|
|
562
|
+
raise ArgumentError, 'remove_breakpoint_matcher requires a non-empty id' if breakpoint_id.nil? || breakpoint_id.empty?
|
|
563
|
+
|
|
564
|
+
body = JSON.generate({ 'id' => breakpoint_id })
|
|
565
|
+
status, response_body = request('PUT', '/mockserver/breakpoint/matcher/remove', body)
|
|
566
|
+
if status >= 400
|
|
567
|
+
raise Error, "Failed to remove breakpoint matcher (status=#{status}): #{response_body}"
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Remove client-side handlers
|
|
571
|
+
@websocket_mutex.synchronize do
|
|
572
|
+
@websocket_clients.each do |ws|
|
|
573
|
+
ws.remove_breakpoint_handlers(breakpoint_id) if ws.respond_to?(:remove_breakpoint_handlers)
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Clear all registered breakpoint matchers.
|
|
581
|
+
# @return [Hash]
|
|
582
|
+
def clear_breakpoint_matchers
|
|
583
|
+
status, response_body = request('PUT', '/mockserver/breakpoint/matcher/clear')
|
|
584
|
+
if status >= 400
|
|
585
|
+
raise Error, "Failed to clear breakpoint matchers (status=#{status}): #{response_body}"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Clear client-side handlers
|
|
589
|
+
@websocket_mutex.synchronize do
|
|
590
|
+
@websocket_clients.each do |ws|
|
|
591
|
+
ws.clear_breakpoint_handlers if ws.respond_to?(:clear_breakpoint_handlers)
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
response_body && !response_body.empty? ? JSON.parse(response_body) : {}
|
|
596
|
+
end
|
|
597
|
+
|
|
367
598
|
# -------------------------------------------------------------------
|
|
368
599
|
# Callback methods
|
|
369
600
|
# -------------------------------------------------------------------
|
|
@@ -418,6 +649,32 @@ module MockServer
|
|
|
418
649
|
|
|
419
650
|
private
|
|
420
651
|
|
|
652
|
+
# @api private
|
|
653
|
+
# Ensure a callback WS is connected for breakpoint use, returning it.
|
|
654
|
+
def ensure_breakpoint_websocket
|
|
655
|
+
# Hold the mutex for the whole check-create-append so two concurrent
|
|
656
|
+
# add_breakpoint calls cannot both create a breakpoint WS (TOCTOU).
|
|
657
|
+
# Breakpoint WS creation is rare, so blocking on connect under the lock
|
|
658
|
+
# is acceptable.
|
|
659
|
+
@websocket_mutex.synchronize do
|
|
660
|
+
existing = @websocket_clients.find { |ws| ws.instance_variable_get(:@is_breakpoint_ws) }
|
|
661
|
+
return existing if existing
|
|
662
|
+
|
|
663
|
+
ws_client = WebSocketClient.new
|
|
664
|
+
ws_client.connect(
|
|
665
|
+
@host, @port,
|
|
666
|
+
context_path: @context_path,
|
|
667
|
+
secure: @secure,
|
|
668
|
+
ca_cert_path: @ca_cert_path,
|
|
669
|
+
tls_verify: @tls_verify
|
|
670
|
+
)
|
|
671
|
+
ws_client.instance_variable_set(:@is_breakpoint_ws, true)
|
|
672
|
+
ws_client.listen
|
|
673
|
+
@websocket_clients << ws_client
|
|
674
|
+
ws_client
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
421
678
|
# @api private
|
|
422
679
|
def register_websocket_callback(callback_type, callback_fn, forward_response_fn = nil)
|
|
423
680
|
ws_client = WebSocketClient.new
|
|
@@ -29,6 +29,14 @@ module MockServer
|
|
|
29
29
|
self
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Set a declarative HTTP chaos/fault injection profile.
|
|
33
|
+
# @param chaos [HttpChaosProfile]
|
|
34
|
+
# @return [self]
|
|
35
|
+
def with_chaos(chaos)
|
|
36
|
+
@expectation.chaos = chaos
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
32
40
|
# Set the response action. Accepts an HttpResponse, HttpTemplate, or
|
|
33
41
|
# a Proc/lambda callback.
|
|
34
42
|
# @param response_or_callback [HttpResponse, HttpTemplate, Proc]
|
|
@@ -113,5 +121,92 @@ module MockServer
|
|
|
113
121
|
@expectation.http_websocket_response = websocket_response
|
|
114
122
|
@client.upsert(@expectation)
|
|
115
123
|
end
|
|
124
|
+
|
|
125
|
+
# Set a gRPC stream response action.
|
|
126
|
+
# @param grpc_stream_response [GrpcStreamResponse]
|
|
127
|
+
# @return [Array<Expectation>]
|
|
128
|
+
def respond_with_grpc_stream(grpc_stream_response)
|
|
129
|
+
unless grpc_stream_response.is_a?(GrpcStreamResponse)
|
|
130
|
+
raise TypeError,
|
|
131
|
+
"Expected GrpcStreamResponse, got #{grpc_stream_response.class.name}"
|
|
132
|
+
end
|
|
133
|
+
@expectation.grpc_stream_response = grpc_stream_response
|
|
134
|
+
@client.upsert(@expectation)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Set a gRPC bidi streaming response action.
|
|
138
|
+
# @param grpc_bidi_response [GrpcBidiResponse]
|
|
139
|
+
# @return [Array<Expectation>]
|
|
140
|
+
def respond_with_grpc_bidi(grpc_bidi_response)
|
|
141
|
+
unless grpc_bidi_response.is_a?(GrpcBidiResponse)
|
|
142
|
+
raise TypeError,
|
|
143
|
+
"Expected GrpcBidiResponse, got #{grpc_bidi_response.class.name}"
|
|
144
|
+
end
|
|
145
|
+
@expectation.grpc_bidi_response = grpc_bidi_response
|
|
146
|
+
@client.upsert(@expectation)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Set a binary response action.
|
|
150
|
+
# @param binary_response [BinaryResponse]
|
|
151
|
+
# @return [Array<Expectation>]
|
|
152
|
+
def respond_with_binary(binary_response)
|
|
153
|
+
unless binary_response.is_a?(BinaryResponse)
|
|
154
|
+
raise TypeError,
|
|
155
|
+
"Expected BinaryResponse, got #{binary_response.class.name}"
|
|
156
|
+
end
|
|
157
|
+
@expectation.binary_response = binary_response
|
|
158
|
+
@client.upsert(@expectation)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Set a DNS response action.
|
|
162
|
+
# @param dns_response [DnsResponse]
|
|
163
|
+
# @return [Array<Expectation>]
|
|
164
|
+
def respond_with_dns(dns_response)
|
|
165
|
+
unless dns_response.is_a?(DnsResponse)
|
|
166
|
+
raise TypeError,
|
|
167
|
+
"Expected DnsResponse, got #{dns_response.class.name}"
|
|
168
|
+
end
|
|
169
|
+
@expectation.dns_response = dns_response
|
|
170
|
+
@client.upsert(@expectation)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Set a forward template action.
|
|
174
|
+
# @param template [HttpTemplate]
|
|
175
|
+
# @return [Array<Expectation>]
|
|
176
|
+
def forward_with_template(template)
|
|
177
|
+
unless template.is_a?(HttpTemplate)
|
|
178
|
+
raise TypeError,
|
|
179
|
+
"Expected HttpTemplate, got #{template.class.name}"
|
|
180
|
+
end
|
|
181
|
+
@expectation.http_forward_template = template
|
|
182
|
+
@client.upsert(@expectation)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Set a forward class callback action.
|
|
186
|
+
# @param class_callback [HttpClassCallback]
|
|
187
|
+
# @return [Array<Expectation>]
|
|
188
|
+
def forward_with_class_callback(class_callback)
|
|
189
|
+
unless class_callback.is_a?(HttpClassCallback)
|
|
190
|
+
raise TypeError,
|
|
191
|
+
"Expected HttpClassCallback, got #{class_callback.class.name}"
|
|
192
|
+
end
|
|
193
|
+
@expectation.http_forward_class_callback = class_callback
|
|
194
|
+
@client.upsert(@expectation)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Set an ordered multi-action pipeline of steps.
|
|
198
|
+
#
|
|
199
|
+
# Exactly one step must have +responder: true+; that step produces the
|
|
200
|
+
# HTTP response. All other steps are side-effects executed in order.
|
|
201
|
+
# @param steps [Array<ExpectationStep>]
|
|
202
|
+
# @return [Array<Expectation>]
|
|
203
|
+
def with_steps(steps)
|
|
204
|
+
unless steps.is_a?(Array) && steps.all? { |s| s.is_a?(ExpectationStep) }
|
|
205
|
+
raise TypeError, 'Expected an Array of ExpectationStep objects'
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
@expectation.steps = steps
|
|
209
|
+
@client.upsert(@expectation)
|
|
210
|
+
end
|
|
116
211
|
end
|
|
117
212
|
end
|