1 # -*- coding: utf-8 -*-
3 * Copyright (c) 2017 SUSE LLC
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.
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.
20 from requests
.auth
import AuthBase
21 from requests
.exceptions
import ConnectionError
, InvalidURL
, Timeout
23 from .settings
import Settings
26 from requests
.packages
.urllib3
.exceptions
import SSLError
28 from urllib3
.exceptions
import SSLError
# type: ignore
30 from typing
import List
, Optional
32 from mgr_util
import build_url
34 logger
= logging
.getLogger('rest_client')
37 class TimeoutRequestsSession(requests
.Session
):
39 Set timeout argument for all requests if this is not already done.
42 def request(self
, *args
, **kwargs
):
43 if ((args
[8] if len(args
) > 8 else None) is None) \
44 and kwargs
.get('timeout') is None:
45 if Settings
.REST_REQUESTS_TIMEOUT
> 0:
46 kwargs
['timeout'] = Settings
.REST_REQUESTS_TIMEOUT
47 return super(TimeoutRequestsSession
, self
).request(*args
, **kwargs
)
50 class RequestException(Exception):
57 super(RequestException
, self
).__init
__(message
)
58 self
.status_code
= status_code
59 self
.content
= content
60 self
.conn_errno
= conn_errno
61 self
.conn_strerror
= conn_strerror
64 class BadResponseFormatException(RequestException
):
65 def __init__(self
, message
):
66 super(BadResponseFormatException
, self
).__init
__(
67 "Bad response format" if message
is None else message
, None)
70 class _ResponseValidator(object):
71 """Simple JSON schema validator
73 This class implements a very simple validator for the JSON formatted
74 messages received by request responses from a RestClient instance.
76 The validator validates the JSON response against a "structure" string that
77 specifies the structure that the JSON response must comply. The validation
78 procedure raises a BadResponseFormatException in case of a validation
81 The structure syntax is given by the following grammar:
84 Level ::= Path | Path '&' Level
85 Path ::= Step | Step '>'+ Path
86 Step ::= Key | '?' Key | '*' | '(' Level ')'
87 Key ::= <string> | Array+
88 Array ::= '[' <int> ']' | '[' '*' ']' | '[' '+' ']'
90 The symbols enclosed in ' ' are tokens of the language, and the + symbol
91 denotes repetition of of the preceding token at least once.
97 structure = "return > *"
98 response = { 'return': { ... } }
100 In the above example the structure will validate against any response
101 that contains a key named "return" in the root of the response
102 dictionary and its value is also a dictionary.
109 In the above example the structure will validate against any response
110 that is an array of any size.
114 structure = "return[*]"
115 response = { 'return': [....] }
117 In the above example the structure will validate against any response
118 that contains a key named "return" in the root of the response
119 dictionary and its value is an array.
123 structure = "return[0] > token"
124 response = { 'return': [ { 'token': .... } ] }
126 In the above example the structure will validate against any response
127 that contains a key named "return" in the root of the response
128 dictionary and its value is an array, and the first element of the
129 array is a dictionary that contains the key 'token'.
133 structure = "return[0][*] > key1"
134 response = { 'return': [ [ { 'key1': ... } ], ...] }
136 In the above example the structure will validate against any response
137 that contains a key named "return" in the root of the response
138 dictionary where its value is an array, and the first value of this
139 array is also an array where all it's values must be a dictionary
140 containing a key named "key1".
144 structure = "return > (key1[*] & key2 & ?key3 > subkey)"
145 response = { 'return': { 'key1': [...], 'key2: .... } ] }
147 In the above example the structure will validate against any response
148 that contains a key named "return" in the root of the response
149 dictionary and its value is a dictionary that must contain a key named
150 "key1" that is an array, a key named "key2", and optionally a key named
151 "key3" that is a dictionary that contains a key named "subkey".
155 structure = "return >> roles[*]"
156 response = { 'return': { 'key1': { 'roles': [...] }, 'key2': { 'roles': [...] } } }
158 In the above example the structure will validate against any response
159 that contains a key named "return" in the root of the response
160 dictionary, and its value is a dictionary that for any key present in
161 the dictionary their value is also a dictionary that must contain a key
162 named 'roles' that is an array. Please note that you can use any
163 number of successive '>' to denote the level in the JSON tree that you
164 want to match next step in the path.
169 def validate(structure
, response
):
170 if structure
is None:
173 _ResponseValidator
._validate
_level
(structure
, response
)
176 def _validate_level(level
, resp
):
177 if not isinstance(resp
, dict) and not isinstance(resp
, list):
178 raise BadResponseFormatException(
179 "{} is neither a dict nor a list".format(resp
))
181 paths
= _ResponseValidator
._parse
_level
_paths
(level
)
183 path_sep
= path
.find('>')
185 level_next
= path
[path_sep
+ 1:].strip()
188 level_next
= None # type: ignore
189 key
= path
[:path_sep
].strip()
193 elif key
== '': # check all keys
194 for k
in resp
.keys(): # type: ignore
195 _ResponseValidator
._validate
_key
(k
, level_next
, resp
)
197 _ResponseValidator
._validate
_key
(key
, level_next
, resp
)
200 def _validate_array(array_seq
, level_next
, resp
):
202 if not isinstance(resp
, list):
203 raise BadResponseFormatException(
204 "{} is not an array".format(resp
))
205 if array_seq
[0].isdigit():
206 idx
= int(array_seq
[0])
208 raise BadResponseFormatException(
209 "length of array {} is lower than the index {}".format(
211 _ResponseValidator
._validate
_array
(array_seq
[1:], level_next
,
213 elif array_seq
[0] == '*':
214 _ResponseValidator
.validate_all_resp(resp
, array_seq
, level_next
)
215 elif array_seq
[0] == '+':
217 raise BadResponseFormatException(
218 "array should not be empty")
219 _ResponseValidator
.validate_all_resp(resp
, array_seq
, level_next
)
222 "Response structure is invalid: only <int> | '*' are "
223 "allowed as array index arguments")
226 _ResponseValidator
._validate
_level
(level_next
, resp
)
229 def validate_all_resp(resp
, array_seq
, level_next
):
231 _ResponseValidator
._validate
_array
(array_seq
[1:],
235 def _validate_key(key
, level_next
, resp
):
236 array_access
= [a
.strip() for a
in key
.split("[")]
237 key
= array_access
[0]
239 optional
= key
[0] == '?'
245 raise BadResponseFormatException(
246 "key {} is not in dict {}".format(key
, resp
))
247 resp_next
= resp
[key
]
250 if len(array_access
) > 1:
251 _ResponseValidator
._validate
_array
(
252 [a
[0:-1] for a
in array_access
[1:]], level_next
, resp_next
)
255 _ResponseValidator
._validate
_level
(level_next
, resp_next
)
258 def _parse_level_paths(level
):
259 # type: (str) -> List[str]
260 level
= level
.strip()
269 for i
, c
in enumerate(level
):
270 if c
== '&' and nested
== 0:
271 paths
.append(level
[lp
:i
].strip())
277 paths
.append(level
[lp
:].strip())
281 class _Request(object):
282 def __init__(self
, method
, path
, path_params
, rest_client
, resp_structure
):
285 self
.path_params
= path_params
286 self
.rest_client
= rest_client
287 self
.resp_structure
= resp_structure
291 matches
= re
.finditer(r
'\{(\w+?)\}', self
.path
)
292 for match
in matches
:
294 param_key
= match
.group(1)
295 if param_key
in self
.path_params
:
296 new_path
= new_path
.replace(
297 match
.group(0), self
.path_params
[param_key
])
299 raise RequestException(
300 'Invalid path. Param "{}" was not specified'
301 .format(param_key
), None)
311 method
= method
if method
else self
.method
313 raise Exception('No HTTP request method specified')
317 raise Exception('Ambiguous source of GET params')
321 raise Exception('Ambiguous source of {} data'.format(
324 resp
= self
.rest_client
.do_request(method
, self
._gen
_path
(), params
,
325 data
, raw_content
, headers
)
326 if raw_content
and self
.resp_structure
:
327 raise Exception("Cannot validate response in raw format")
328 _ResponseValidator
.validate(self
.resp_structure
, resp
)
332 class RestClient(object):
336 client_name
: Optional
[str] = None,
338 auth
: Optional
[AuthBase
] = None,
339 ssl_verify
: bool = True) -> None:
340 super(RestClient
, self
).__init
__()
341 self
.client_name
= client_name
if client_name
else ''
344 self
.base_url
= build_url(
345 scheme
='https' if ssl
else 'http', host
=host
, port
=port
)
346 logger
.debug("REST service base URL: %s", self
.base_url
)
347 self
.headers
= {'Accept': 'application/json'}
349 self
.session
= TimeoutRequestsSession()
350 self
.session
.verify
= ssl_verify
352 def _login(self
, request
=None):
355 def _is_logged_in(self
):
358 def _reset_login(self
):
361 def is_service_online(self
, request
=None):
365 def requires_login(func
):
366 def func_wrapper(self
, *args
, **kwargs
):
370 if not self
._is
_logged
_in
():
372 resp
= func(self
, *args
, **kwargs
)
374 except RequestException
as e
:
375 if isinstance(e
, BadResponseFormatException
):
378 if e
.status_code
not in [401, 403] or retries
== 0:
391 url
= '{}{}'.format(self
.base_url
, path
)
392 logger
.debug('%s REST API %s req: %s data: %s', self
.client_name
,
393 method
.upper(), path
, data
)
394 request_headers
= self
.headers
.copy()
396 request_headers
.update(headers
)
398 resp
= self
.send_request(method
, url
, request_headers
, params
, data
)
400 logger
.debug("%s REST API %s res status: %s content: %s",
401 self
.client_name
, method
.upper(),
402 resp
.status_code
, resp
.text
)
406 return resp
.json() if resp
.text
else None
409 "%s REST API failed %s req while decoding JSON "
411 self
.client_name
, method
.upper(), resp
.text
)
412 raise RequestException(
413 "{} REST API failed request while decoding JSON "
414 "response: {}".format(self
.client_name
, resp
.text
),
415 resp
.status_code
, resp
.text
)
418 "%s REST API failed %s req status: %s", self
.client_name
,
419 method
.upper(), resp
.status_code
)
420 from pprint
import pformat
as pf
422 raise RequestException(
423 "{} REST API failed request with status code {}\n"
425 .format(self
.client_name
, resp
.status_code
, pf(
427 self
._handle
_response
_status
_code
(resp
.status_code
),
429 except ConnectionError
as ex
:
430 self
.handle_connection_error(ex
, method
)
431 except InvalidURL
as ex
:
432 logger
.exception("%s REST API failed %s: %s", self
.client_name
,
433 method
.upper(), str(ex
))
434 raise RequestException(str(ex
))
435 except Timeout
as ex
:
436 msg
= "{} REST API {} timed out after {} seconds (url={}).".format(
437 self
.client_name
, ex
.request
.method
, Settings
.REST_REQUESTS_TIMEOUT
,
439 logger
.exception(msg
)
440 raise RequestException(msg
)
442 def send_request(self
, method
, url
, request_headers
, params
, data
):
443 if method
.lower() == 'get':
444 resp
= self
.session
.get(
445 url
, headers
=request_headers
, params
=params
, auth
=self
.auth
)
446 elif method
.lower() == 'post':
447 resp
= self
.session
.post(
449 headers
=request_headers
,
453 elif method
.lower() == 'put':
454 resp
= self
.session
.put(
456 headers
=request_headers
,
460 elif method
.lower() == 'delete':
461 resp
= self
.session
.delete(
463 headers
=request_headers
,
468 raise RequestException('Method "{}" not supported'.format(
469 method
.upper()), None)
472 def handle_connection_error(self
, exception
, method
):
474 if isinstance(exception
.args
[0], SSLError
):
476 strerror
= "SSL error. Probably trying to access a non " \
478 logger
.error("%s REST API failed %s, SSL error (url=%s).",
479 self
.client_name
, method
.upper(), exception
.request
.url
)
482 match
= re
.match(r
'.*: \[Errno (-?\d+)\] (.+)',
483 exception
.args
[0].reason
.args
[0])
484 except AttributeError:
487 errno
= match
.group(1)
488 strerror
= match
.group(2)
490 "%s REST API failed %s, connection error (url=%s): "
492 self
.client_name
, method
.upper(), exception
.request
.url
,
498 "%s REST API failed %s, connection error (url=%s).",
499 self
.client_name
, method
.upper(), exception
.request
.url
)
503 logger
.error("%s REST API failed %s, connection error (url=%s).",
504 self
.client_name
, method
.upper(), exception
.request
.url
)
507 "{} REST API cannot be reached: {} [errno {}]. "
508 "Please check your configuration and that the API endpoint"
510 .format(self
.client_name
, strerror
, errno
))
513 "{} REST API cannot be reached. Please check "
514 "your configuration and that the API endpoint is"
516 .format(self
.client_name
))
517 raise RequestException(
518 exception_msg
, conn_errno
=errno
, conn_strerror
=strerror
)
521 def _handle_response_status_code(status_code
: int) -> int:
523 Method to be overridden by subclasses that need specific handling.
528 def api(path
, **api_kwargs
):
529 def call_decorator(func
):
530 def func_wrapper(self
, *args
, **kwargs
):
531 method
= api_kwargs
.get('method', None)
532 resp_structure
= api_kwargs
.get('resp_structure', None)
533 args_name
= inspect
.getfullargspec(func
).args
534 args_dict
= dict(zip(args_name
[1:], args
))
535 for key
, val
in kwargs
.items():
540 request
=_Request(method
, path
, args_dict
, self
,
546 return call_decorator
549 def api_get(path
, resp_structure
=None):
550 return RestClient
.api(
551 path
, method
='get', resp_structure
=resp_structure
)
554 def api_post(path
, resp_structure
=None):
555 return RestClient
.api(
556 path
, method
='post', resp_structure
=resp_structure
)
559 def api_put(path
, resp_structure
=None):
560 return RestClient
.api(
561 path
, method
='put', resp_structure
=resp_structure
)
564 def api_delete(path
, resp_structure
=None):
565 return RestClient
.api(
566 path
, method
='delete', resp_structure
=resp_structure
)