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.
- okw_api_rest/__init__.py +3 -0
- okw_api_rest/library.py +707 -0
- okw_api_rest/rest_client.py +269 -0
- okw_api_rest/rest_context.py +217 -0
- robotframework_okw_api_rest-0.1.0.dist-info/METADATA +778 -0
- robotframework_okw_api_rest-0.1.0.dist-info/RECORD +9 -0
- robotframework_okw_api_rest-0.1.0.dist-info/WHEEL +5 -0
- robotframework_okw_api_rest-0.1.0.dist-info/licenses/LICENSE +235 -0
- robotframework_okw_api_rest-0.1.0.dist-info/top_level.txt +1 -0
okw_api_rest/library.py
ADDED
|
@@ -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
|
+
)
|