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.
14 from __future__
import absolute_import
21 from requests
.exceptions
import ConnectionError
, InvalidURL
, Timeout
23 from .settings
import Settings
24 from .tools
import build_url
27 from requests
.packages
.urllib3
.exceptions
import SSLError
29 from urllib3
.exceptions
import SSLError
# type: ignore
32 from typing
import List
34 pass # Just for type checking
37 logger
= logging
.getLogger('rest_client')
40 class TimeoutRequestsSession(requests
.Session
):
42 Set timeout argument for all requests if this is not already done.
45 def request(self
, *args
, **kwargs
):
46 if ((args
[8] if len(args
) > 8 else None) is None) \
47 and kwargs
.get('timeout') is None:
48 if Settings
.REST_REQUESTS_TIMEOUT
> 0:
49 kwargs
['timeout'] = Settings
.REST_REQUESTS_TIMEOUT
50 return super(TimeoutRequestsSession
, self
).request(*args
, **kwargs
)
53 class RequestException(Exception):
60 super(RequestException
, self
).__init
__(message
)
61 self
.status_code
= status_code
62 self
.content
= content
63 self
.conn_errno
= conn_errno
64 self
.conn_strerror
= conn_strerror
67 class BadResponseFormatException(RequestException
):
68 def __init__(self
, message
):
69 super(BadResponseFormatException
, self
).__init
__(
70 "Bad response format" if message
is None else message
, None)
73 class _ResponseValidator(object):
74 """Simple JSON schema validator
76 This class implements a very simple validator for the JSON formatted
77 messages received by request responses from a RestClient instance.
79 The validator validates the JSON response against a "structure" string that
80 specifies the structure that the JSON response must comply. The validation
81 procedure raises a BadResponseFormatException in case of a validation
84 The structure syntax is given by the following grammar:
87 Level ::= Path | Path '&' Level
88 Path ::= Step | Step '>'+ Path
89 Step ::= Key | '?' Key | '*' | '(' Level ')'
90 Key ::= <string> | Array+
91 Array ::= '[' <int> ']' | '[' '*' ']' | '[' '+' ']'
93 The symbols enclosed in ' ' are tokens of the language, and the + symbol
94 denotes repetition of of the preceding token at least once.
100 structure = "return > *"
101 response = { 'return': { ... } }
103 In the above example the structure will validate against any response
104 that contains a key named "return" in the root of the response
105 dictionary and its value is also a dictionary.
112 In the above example the structure will validate against any response
113 that is an array of any size.
117 structure = "return[*]"
118 response = { 'return': [....] }
120 In the above example the structure will validate against any response
121 that contains a key named "return" in the root of the response
122 dictionary and its value is an array.
126 structure = "return[0] > token"
127 response = { 'return': [ { 'token': .... } ] }
129 In the above example the structure will validate against any response
130 that contains a key named "return" in the root of the response
131 dictionary and its value is an array, and the first element of the
132 array is a dictionary that contains the key 'token'.
136 structure = "return[0][*] > key1"
137 response = { 'return': [ [ { 'key1': ... } ], ...] }
139 In the above example the structure will validate against any response
140 that contains a key named "return" in the root of the response
141 dictionary where its value is an array, and the first value of this
142 array is also an array where all it's values must be a dictionary
143 containing a key named "key1".
147 structure = "return > (key1[*] & key2 & ?key3 > subkey)"
148 response = { 'return': { 'key1': [...], 'key2: .... } ] }
150 In the above example the structure will validate against any response
151 that contains a key named "return" in the root of the response
152 dictionary and its value is a dictionary that must contain a key named
153 "key1" that is an array, a key named "key2", and optionally a key named
154 "key3" that is a dictionary that contains a key named "subkey".
158 structure = "return >> roles[*]"
159 response = { 'return': { 'key1': { 'roles': [...] }, 'key2': { 'roles': [...] } } }
161 In the above example the structure will validate against any response
162 that contains a key named "return" in the root of the response
163 dictionary, and its value is a dictionary that for any key present in
164 the dictionary their value is also a dictionary that must contain a key
165 named 'roles' that is an array. Please note that you can use any
166 number of successive '>' to denote the level in the JSON tree that you
167 want to match next step in the path.
172 def validate(structure
, response
):
173 if structure
is None:
176 _ResponseValidator
._validate
_level
(structure
, response
)
179 def _validate_level(level
, resp
):
180 if not isinstance(resp
, dict) and not isinstance(resp
, list):
181 raise BadResponseFormatException(
182 "{} is neither a dict nor a list".format(resp
))
184 paths
= _ResponseValidator
._parse
_level
_paths
(level
)
186 path_sep
= path
.find('>')
188 level_next
= path
[path_sep
+ 1:].strip()
191 level_next
= None # type: ignore
192 key
= path
[:path_sep
].strip()
196 elif key
== '': # check all keys
197 for k
in resp
.keys(): # type: ignore
198 _ResponseValidator
._validate
_key
(k
, level_next
, resp
)
200 _ResponseValidator
._validate
_key
(key
, level_next
, resp
)
203 def _validate_array(array_seq
, level_next
, resp
):
205 if not isinstance(resp
, list):
206 raise BadResponseFormatException(
207 "{} is not an array".format(resp
))
208 if array_seq
[0].isdigit():
209 idx
= int(array_seq
[0])
211 raise BadResponseFormatException(
212 "length of array {} is lower than the index {}".format(
214 _ResponseValidator
._validate
_array
(array_seq
[1:], level_next
,
216 elif array_seq
[0] == '*':
218 _ResponseValidator
._validate
_array
(array_seq
[1:],
220 elif array_seq
[0] == '+':
222 raise BadResponseFormatException(
223 "array should not be empty")
225 _ResponseValidator
._validate
_array
(array_seq
[1:],
229 "Response structure is invalid: only <int> | '*' are "
230 "allowed as array index arguments")
233 _ResponseValidator
._validate
_level
(level_next
, resp
)
236 def _validate_key(key
, level_next
, resp
):
237 array_access
= [a
.strip() for a
in key
.split("[")]
238 key
= array_access
[0]
240 optional
= key
[0] == '?'
246 raise BadResponseFormatException(
247 "key {} is not in dict {}".format(key
, resp
))
248 resp_next
= resp
[key
]
251 if len(array_access
) > 1:
252 _ResponseValidator
._validate
_array
(
253 [a
[0:-1] for a
in array_access
[1:]], level_next
, resp_next
)
256 _ResponseValidator
._validate
_level
(level_next
, resp_next
)
259 def _parse_level_paths(level
):
260 # type: (str) -> List[str]
261 level
= level
.strip()
270 for i
, c
in enumerate(level
):
271 if c
== '&' and nested
== 0:
272 paths
.append(level
[lp
:i
].strip())
278 paths
.append(level
[lp
:].strip())
282 class _Request(object):
283 def __init__(self
, method
, path
, path_params
, rest_client
, resp_structure
):
286 self
.path_params
= path_params
287 self
.rest_client
= rest_client
288 self
.resp_structure
= resp_structure
292 matches
= re
.finditer(r
'\{(\w+?)\}', self
.path
)
293 for match
in matches
:
295 param_key
= match
.group(1)
296 if param_key
in self
.path_params
:
297 new_path
= new_path
.replace(
298 match
.group(0), self
.path_params
[param_key
])
300 raise RequestException(
301 'Invalid path. Param "{}" was not specified'
302 .format(param_key
), None)
312 method
= method
if method
else self
.method
314 raise Exception('No HTTP request method specified')
318 raise Exception('Ambiguous source of GET params')
322 raise Exception('Ambiguous source of {} data'.format(
325 resp
= self
.rest_client
.do_request(method
, self
._gen
_path
(), params
,
326 data
, raw_content
, headers
)
327 if raw_content
and self
.resp_structure
:
328 raise Exception("Cannot validate response in raw format")
329 _ResponseValidator
.validate(self
.resp_structure
, resp
)
333 class RestClient(object):
334 def __init__(self
, host
, port
, client_name
=None, ssl
=False, auth
=None, ssl_verify
=True):
335 super(RestClient
, self
).__init
__()
336 self
.client_name
= client_name
if client_name
else ''
339 self
.base_url
= build_url(
340 scheme
='https' if ssl
else 'http', host
=host
, port
=port
)
341 logger
.debug("REST service base URL: %s", self
.base_url
)
342 self
.headers
= {'Accept': 'application/json'}
344 self
.session
= TimeoutRequestsSession()
345 self
.session
.verify
= ssl_verify
347 def _login(self
, request
=None):
350 def _is_logged_in(self
):
353 def _reset_login(self
):
356 def is_service_online(self
, request
=None):
360 def requires_login(func
):
361 def func_wrapper(self
, *args
, **kwargs
):
365 if not self
._is
_logged
_in
():
367 resp
= func(self
, *args
, **kwargs
)
369 except RequestException
as e
:
370 if isinstance(e
, BadResponseFormatException
):
373 if e
.status_code
not in [401, 403] or retries
== 0:
386 url
= '{}{}'.format(self
.base_url
, path
)
387 logger
.debug('%s REST API %s req: %s data: %s', self
.client_name
,
388 method
.upper(), path
, data
)
389 request_headers
= self
.headers
.copy()
391 request_headers
.update(headers
)
393 if method
.lower() == 'get':
394 resp
= self
.session
.get(
395 url
, headers
=request_headers
, params
=params
, auth
=self
.auth
)
396 elif method
.lower() == 'post':
397 resp
= self
.session
.post(
399 headers
=request_headers
,
403 elif method
.lower() == 'put':
404 resp
= self
.session
.put(
406 headers
=request_headers
,
410 elif method
.lower() == 'delete':
411 resp
= self
.session
.delete(
413 headers
=request_headers
,
418 raise RequestException('Method "{}" not supported'.format(
419 method
.upper()), None)
421 logger
.debug("%s REST API %s res status: %s content: %s",
422 self
.client_name
, method
.upper(),
423 resp
.status_code
, resp
.text
)
427 return resp
.json() if resp
.text
else None
430 "%s REST API failed %s req while decoding JSON "
432 self
.client_name
, method
.upper(), resp
.text
)
433 raise RequestException(
434 "{} REST API failed request while decoding JSON "
435 "response: {}".format(self
.client_name
, resp
.text
),
436 resp
.status_code
, resp
.text
)
439 "%s REST API failed %s req status: %s", self
.client_name
,
440 method
.upper(), resp
.status_code
)
441 from pprint
import pformat
as pf
443 raise RequestException(
444 "{} REST API failed request with status code {}\n"
446 .format(self
.client_name
, resp
.status_code
, pf(
448 self
._handle
_response
_status
_code
(resp
.status_code
),
450 except ConnectionError
as ex
:
452 if isinstance(ex
.args
[0], SSLError
):
454 strerror
= "SSL error. Probably trying to access a non " \
456 logger
.error("%s REST API failed %s, SSL error (url=%s).",
457 self
.client_name
, method
.upper(), ex
.request
.url
)
460 match
= re
.match(r
'.*: \[Errno (-?\d+)\] (.+)',
461 ex
.args
[0].reason
.args
[0])
462 except AttributeError:
465 errno
= match
.group(1)
466 strerror
= match
.group(2)
468 "%s REST API failed %s, connection error (url=%s): "
470 self
.client_name
, method
.upper(), ex
.request
.url
,
476 "%s REST API failed %s, connection error (url=%s).",
477 self
.client_name
, method
.upper(), ex
.request
.url
)
481 logger
.error("%s REST API failed %s, connection error (url=%s).",
482 self
.client_name
, method
.upper(), ex
.request
.url
)
486 "{} REST API cannot be reached: {} [errno {}]. "
487 "Please check your configuration and that the API endpoint"
489 .format(self
.client_name
, strerror
, errno
))
492 "{} REST API cannot be reached. Please check "
493 "your configuration and that the API endpoint is"
495 .format(self
.client_name
))
496 raise RequestException(
497 ex_msg
, conn_errno
=errno
, conn_strerror
=strerror
)
498 except InvalidURL
as ex
:
499 logger
.exception("%s REST API failed %s: %s", self
.client_name
,
500 method
.upper(), str(ex
))
501 raise RequestException(str(ex
))
502 except Timeout
as ex
:
503 msg
= "{} REST API {} timed out after {} seconds (url={}).".format(
504 self
.client_name
, ex
.request
.method
, Settings
.REST_REQUESTS_TIMEOUT
,
506 logger
.exception(msg
)
507 raise RequestException(msg
)
510 def _handle_response_status_code(status_code
: int) -> int:
512 Method to be overridden by subclasses that need specific handling.
517 def api(path
, **api_kwargs
):
518 def call_decorator(func
):
519 def func_wrapper(self
, *args
, **kwargs
):
520 method
= api_kwargs
.get('method', None)
521 resp_structure
= api_kwargs
.get('resp_structure', None)
522 args_name
= inspect
.getfullargspec(func
).args
523 args_dict
= dict(zip(args_name
[1:], args
))
524 for key
, val
in kwargs
.items():
529 request
=_Request(method
, path
, args_dict
, self
,
535 return call_decorator
538 def api_get(path
, resp_structure
=None):
539 return RestClient
.api(
540 path
, method
='get', resp_structure
=resp_structure
)
543 def api_post(path
, resp_structure
=None):
544 return RestClient
.api(
545 path
, method
='post', resp_structure
=resp_structure
)
548 def api_put(path
, resp_structure
=None):
549 return RestClient
.api(
550 path
, method
='put', resp_structure
=resp_structure
)
553 def api_delete(path
, resp_structure
=None):
554 return RestClient
.api(
555 path
, method
='delete', resp_structure
=resp_structure
)