]>
Commit | Line | Data |
---|---|---|
11fdf7f2 TL |
1 | # -*- coding: utf-8 -*- |
2 | """ | |
3 | * Copyright (c) 2017 SUSE LLC | |
4 | * | |
5 | * openATTIC is free software; you can redistribute it and/or modify it | |
6 | * under the terms of the GNU General Public License as published by | |
7 | * the Free Software Foundation; version 2. | |
8 | * | |
9 | * This package is distributed in the hope that it will be useful, | |
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | * GNU General Public License for more details. | |
13 | """ | |
14 | from __future__ import absolute_import | |
15 | ||
11fdf7f2 | 16 | import inspect |
9f95a23c | 17 | import logging |
11fdf7f2 | 18 | import re |
f67539c2 | 19 | |
11fdf7f2 | 20 | import requests |
522d829b | 21 | from requests.auth import AuthBase |
11fdf7f2 | 22 | from requests.exceptions import ConnectionError, InvalidURL, Timeout |
f67539c2 | 23 | |
9f95a23c | 24 | from .settings import Settings |
11fdf7f2 TL |
25 | |
26 | try: | |
27 | from requests.packages.urllib3.exceptions import SSLError | |
28 | except ImportError: | |
9f95a23c TL |
29 | from urllib3.exceptions import SSLError # type: ignore |
30 | ||
522d829b | 31 | from typing import List, Optional |
9f95a23c | 32 | |
522d829b | 33 | from mgr_util import build_url |
9f95a23c TL |
34 | |
35 | logger = logging.getLogger('rest_client') | |
11fdf7f2 TL |
36 | |
37 | ||
38 | class TimeoutRequestsSession(requests.Session): | |
39 | """ | |
40 | Set timeout argument for all requests if this is not already done. | |
41 | """ | |
f67539c2 | 42 | |
11fdf7f2 TL |
43 | def request(self, *args, **kwargs): |
44 | if ((args[8] if len(args) > 8 else None) is None) \ | |
45 | and kwargs.get('timeout') is None: | |
46 | if Settings.REST_REQUESTS_TIMEOUT > 0: | |
47 | kwargs['timeout'] = Settings.REST_REQUESTS_TIMEOUT | |
48 | return super(TimeoutRequestsSession, self).request(*args, **kwargs) | |
49 | ||
50 | ||
51 | class RequestException(Exception): | |
52 | def __init__(self, | |
53 | message, | |
54 | status_code=None, | |
55 | content=None, | |
56 | conn_errno=None, | |
57 | conn_strerror=None): | |
58 | super(RequestException, self).__init__(message) | |
59 | self.status_code = status_code | |
60 | self.content = content | |
61 | self.conn_errno = conn_errno | |
62 | self.conn_strerror = conn_strerror | |
63 | ||
64 | ||
65 | class BadResponseFormatException(RequestException): | |
66 | def __init__(self, message): | |
67 | super(BadResponseFormatException, self).__init__( | |
68 | "Bad response format" if message is None else message, None) | |
69 | ||
70 | ||
71 | class _ResponseValidator(object): | |
72 | """Simple JSON schema validator | |
73 | ||
74 | This class implements a very simple validator for the JSON formatted | |
75 | messages received by request responses from a RestClient instance. | |
76 | ||
77 | The validator validates the JSON response against a "structure" string that | |
78 | specifies the structure that the JSON response must comply. The validation | |
79 | procedure raises a BadResponseFormatException in case of a validation | |
80 | failure. | |
81 | ||
82 | The structure syntax is given by the following grammar: | |
83 | ||
84 | Structure ::= Level | |
85 | Level ::= Path | Path '&' Level | |
86 | Path ::= Step | Step '>'+ Path | |
87 | Step ::= Key | '?' Key | '*' | '(' Level ')' | |
88 | Key ::= <string> | Array+ | |
89 | Array ::= '[' <int> ']' | '[' '*' ']' | '[' '+' ']' | |
90 | ||
91 | The symbols enclosed in ' ' are tokens of the language, and the + symbol | |
92 | denotes repetition of of the preceding token at least once. | |
93 | ||
94 | Examples of usage: | |
95 | ||
96 | Example 1: | |
97 | Validator args: | |
98 | structure = "return > *" | |
99 | response = { 'return': { ... } } | |
100 | ||
101 | In the above example the structure will validate against any response | |
102 | that contains a key named "return" in the root of the response | |
103 | dictionary and its value is also a dictionary. | |
104 | ||
105 | Example 2: | |
106 | Validator args: | |
107 | structure = "[*]" | |
108 | response = [...] | |
109 | ||
110 | In the above example the structure will validate against any response | |
111 | that is an array of any size. | |
112 | ||
113 | Example 3: | |
114 | Validator args: | |
115 | structure = "return[*]" | |
116 | response = { 'return': [....] } | |
117 | ||
118 | In the above example the structure will validate against any response | |
119 | that contains a key named "return" in the root of the response | |
120 | dictionary and its value is an array. | |
121 | ||
122 | Example 4: | |
123 | Validator args: | |
124 | structure = "return[0] > token" | |
125 | response = { 'return': [ { 'token': .... } ] } | |
126 | ||
127 | In the above example the structure will validate against any response | |
128 | that contains a key named "return" in the root of the response | |
129 | dictionary and its value is an array, and the first element of the | |
130 | array is a dictionary that contains the key 'token'. | |
131 | ||
132 | Example 5: | |
133 | Validator args: | |
134 | structure = "return[0][*] > key1" | |
135 | response = { 'return': [ [ { 'key1': ... } ], ...] } | |
136 | ||
137 | In the above example the structure will validate against any response | |
138 | that contains a key named "return" in the root of the response | |
139 | dictionary where its value is an array, and the first value of this | |
140 | array is also an array where all it's values must be a dictionary | |
141 | containing a key named "key1". | |
142 | ||
143 | Example 6: | |
144 | Validator args: | |
145 | structure = "return > (key1[*] & key2 & ?key3 > subkey)" | |
146 | response = { 'return': { 'key1': [...], 'key2: .... } ] } | |
147 | ||
148 | In the above example the structure will validate against any response | |
149 | that contains a key named "return" in the root of the response | |
150 | dictionary and its value is a dictionary that must contain a key named | |
151 | "key1" that is an array, a key named "key2", and optionally a key named | |
152 | "key3" that is a dictionary that contains a key named "subkey". | |
153 | ||
154 | Example 7: | |
155 | Validator args: | |
156 | structure = "return >> roles[*]" | |
157 | response = { 'return': { 'key1': { 'roles': [...] }, 'key2': { 'roles': [...] } } } | |
158 | ||
159 | In the above example the structure will validate against any response | |
160 | that contains a key named "return" in the root of the response | |
161 | dictionary, and its value is a dictionary that for any key present in | |
162 | the dictionary their value is also a dictionary that must contain a key | |
163 | named 'roles' that is an array. Please note that you can use any | |
164 | number of successive '>' to denote the level in the JSON tree that you | |
165 | want to match next step in the path. | |
166 | ||
167 | """ | |
168 | ||
169 | @staticmethod | |
170 | def validate(structure, response): | |
171 | if structure is None: | |
172 | return | |
173 | ||
174 | _ResponseValidator._validate_level(structure, response) | |
175 | ||
176 | @staticmethod | |
177 | def _validate_level(level, resp): | |
178 | if not isinstance(resp, dict) and not isinstance(resp, list): | |
179 | raise BadResponseFormatException( | |
180 | "{} is neither a dict nor a list".format(resp)) | |
181 | ||
182 | paths = _ResponseValidator._parse_level_paths(level) | |
183 | for path in paths: | |
184 | path_sep = path.find('>') | |
185 | if path_sep != -1: | |
186 | level_next = path[path_sep + 1:].strip() | |
187 | else: | |
188 | path_sep = len(path) | |
9f95a23c | 189 | level_next = None # type: ignore |
11fdf7f2 TL |
190 | key = path[:path_sep].strip() |
191 | ||
192 | if key == '*': | |
193 | continue | |
194 | elif key == '': # check all keys | |
9f95a23c | 195 | for k in resp.keys(): # type: ignore |
11fdf7f2 TL |
196 | _ResponseValidator._validate_key(k, level_next, resp) |
197 | else: | |
198 | _ResponseValidator._validate_key(key, level_next, resp) | |
199 | ||
200 | @staticmethod | |
201 | def _validate_array(array_seq, level_next, resp): | |
202 | if array_seq: | |
203 | if not isinstance(resp, list): | |
204 | raise BadResponseFormatException( | |
205 | "{} is not an array".format(resp)) | |
206 | if array_seq[0].isdigit(): | |
207 | idx = int(array_seq[0]) | |
208 | if len(resp) <= idx: | |
209 | raise BadResponseFormatException( | |
210 | "length of array {} is lower than the index {}".format( | |
211 | resp, idx)) | |
212 | _ResponseValidator._validate_array(array_seq[1:], level_next, | |
213 | resp[idx]) | |
214 | elif array_seq[0] == '*': | |
215 | for r in resp: | |
216 | _ResponseValidator._validate_array(array_seq[1:], | |
217 | level_next, r) | |
218 | elif array_seq[0] == '+': | |
219 | if len(resp) < 1: | |
220 | raise BadResponseFormatException( | |
221 | "array should not be empty") | |
222 | for r in resp: | |
223 | _ResponseValidator._validate_array(array_seq[1:], | |
224 | level_next, r) | |
225 | else: | |
226 | raise Exception( | |
227 | "Response structure is invalid: only <int> | '*' are " | |
228 | "allowed as array index arguments") | |
229 | else: | |
230 | if level_next: | |
231 | _ResponseValidator._validate_level(level_next, resp) | |
232 | ||
233 | @staticmethod | |
234 | def _validate_key(key, level_next, resp): | |
235 | array_access = [a.strip() for a in key.split("[")] | |
236 | key = array_access[0] | |
237 | if key: | |
238 | optional = key[0] == '?' | |
239 | if optional: | |
240 | key = key[1:] | |
241 | if key not in resp: | |
242 | if optional: | |
243 | return | |
244 | raise BadResponseFormatException( | |
245 | "key {} is not in dict {}".format(key, resp)) | |
246 | resp_next = resp[key] | |
247 | else: | |
248 | resp_next = resp | |
249 | if len(array_access) > 1: | |
250 | _ResponseValidator._validate_array( | |
251 | [a[0:-1] for a in array_access[1:]], level_next, resp_next) | |
252 | else: | |
253 | if level_next: | |
254 | _ResponseValidator._validate_level(level_next, resp_next) | |
255 | ||
256 | @staticmethod | |
257 | def _parse_level_paths(level): | |
9f95a23c | 258 | # type: (str) -> List[str] |
11fdf7f2 TL |
259 | level = level.strip() |
260 | if level[0] == '(': | |
261 | level = level[1:] | |
262 | if level[-1] == ')': | |
263 | level = level[:-1] | |
264 | ||
265 | paths = [] | |
266 | lp = 0 | |
267 | nested = 0 | |
268 | for i, c in enumerate(level): | |
269 | if c == '&' and nested == 0: | |
270 | paths.append(level[lp:i].strip()) | |
271 | lp = i + 1 | |
272 | elif c == '(': | |
273 | nested += 1 | |
274 | elif c == ')': | |
275 | nested -= 1 | |
276 | paths.append(level[lp:].strip()) | |
277 | return paths | |
278 | ||
279 | ||
280 | class _Request(object): | |
281 | def __init__(self, method, path, path_params, rest_client, resp_structure): | |
282 | self.method = method | |
283 | self.path = path | |
284 | self.path_params = path_params | |
285 | self.rest_client = rest_client | |
286 | self.resp_structure = resp_structure | |
287 | ||
288 | def _gen_path(self): | |
289 | new_path = self.path | |
290 | matches = re.finditer(r'\{(\w+?)\}', self.path) | |
291 | for match in matches: | |
292 | if match: | |
293 | param_key = match.group(1) | |
294 | if param_key in self.path_params: | |
295 | new_path = new_path.replace( | |
296 | match.group(0), self.path_params[param_key]) | |
297 | else: | |
298 | raise RequestException( | |
299 | 'Invalid path. Param "{}" was not specified' | |
300 | .format(param_key), None) | |
301 | return new_path | |
302 | ||
303 | def __call__(self, | |
304 | req_data=None, | |
305 | method=None, | |
306 | params=None, | |
307 | data=None, | |
9f95a23c TL |
308 | raw_content=False, |
309 | headers=None): | |
11fdf7f2 TL |
310 | method = method if method else self.method |
311 | if not method: | |
312 | raise Exception('No HTTP request method specified') | |
313 | if req_data: | |
314 | if method == 'get': | |
315 | if params: | |
316 | raise Exception('Ambiguous source of GET params') | |
317 | params = req_data | |
318 | else: | |
319 | if data: | |
320 | raise Exception('Ambiguous source of {} data'.format( | |
321 | method.upper())) | |
322 | data = req_data | |
323 | resp = self.rest_client.do_request(method, self._gen_path(), params, | |
9f95a23c | 324 | data, raw_content, headers) |
11fdf7f2 TL |
325 | if raw_content and self.resp_structure: |
326 | raise Exception("Cannot validate response in raw format") | |
327 | _ResponseValidator.validate(self.resp_structure, resp) | |
328 | return resp | |
329 | ||
330 | ||
331 | class RestClient(object): | |
522d829b TL |
332 | def __init__(self, |
333 | host: str, | |
334 | port: int, | |
335 | client_name: Optional[str] = None, | |
336 | ssl: bool = False, | |
337 | auth: Optional[AuthBase] = None, | |
338 | ssl_verify: bool = True) -> None: | |
11fdf7f2 TL |
339 | super(RestClient, self).__init__() |
340 | self.client_name = client_name if client_name else '' | |
341 | self.host = host | |
342 | self.port = port | |
343 | self.base_url = build_url( | |
344 | scheme='https' if ssl else 'http', host=host, port=port) | |
345 | logger.debug("REST service base URL: %s", self.base_url) | |
346 | self.headers = {'Accept': 'application/json'} | |
347 | self.auth = auth | |
348 | self.session = TimeoutRequestsSession() | |
349 | self.session.verify = ssl_verify | |
350 | ||
351 | def _login(self, request=None): | |
352 | pass | |
353 | ||
354 | def _is_logged_in(self): | |
355 | pass | |
356 | ||
357 | def _reset_login(self): | |
358 | pass | |
359 | ||
360 | def is_service_online(self, request=None): | |
361 | pass | |
362 | ||
363 | @staticmethod | |
364 | def requires_login(func): | |
365 | def func_wrapper(self, *args, **kwargs): | |
366 | retries = 2 | |
367 | while True: | |
368 | try: | |
369 | if not self._is_logged_in(): | |
370 | self._login() | |
371 | resp = func(self, *args, **kwargs) | |
372 | return resp | |
373 | except RequestException as e: | |
374 | if isinstance(e, BadResponseFormatException): | |
375 | raise e | |
376 | retries -= 1 | |
377 | if e.status_code not in [401, 403] or retries == 0: | |
378 | raise e | |
379 | self._reset_login() | |
380 | ||
381 | return func_wrapper | |
382 | ||
383 | def do_request(self, | |
384 | method, | |
385 | path, | |
386 | params=None, | |
387 | data=None, | |
9f95a23c TL |
388 | raw_content=False, |
389 | headers=None): | |
11fdf7f2 TL |
390 | url = '{}{}'.format(self.base_url, path) |
391 | logger.debug('%s REST API %s req: %s data: %s', self.client_name, | |
392 | method.upper(), path, data) | |
9f95a23c TL |
393 | request_headers = self.headers.copy() |
394 | if headers: | |
395 | request_headers.update(headers) | |
11fdf7f2 TL |
396 | try: |
397 | if method.lower() == 'get': | |
398 | resp = self.session.get( | |
9f95a23c | 399 | url, headers=request_headers, params=params, auth=self.auth) |
11fdf7f2 TL |
400 | elif method.lower() == 'post': |
401 | resp = self.session.post( | |
402 | url, | |
9f95a23c | 403 | headers=request_headers, |
11fdf7f2 TL |
404 | params=params, |
405 | data=data, | |
406 | auth=self.auth) | |
407 | elif method.lower() == 'put': | |
408 | resp = self.session.put( | |
409 | url, | |
9f95a23c | 410 | headers=request_headers, |
11fdf7f2 TL |
411 | params=params, |
412 | data=data, | |
413 | auth=self.auth) | |
414 | elif method.lower() == 'delete': | |
415 | resp = self.session.delete( | |
416 | url, | |
9f95a23c | 417 | headers=request_headers, |
11fdf7f2 TL |
418 | params=params, |
419 | data=data, | |
420 | auth=self.auth) | |
421 | else: | |
422 | raise RequestException('Method "{}" not supported'.format( | |
423 | method.upper()), None) | |
424 | if resp.ok: | |
425 | logger.debug("%s REST API %s res status: %s content: %s", | |
426 | self.client_name, method.upper(), | |
427 | resp.status_code, resp.text) | |
428 | if raw_content: | |
429 | return resp.content | |
430 | try: | |
431 | return resp.json() if resp.text else None | |
432 | except ValueError: | |
433 | logger.error( | |
434 | "%s REST API failed %s req while decoding JSON " | |
435 | "response : %s", | |
436 | self.client_name, method.upper(), resp.text) | |
437 | raise RequestException( | |
438 | "{} REST API failed request while decoding JSON " | |
439 | "response: {}".format(self.client_name, resp.text), | |
440 | resp.status_code, resp.text) | |
441 | else: | |
442 | logger.error( | |
443 | "%s REST API failed %s req status: %s", self.client_name, | |
444 | method.upper(), resp.status_code) | |
11fdf7f2 TL |
445 | from pprint import pformat as pf |
446 | ||
447 | raise RequestException( | |
448 | "{} REST API failed request with status code {}\n" | |
449 | "{}" # TODO remove | |
450 | .format(self.client_name, resp.status_code, pf( | |
451 | resp.content)), | |
f67539c2 | 452 | self._handle_response_status_code(resp.status_code), |
11fdf7f2 TL |
453 | resp.content) |
454 | except ConnectionError as ex: | |
455 | if ex.args: | |
456 | if isinstance(ex.args[0], SSLError): | |
457 | errno = "n/a" | |
458 | strerror = "SSL error. Probably trying to access a non " \ | |
459 | "SSL connection." | |
9f95a23c TL |
460 | logger.error("%s REST API failed %s, SSL error (url=%s).", |
461 | self.client_name, method.upper(), ex.request.url) | |
11fdf7f2 TL |
462 | else: |
463 | try: | |
464 | match = re.match(r'.*: \[Errno (-?\d+)\] (.+)', | |
465 | ex.args[0].reason.args[0]) | |
466 | except AttributeError: | |
9f95a23c | 467 | match = None |
11fdf7f2 TL |
468 | if match: |
469 | errno = match.group(1) | |
470 | strerror = match.group(2) | |
471 | logger.error( | |
9f95a23c | 472 | "%s REST API failed %s, connection error (url=%s): " |
11fdf7f2 | 473 | "[errno: %s] %s", |
9f95a23c TL |
474 | self.client_name, method.upper(), ex.request.url, |
475 | errno, strerror) | |
11fdf7f2 TL |
476 | else: |
477 | errno = "n/a" | |
478 | strerror = "n/a" | |
479 | logger.error( | |
9f95a23c TL |
480 | "%s REST API failed %s, connection error (url=%s).", |
481 | self.client_name, method.upper(), ex.request.url) | |
11fdf7f2 TL |
482 | else: |
483 | errno = "n/a" | |
484 | strerror = "n/a" | |
9f95a23c TL |
485 | logger.error("%s REST API failed %s, connection error (url=%s).", |
486 | self.client_name, method.upper(), ex.request.url) | |
11fdf7f2 TL |
487 | |
488 | if errno != "n/a": | |
489 | ex_msg = ( | |
490 | "{} REST API cannot be reached: {} [errno {}]. " | |
491 | "Please check your configuration and that the API endpoint" | |
492 | " is accessible" | |
493 | .format(self.client_name, strerror, errno)) | |
494 | else: | |
495 | ex_msg = ( | |
496 | "{} REST API cannot be reached. Please check " | |
497 | "your configuration and that the API endpoint is" | |
498 | " accessible" | |
499 | .format(self.client_name)) | |
500 | raise RequestException( | |
501 | ex_msg, conn_errno=errno, conn_strerror=strerror) | |
502 | except InvalidURL as ex: | |
503 | logger.exception("%s REST API failed %s: %s", self.client_name, | |
504 | method.upper(), str(ex)) | |
505 | raise RequestException(str(ex)) | |
506 | except Timeout as ex: | |
507 | msg = "{} REST API {} timed out after {} seconds (url={}).".format( | |
508 | self.client_name, ex.request.method, Settings.REST_REQUESTS_TIMEOUT, | |
509 | ex.request.url) | |
510 | logger.exception(msg) | |
511 | raise RequestException(msg) | |
512 | ||
f67539c2 TL |
513 | @staticmethod |
514 | def _handle_response_status_code(status_code: int) -> int: | |
515 | """ | |
516 | Method to be overridden by subclasses that need specific handling. | |
517 | """ | |
518 | return status_code | |
519 | ||
11fdf7f2 TL |
520 | @staticmethod |
521 | def api(path, **api_kwargs): | |
522 | def call_decorator(func): | |
523 | def func_wrapper(self, *args, **kwargs): | |
524 | method = api_kwargs.get('method', None) | |
525 | resp_structure = api_kwargs.get('resp_structure', None) | |
f67539c2 | 526 | args_name = inspect.getfullargspec(func).args |
11fdf7f2 | 527 | args_dict = dict(zip(args_name[1:], args)) |
f67539c2 | 528 | for key, val in kwargs.items(): |
11fdf7f2 TL |
529 | args_dict[key] = val |
530 | return func( | |
531 | self, | |
532 | *args, | |
533 | request=_Request(method, path, args_dict, self, | |
534 | resp_structure), | |
535 | **kwargs) | |
536 | ||
537 | return func_wrapper | |
538 | ||
539 | return call_decorator | |
540 | ||
541 | @staticmethod | |
542 | def api_get(path, resp_structure=None): | |
543 | return RestClient.api( | |
544 | path, method='get', resp_structure=resp_structure) | |
545 | ||
546 | @staticmethod | |
547 | def api_post(path, resp_structure=None): | |
548 | return RestClient.api( | |
549 | path, method='post', resp_structure=resp_structure) | |
550 | ||
551 | @staticmethod | |
552 | def api_put(path, resp_structure=None): | |
553 | return RestClient.api( | |
554 | path, method='put', resp_structure=resp_structure) | |
555 | ||
556 | @staticmethod | |
557 | def api_delete(path, resp_structure=None): | |
558 | return RestClient.api( | |
559 | path, method='delete', resp_structure=resp_structure) |