robotframework-okw-api-rest 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,707 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import yaml
5
+ import os
6
+
7
+ from robot.api import logger
8
+ from robot.api.deco import keyword, library
9
+
10
+ from okw_contract_utils import MatchMode, assert_match, expand_mem
11
+
12
+ from .rest_context import RestContext
13
+ from .rest_client import send_request, _fetch_oauth2_token, _build_ssl_kwargs
14
+
15
+
16
+ _IGNORE = "$IGNORE"
17
+ _EMPTY = "$EMPTY"
18
+
19
+ _mem_store: dict[str, str] = {}
20
+
21
+ _ENV_RE = re.compile(r"\$\{([A-Za-z0-9_\-\.]+)\}")
22
+
23
+
24
+ def _resolve_env_vars(obj, env: dict):
25
+ """Recursively resolve ${VAR} placeholders in YAML values from env dict."""
26
+ if isinstance(obj, str):
27
+ def repl(m):
28
+ key = m.group(1)
29
+ if key in env:
30
+ return str(env[key])
31
+ # Fall back to OS environment variable
32
+ os_val = os.environ.get(key)
33
+ if os_val is not None:
34
+ return os_val
35
+ raise ValueError(f"Environment variable '{key}' not found in env file or OS environment.")
36
+ return _ENV_RE.sub(repl, obj)
37
+ elif isinstance(obj, dict):
38
+ return {k: _resolve_env_vars(v, env) for k, v in obj.items()}
39
+ elif isinstance(obj, list):
40
+ return [_resolve_env_vars(v, env) for v in obj]
41
+ return obj
42
+
43
+
44
+ def _load_env_file(env_path: str) -> dict:
45
+ """Load a YAML environment file and return its contents as a dict."""
46
+ if not os.path.isfile(env_path):
47
+ raise FileNotFoundError(f"Environment file not found: {env_path}")
48
+ with open(env_path, encoding="utf-8") as f:
49
+ data = yaml.safe_load(f)
50
+ return data if isinstance(data, dict) else {}
51
+
52
+
53
+ def _expand(value: str) -> str:
54
+ if value is None:
55
+ return ""
56
+ return expand_mem(str(value), _mem_store)
57
+
58
+
59
+ def _is_ignore(value: str) -> bool:
60
+ return str(value).strip().upper() == _IGNORE
61
+
62
+
63
+ @library(scope="GLOBAL")
64
+ class OkwApiRestLibrary:
65
+ """OKW keyword-driven REST API testing library.
66
+
67
+ Provides keywords for building, sending, and verifying REST API requests
68
+ following the OKW phase model: Start -> Scope -> Input -> Action -> Verify -> Stop.
69
+
70
+ = Import =
71
+
72
+ | Library okw_api_rest.library.OkwApiRestLibrary WITH NAME RESTAPI
73
+
74
+ = YAML Configuration =
75
+
76
+ | NotesAPI:
77
+ | __self__:
78
+ | class: okw_api_rest.library.OkwApiRestLibrary
79
+ | base_url: https://practice.expandtesting.com/notes/api
80
+ | content_type: application/x-www-form-urlencoded
81
+
82
+ = Example =
83
+
84
+ | RESTStart NotesAPI
85
+ | RESTSelectEndpoint /users/login
86
+ | RESTSetValue email user@example.com
87
+ | RESTSetValue password Secret123!
88
+ | RESTSendRequest POST
89
+ | RESTVerifyStatus 200
90
+ | RESTMemorizeValue data.token TOKEN
91
+ | RESTStop
92
+ """
93
+
94
+ ROBOT_LIBRARY_DOC_FORMAT = "ROBOT"
95
+ ROBOT_LIBRARY_VERSION = "0.1.0"
96
+
97
+ def __init__(self):
98
+ self._ctx: RestContext | None = None
99
+ self._service_name: str | None = None
100
+
101
+ def _require_ctx(self) -> RestContext:
102
+ if self._ctx is None:
103
+ raise RuntimeError("No REST service active. Call RESTStart first.")
104
+ return self._ctx
105
+
106
+ def _load_yaml(self, name: str) -> dict:
107
+ search_dirs = []
108
+
109
+ try:
110
+ from robot.libraries.BuiltIn import BuiltIn
111
+ suite_source = BuiltIn().get_variable_value("${SUITE SOURCE}")
112
+ if suite_source:
113
+ search_dirs.append(os.path.join(os.path.dirname(suite_source), "locators"))
114
+ search_dirs.append(os.path.dirname(suite_source))
115
+ except Exception:
116
+ pass
117
+
118
+ search_dirs.append(os.path.join(os.getcwd(), "locators"))
119
+ search_dirs.append(os.getcwd())
120
+
121
+ for d in search_dirs:
122
+ for ext in (".yaml", ".yml"):
123
+ path = os.path.join(d, f"{name}{ext}")
124
+ if os.path.isfile(path):
125
+ with open(path, encoding="utf-8") as f:
126
+ return yaml.safe_load(f)
127
+
128
+ raise FileNotFoundError(
129
+ f"YAML file for service '{name}' not found. "
130
+ f"Searched: {search_dirs}"
131
+ )
132
+
133
+ def _find_env_file(self, env_name: str, service_name: str) -> str:
134
+ """Find environment YAML file by name.
135
+
136
+ Search order (like ~/.ssh/ convention):
137
+ 1. ~/.okw/env/ (user profile — secure, not in repo)
138
+ 2. OKW_ENV_DIR env var (explicit override, e.g. for CI/CD)
139
+ 3. locators/ next to test suite
140
+ 4. test suite directory
141
+ 5. cwd/locators/
142
+ 6. cwd
143
+ """
144
+ search_dirs = []
145
+
146
+ # 1. User profile: ~/.okw/env/
147
+ okw_home = os.path.join(os.path.expanduser("~"), ".okw", "env")
148
+ search_dirs.append(okw_home)
149
+
150
+ # 2. OKW_ENV_DIR environment variable
151
+ env_dir = os.environ.get("OKW_ENV_DIR")
152
+ if env_dir:
153
+ search_dirs.append(env_dir)
154
+
155
+ # 3+4. Next to test suite
156
+ try:
157
+ from robot.libraries.BuiltIn import BuiltIn
158
+ suite_source = BuiltIn().get_variable_value("${SUITE SOURCE}")
159
+ if suite_source:
160
+ search_dirs.append(os.path.join(os.path.dirname(suite_source), "locators"))
161
+ search_dirs.append(os.path.dirname(suite_source))
162
+ except Exception:
163
+ pass
164
+
165
+ # 5+6. Current working directory
166
+ search_dirs.append(os.path.join(os.getcwd(), "locators"))
167
+ search_dirs.append(os.getcwd())
168
+
169
+ for d in search_dirs:
170
+ for ext in (".yaml", ".yml"):
171
+ path = os.path.join(d, f"{env_name}{ext}")
172
+ if os.path.isfile(path):
173
+ logger.info(f"RESTStart: Found env file at {path}")
174
+ return path
175
+
176
+ raise FileNotFoundError(
177
+ f"Environment file '{env_name}' not found. "
178
+ f"Searched: {search_dirs}"
179
+ )
180
+
181
+ # ── Start / Stop ────────────────────────────────────────────
182
+
183
+ @keyword("RESTStart")
184
+ def rest_start(self, service: str, env: str | None = None):
185
+ """Starts a REST service session.
186
+
187
+ Loads the YAML configuration for the given service name and
188
+ initialises the REST context with base URL, content type,
189
+ authentication, and SSL settings.
190
+
191
+ The optional ``env`` parameter specifies an environment file
192
+ (YAML) whose values replace ``${VAR}`` placeholders in the
193
+ service YAML. If omitted, placeholders are resolved from OS
194
+ environment variables.
195
+
196
+ Examples:
197
+ | RESTStart | NotesAPI |
198
+ | RESTStart | NotesAPI | env-test |
199
+ """
200
+ logger.info(f"RESTStart: Loading service '{service}'...")
201
+ model = self._load_yaml(service)
202
+
203
+ if service not in model:
204
+ raise KeyError(f"Service '{service}' not found in YAML root.")
205
+
206
+ svc_model = model[service]
207
+
208
+ # Load environment variables for ${VAR} resolution
209
+ env_vars = {}
210
+ if env:
211
+ env_path = self._find_env_file(env, service)
212
+ env_vars = _load_env_file(env_path)
213
+ logger.info(f"RESTStart: Loaded env file '{env}' ({len(env_vars)} variables).")
214
+
215
+ # Resolve ${VAR} placeholders in __self__
216
+ self_cfg = svc_model.get("__self__", {})
217
+ self_cfg = _resolve_env_vars(self_cfg, env_vars)
218
+
219
+ base_url = self_cfg.get("base_url")
220
+ if not base_url:
221
+ raise ValueError(f"Service '{service}' has no base_url in __self__.")
222
+
223
+ content_type = self_cfg.get("content_type", "application/json")
224
+
225
+ # Authentication config
226
+ auth_keys = {"auth_type", "auth_user", "auth_password",
227
+ "auth_header", "auth_key", "auth_token",
228
+ "token_url", "client_id", "client_secret", "scope"}
229
+ auth_cfg = {k: v for k, v in self_cfg.items() if k in auth_keys}
230
+
231
+ # SSL config
232
+ ssl_keys = {"verify_ssl", "client_cert", "client_key", "ca_bundle"}
233
+ ssl_cfg = {k: v for k, v in self_cfg.items() if k in ssl_keys}
234
+
235
+ # Retry config
236
+ retry_cfg = {}
237
+ if "retry_count" in self_cfg:
238
+ retry_cfg["retry_count"] = int(self_cfg["retry_count"])
239
+ if "retry_delay" in self_cfg:
240
+ retry_cfg["retry_delay"] = int(self_cfg["retry_delay"])
241
+ if "retry_on" in self_cfg:
242
+ raw = self_cfg["retry_on"]
243
+ if isinstance(raw, str):
244
+ retry_cfg["retry_on"] = {int(s.strip()) for s in raw.split(",")}
245
+ elif isinstance(raw, list):
246
+ retry_cfg["retry_on"] = {int(s) for s in raw}
247
+ else:
248
+ retry_cfg["retry_on"] = {int(raw)}
249
+
250
+ # OAuth 2.0: fetch token before creating context
251
+ auth_type = auth_cfg.get("auth_type", "none").lower()
252
+ if auth_type == "oauth2_client_credentials":
253
+ ssl_kwargs = _build_ssl_kwargs(ssl_cfg)
254
+ token = _fetch_oauth2_token(auth_cfg, ssl_kwargs)
255
+ auth_cfg["_oauth2_token"] = token
256
+
257
+ self._ctx = RestContext(
258
+ base_url=base_url,
259
+ content_type=content_type,
260
+ auth=auth_cfg,
261
+ ssl=ssl_cfg,
262
+ retry=retry_cfg,
263
+ )
264
+ self._service_name = service
265
+ verify = ssl_cfg.get("verify_ssl", True)
266
+ retry_info = f", retry={retry_cfg['retry_count']}x" if retry_cfg.get("retry_count") else ""
267
+ logger.info(
268
+ f"RESTStart: Service '{service}' active "
269
+ f"(base_url={base_url}, auth={auth_type}, verify_ssl={verify}{retry_info})."
270
+ )
271
+
272
+ @keyword("RESTStop")
273
+ def rest_stop(self):
274
+ """Stops the REST service and releases resources.
275
+
276
+ Examples:
277
+ | RESTStop |
278
+ """
279
+ name = self._service_name or "<unknown>"
280
+ if self._ctx is not None:
281
+ self._ctx._session.close()
282
+ self._ctx = None
283
+ self._service_name = None
284
+ logger.info(f"RESTStop: Service '{name}' stopped.")
285
+
286
+ # ── Scope ───────────────────────────────────────────────────
287
+
288
+ @keyword("RESTSelectEndpoint")
289
+ def rest_select_endpoint(self, path: str):
290
+ """Selects the API endpoint for the next request.
291
+
292
+ Resets body, query parameters, headers, and context.
293
+ Path is relative to the base_url from YAML.
294
+
295
+ Examples:
296
+ | RESTSelectEndpoint | /users/register |
297
+ | RESTSelectEndpoint | /notes/$MEM{NOTE_ID} |
298
+ """
299
+ ctx = self._require_ctx()
300
+ path = _expand(path)
301
+ ctx.select_endpoint(path)
302
+ logger.info(f"RESTSelectEndpoint: {path}")
303
+
304
+ # ── Input ───────────────────────────────────────────────────
305
+
306
+ @keyword("RESTSetValue")
307
+ def rest_set_value(self, field: str, value: str):
308
+ """Sets a request body field or query parameter with auto type detection.
309
+
310
+ Values are automatically converted to native JSON types:
311
+ - ``true`` / ``false`` -> boolean
312
+ - ``null`` or ``$NULL`` -> null
313
+ - Integer strings (``42``) -> number
314
+ - Float strings (``3.14``) -> number
315
+ - Everything else -> string
316
+
317
+ Fields prefixed with ``?`` are sent as URL query parameters
318
+ (always as string, no type conversion).
319
+
320
+ Use ``RESTSetValueAsString`` to force a value as string.
321
+
322
+ Examples:
323
+ | RESTSetValue | name | Zoltan | # -> string "Zoltan" |
324
+ | RESTSetValue | count | 42 | # -> integer 42 |
325
+ | RESTSetValue | price | 3.14 | # -> float 3.14 |
326
+ | RESTSetValue | active | true | # -> boolean true |
327
+ | RESTSetValue | deleted | false | # -> boolean false |
328
+ | RESTSetValue | comment | $NULL | # -> null |
329
+ | RESTSetValue | ?page | 1 | # -> query param (string) |
330
+ """
331
+ if _is_ignore(value):
332
+ logger.info(f"RESTSetValue: {field} = $IGNORE (skipped)")
333
+ return
334
+
335
+ ctx = self._require_ctx()
336
+ value = _expand(str(value))
337
+ if value == _EMPTY:
338
+ value = ""
339
+
340
+ ctx.set_value(field, value)
341
+ log_val = "***" if "password" in field.lower() else value
342
+ logger.info(f"RESTSetValue: {field} = {log_val}")
343
+
344
+ @keyword("RESTSetValueAsString")
345
+ def rest_set_value_as_string(self, field: str, value: str):
346
+ """Sets a request body field as string, without type conversion.
347
+
348
+ Use this when a value that looks like a number or boolean
349
+ must be sent as a JSON string.
350
+
351
+ Examples:
352
+ | RESTSetValueAsString | zipcode | 01234 | # -> string "01234" |
353
+ | RESTSetValueAsString | flag | true | # -> string "true" |
354
+ | RESTSetValueAsString | code | 42 | # -> string "42" |
355
+ """
356
+ if _is_ignore(value):
357
+ logger.info(f"RESTSetValueAsString: {field} = $IGNORE (skipped)")
358
+ return
359
+
360
+ ctx = self._require_ctx()
361
+ value = _expand(str(value))
362
+ if value == _EMPTY:
363
+ value = ""
364
+
365
+ ctx.set_value(field, value, force_string=True)
366
+ log_val = "***" if "password" in field.lower() else value
367
+ logger.info(f"RESTSetValueAsString: {field} = '{log_val}' (string)")
368
+
369
+ @keyword("RESTSetValueAsList")
370
+ def rest_set_value_as_list(self, field: str, *values: str):
371
+ """Sets a request body field as a JSON array.
372
+
373
+ All values are auto-typed (integers, floats, booleans are
374
+ converted). Use for short primitive arrays.
375
+
376
+ Examples:
377
+ | RESTSetValueAsList | tags | wichtig | dringend | arbeit |
378
+ | RESTSetValueAsList | scores | 42 | 87 | 15 |
379
+ | RESTSetValueAsList | flags | true | false | true |
380
+ """
381
+ if len(values) == 1 and _is_ignore(values[0]):
382
+ logger.info(f"RESTSetValueAsList: {field} = $IGNORE (skipped)")
383
+ return
384
+
385
+ ctx = self._require_ctx()
386
+ expanded = [_expand(str(v)) for v in values]
387
+ ctx.set_value_as_list(field, expanded)
388
+ logger.info(f"RESTSetValueAsList: {field} = [{', '.join(expanded)}]")
389
+
390
+ @keyword("RESTSetFile")
391
+ def rest_set_file(self, field: str, filepath: str, mime_type: str | None = None):
392
+ """Sets a file field for multipart form-data upload.
393
+
394
+ Can be called multiple times — files accumulate until
395
+ ``RESTSelectEndpoint`` resets them. When files are present,
396
+ ``RESTSendRequest`` automatically switches to multipart
397
+ encoding. Text fields set via ``RESTSetValue`` are sent as
398
+ form fields alongside the files.
399
+
400
+ The MIME type is auto-detected from the file extension.
401
+ An optional third argument overrides it.
402
+
403
+ Multiple files with the **same field name** are supported
404
+ (e.g. ``<input type="file" multiple>``).
405
+
406
+ Examples:
407
+ | RESTSetFile | avatar | C:/img/photo.jpg |
408
+ | RESTSetFile | document | report.pdf | application/pdf |
409
+ | RESTSetFile | attachments | file1.jpg |
410
+ | RESTSetFile | attachments | file2.jpg |
411
+ """
412
+ if _is_ignore(filepath):
413
+ logger.info(f"RESTSetFile: {field} = $IGNORE (skipped)")
414
+ return
415
+
416
+ ctx = self._require_ctx()
417
+ filepath = _expand(filepath)
418
+ ctx.set_file(field, filepath, mime_type)
419
+ filename = os.path.basename(filepath)
420
+ logger.info(f"RESTSetFile: {field} = '{filename}' ({mime_type or 'auto'})")
421
+
422
+ @keyword("RESTSetContext")
423
+ def rest_set_context(self, path: str):
424
+ """Sets the context path for subsequent SetValue/VerifyValue calls.
425
+
426
+ Fields are resolved relative to the context path.
427
+ Each call replaces the previous context (flat, no stack).
428
+
429
+ Examples:
430
+ | RESTSetContext | customer |
431
+ | RESTSetContext | customer.address |
432
+ | RESTSetContext | items[0] |
433
+ """
434
+ ctx = self._require_ctx()
435
+ path = _expand(path)
436
+ ctx.set_context(path)
437
+ logger.info(f"RESTSetContext: {path}")
438
+
439
+ @keyword("RESTSetHeader")
440
+ def rest_set_header(self, header: str, value: str):
441
+ """Sets a request header for the next request.
442
+
443
+ Multiple headers can be set. Headers persist until
444
+ RESTSelectEndpoint is called.
445
+
446
+ Examples:
447
+ | RESTSetHeader | x-auth-token | $MEM{TOKEN} |
448
+ | RESTSetHeader | Accept | application/json |
449
+ """
450
+ if _is_ignore(value):
451
+ logger.info(f"RESTSetHeader: {header} = $IGNORE (skipped)")
452
+ return
453
+
454
+ ctx = self._require_ctx()
455
+ value = _expand(str(value))
456
+ ctx.set_header(header, value)
457
+ logger.info(f"RESTSetHeader: {header} = {value}")
458
+
459
+ # ── Action ──────────────────────────────────────────────────
460
+
461
+ @keyword("RESTSendRequest")
462
+ def rest_send_request(self, method: str):
463
+ """Sends the prepared HTTP request.
464
+
465
+ After sending, the response is stored. All subsequent
466
+ RESTVerify* and RESTMemorize* keywords operate on this response.
467
+
468
+ Examples:
469
+ | RESTSendRequest | POST |
470
+ | RESTSendRequest | GET |
471
+ | RESTSendRequest | PUT |
472
+ | RESTSendRequest | DELETE |
473
+ """
474
+ ctx = self._require_ctx()
475
+ method = method.upper()
476
+ url = ctx.get_request_url()
477
+ logger.info(f"RESTSendRequest: {method} {url}")
478
+ send_request(ctx, method)
479
+ logger.info(
480
+ f"RESTSendRequest: Response {ctx.get_response_status()} "
481
+ f"({len(ctx.get_response_body())} bytes)"
482
+ )
483
+
484
+ # ── Verify ──────────────────────────────────────────────────
485
+
486
+ @keyword("RESTVerifyValue")
487
+ def rest_verify_value(self, field: str, expected: str):
488
+ """Verifies a response field value (exact match).
489
+
490
+ Dot notation for nested fields. Context-aware.
491
+
492
+ Examples:
493
+ | RESTVerifyValue | message | User account created successfully |
494
+ | RESTVerifyValue | data.name | Zoltan |
495
+ """
496
+ if _is_ignore(expected):
497
+ logger.info(f"RESTVerifyValue: {field} = $IGNORE (skipped)")
498
+ return
499
+ ctx = self._require_ctx()
500
+ expected = _expand(str(expected))
501
+ if expected == _EMPTY:
502
+ expected = ""
503
+ actual = ctx.get_response_value(field)
504
+ assert_match(actual, expected, MatchMode.EXACT,
505
+ context=f"RESTVerifyValue: {field}")
506
+ logger.info(f"RESTVerifyValue: {field} = '{actual}' (PASS)")
507
+
508
+ @keyword("RESTVerifyValueWCM")
509
+ def rest_verify_value_wcm(self, field: str, expected: str):
510
+ """Verifies a response field value (wildcard match: ``*``, ``?``).
511
+
512
+ Examples:
513
+ | RESTVerifyValueWCM | message | *successfully* |
514
+ """
515
+ if _is_ignore(expected):
516
+ logger.info(f"RESTVerifyValueWCM: {field} = $IGNORE (skipped)")
517
+ return
518
+ ctx = self._require_ctx()
519
+ expected = _expand(str(expected))
520
+ actual = ctx.get_response_value(field)
521
+ assert_match(actual, expected, MatchMode.WCM,
522
+ context=f"RESTVerifyValueWCM: {field}")
523
+ logger.info(f"RESTVerifyValueWCM: {field} = '{actual}' (PASS)")
524
+
525
+ @keyword("RESTVerifyValueREGX")
526
+ def rest_verify_value_regx(self, field: str, expected: str):
527
+ """Verifies a response field value (regular expression match).
528
+
529
+ Examples:
530
+ | RESTVerifyValueREGX | data.id | ^[a-f0-9]{24}$ |
531
+ """
532
+ if _is_ignore(expected):
533
+ logger.info(f"RESTVerifyValueREGX: {field} = $IGNORE (skipped)")
534
+ return
535
+ ctx = self._require_ctx()
536
+ expected = _expand(str(expected))
537
+ actual = ctx.get_response_value(field)
538
+ assert_match(actual, expected, MatchMode.REGX,
539
+ context=f"RESTVerifyValueREGX: {field}")
540
+ logger.info(f"RESTVerifyValueREGX: {field} = '{actual}' (PASS)")
541
+
542
+ @keyword("RESTVerifyStatus")
543
+ def rest_verify_status(self, expected: str):
544
+ """Verifies the HTTP status code of the response.
545
+
546
+ Examples:
547
+ | RESTVerifyStatus | 200 |
548
+ | RESTVerifyStatus | 201 |
549
+ | RESTVerifyStatus | 404 |
550
+ """
551
+ if _is_ignore(expected):
552
+ logger.info(f"RESTVerifyStatus: $IGNORE (skipped)")
553
+ return
554
+ ctx = self._require_ctx()
555
+ actual = ctx.get_response_status()
556
+ expected_int = int(expected)
557
+ if actual != expected_int:
558
+ raise AssertionError(
559
+ f"RESTVerifyStatus: Expected {expected_int}, got {actual}."
560
+ )
561
+ logger.info(f"RESTVerifyStatus: {actual} (PASS)")
562
+
563
+ @keyword("RESTVerifyResponseTime")
564
+ def rest_verify_response_time(self, max_ms: str):
565
+ """Verifies that the response time is below the given threshold.
566
+
567
+ The value is the maximum allowed response time in milliseconds.
568
+ The actual response time must be **less than** this value.
569
+
570
+ Examples:
571
+ | RESTVerifyResponseTime | 500 |
572
+ | RESTVerifyResponseTime | 2000 |
573
+ """
574
+ if _is_ignore(max_ms):
575
+ logger.info(f"RESTVerifyResponseTime: $IGNORE (skipped)")
576
+ return
577
+ ctx = self._require_ctx()
578
+ actual = ctx.get_response_time_ms()
579
+ threshold = float(max_ms)
580
+ if actual >= threshold:
581
+ raise AssertionError(
582
+ f"RESTVerifyResponseTime: {actual:.0f}ms >= {threshold:.0f}ms (too slow)."
583
+ )
584
+ logger.info(f"RESTVerifyResponseTime: {actual:.0f}ms < {threshold:.0f}ms (PASS)")
585
+
586
+ @keyword("RESTVerifyListCount")
587
+ def rest_verify_list_count(self, field: str, expected: str):
588
+ """Verifies the number of elements in a JSON array field.
589
+
590
+ The field must resolve to a list in the response.
591
+ Context-aware (uses current RESTSetContext path).
592
+
593
+ Examples:
594
+ | RESTVerifyListCount | data | 5 |
595
+ | RESTVerifyListCount | items | 0 |
596
+ | RESTVerifyListCount | tags | 3 |
597
+ """
598
+ if _is_ignore(expected):
599
+ logger.info(f"RESTVerifyListCount: {field} = $IGNORE (skipped)")
600
+ return
601
+ ctx = self._require_ctx()
602
+ raw = ctx.get_response_value_raw(field)
603
+ if not isinstance(raw, list):
604
+ raise AssertionError(
605
+ f"RESTVerifyListCount: '{field}' is not a list (got {type(raw).__name__})."
606
+ )
607
+ actual = len(raw)
608
+ expected_int = int(expected)
609
+ if actual != expected_int:
610
+ raise AssertionError(
611
+ f"RESTVerifyListCount: '{field}' has {actual} elements, expected {expected_int}."
612
+ )
613
+ logger.info(f"RESTVerifyListCount: {field} has {actual} elements (PASS)")
614
+
615
+ @keyword("RESTVerifyHeader")
616
+ def rest_verify_header(self, header: str, expected: str):
617
+ """Verifies a response header value.
618
+
619
+ Examples:
620
+ | RESTVerifyHeader | Content-Type | application/json |
621
+ """
622
+ if _is_ignore(expected):
623
+ logger.info(f"RESTVerifyHeader: {header} = $IGNORE (skipped)")
624
+ return
625
+ ctx = self._require_ctx()
626
+ expected = _expand(str(expected))
627
+ actual = ctx.get_response_header(header)
628
+ assert_match(actual, expected, MatchMode.EXACT,
629
+ context=f"RESTVerifyHeader: {header}")
630
+ logger.info(f"RESTVerifyHeader: {header} = '{actual}' (PASS)")
631
+
632
+ # ── Memorize ────────────────────────────────────────────────
633
+
634
+ @keyword("RESTMemorizeValue")
635
+ def rest_memorize_value(self, field: str, name: str):
636
+ """Stores a response field value under a symbolic name.
637
+
638
+ The value can be used later via ``$MEM{name}`` expansion.
639
+
640
+ Examples:
641
+ | RESTMemorizeValue | data.token | TOKEN |
642
+ | RESTMemorizeValue | data.id | USER_ID |
643
+ """
644
+ ctx = self._require_ctx()
645
+ value = ctx.get_response_value(field)
646
+ _mem_store[name] = value
647
+ logger.info(f"RESTMemorizeValue: {field} -> $MEM{{{name}}} = '{value}'")
648
+
649
+ @keyword("RESTMemorizeBody")
650
+ def rest_memorize_body(self, name: str):
651
+ """Stores the entire response body as a string.
652
+
653
+ Examples:
654
+ | RESTMemorizeBody | RESPONSE |
655
+ """
656
+ ctx = self._require_ctx()
657
+ body = ctx.get_response_body()
658
+ _mem_store[name] = body
659
+ logger.info(f"RESTMemorizeBody: -> $MEM{{{name}}} ({len(body)} bytes)")
660
+
661
+ # ── Save ───────────────────────────────────────────────────
662
+
663
+ @keyword("RESTSaveResponseToFile")
664
+ def rest_save_response_to_file(self, filepath: str):
665
+ """Saves the HTTP response body to a local file.
666
+
667
+ The response is written as raw bytes, which works correctly
668
+ for both binary content (PDF, images, ZIP) and text content
669
+ (JSON, XML, CSV, HTML).
670
+
671
+ The file path supports OKW memory expansion (``$MEM{name}``)
672
+ and environment variable expansion (``~``, ``$ENV_VAR``).
673
+
674
+ Parent directories are created automatically if they do not exist.
675
+ If the file already exists, it is overwritten (logged as warning).
676
+
677
+ Examples:
678
+ | RESTSaveResponseToFile | C:/temp/report.pdf |
679
+ | RESTSaveResponseToFile | $MEM{DOWNLOAD_DIR}/export.csv |
680
+ | RESTSaveResponseToFile | ~/downloads/response.json |
681
+ | RESTSaveResponseToFile | $IGNORE |
682
+ """
683
+ if _is_ignore(filepath):
684
+ logger.info("RESTSaveResponseToFile: $IGNORE (skipped)")
685
+ return
686
+
687
+ ctx = self._require_ctx()
688
+ filepath = _expand(filepath)
689
+ filepath = os.path.expanduser(os.path.expandvars(filepath))
690
+
691
+ parent = os.path.dirname(filepath)
692
+ if parent:
693
+ os.makedirs(parent, exist_ok=True)
694
+
695
+ if os.path.isfile(filepath):
696
+ logger.warn(f"RESTSaveResponseToFile: File exists and will be overwritten: '{filepath}'")
697
+
698
+ content = ctx.get_response_content()
699
+
700
+ with open(filepath, "wb") as f:
701
+ f.write(content)
702
+
703
+ content_type = ctx._response_headers.get("Content-Type", "unknown")
704
+ logger.info(
705
+ f"RESTSaveResponseToFile: {len(content)} bytes "
706
+ f"-> '{filepath}' (Content-Type: {content_type})"
707
+ )