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
20 from requests
.exceptions
import ConnectionError
, InvalidURL
, Timeout
21 from .settings
import Settings
22 from .tools
import build_url
25 from requests
.packages
.urllib3
.exceptions
import SSLError
27 from urllib3
.exceptions
import SSLError
# type: ignore
30 from typing
import List
32 pass # Just for type checking
35 logger
= logging
.getLogger('rest_client')
38 class TimeoutRequestsSession(requests
.Session
):
40 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] == '*':
215 _ResponseValidator
._validate
_array
(array_seq
[1:],
217 elif array_seq
[0] == '+':
219 raise BadResponseFormatException(
220 "array should not be empty")
222 _ResponseValidator
._validate
_array
(array_seq
[1:],
226 "Response structure is invalid: only <int> | '*' are "
227 "allowed as array index arguments")
230 _ResponseValidator
._validate
_level
(level_next
, resp
)
233 def _validate_key(key
, level_next
, resp
):
234 array_access
= [a
.strip() for a
in key
.split("[")]
235 key
= array_access
[0]
237 optional
= key
[0] == '?'
243 raise BadResponseFormatException(
244 "key {} is not in dict {}".format(key
, resp
))
245 resp_next
= resp
[key
]
248 if len(array_access
) > 1:
249 _ResponseValidator
._validate
_array
(
250 [a
[0:-1] for a
in array_access
[1:]], level_next
, resp_next
)
253 _ResponseValidator
._validate
_level
(level_next
, resp_next
)
256 def _parse_level_paths(level
):
257 # type: (str) -> List[str]
258 level
= level
.strip()
267 for i
, c
in enumerate(level
):
268 if c
== '&' and nested
== 0:
269 paths
.append(level
[lp
:i
].strip())
275 paths
.append(level
[lp
:].strip())
279 class _Request(object):
280 def __init__(self
, method
, path
, path_params
, rest_client
, resp_structure
):
283 self
.path_params
= path_params
284 self
.rest_client
= rest_client
285 self
.resp_structure
= resp_structure
289 matches
= re
.finditer(r
'\{(\w+?)\}', self
.path
)
290 for match
in matches
:
292 param_key
= match
.group(1)
293 if param_key
in self
.path_params
:
294 new_path
= new_path
.replace(
295 match
.group(0), self
.path_params
[param_key
])
297 raise RequestException(
298 'Invalid path. Param "{}" was not specified'
299 .format(param_key
), None)
309 method
= method
if method
else self
.method
311 raise Exception('No HTTP request method specified')
315 raise Exception('Ambiguous source of GET params')
319 raise Exception('Ambiguous source of {} data'.format(
322 resp
= self
.rest_client
.do_request(method
, self
._gen
_path
(), params
,
323 data
, raw_content
, headers
)
324 if raw_content
and self
.resp_structure
:
325 raise Exception("Cannot validate response in raw format")
326 _ResponseValidator
.validate(self
.resp_structure
, resp
)
330 class RestClient(object):
331 def __init__(self
, host
, port
, client_name
=None, ssl
=False, auth
=None, ssl_verify
=True):
332 super(RestClient
, self
).__init
__()
333 self
.client_name
= client_name
if client_name
else ''
336 self
.base_url
= build_url(
337 scheme
='https' if ssl
else 'http', host
=host
, port
=port
)
338 logger
.debug("REST service base URL: %s", self
.base_url
)
339 self
.headers
= {'Accept': 'application/json'}
341 self
.session
= TimeoutRequestsSession()
342 self
.session
.verify
= ssl_verify
344 def _login(self
, request
=None):
347 def _is_logged_in(self
):
350 def _reset_login(self
):
353 def is_service_online(self
, request
=None):
357 def requires_login(func
):
358 def func_wrapper(self
, *args
, **kwargs
):
362 if not self
._is
_logged
_in
():
364 resp
= func(self
, *args
, **kwargs
)
366 except RequestException
as e
:
367 if isinstance(e
, BadResponseFormatException
):
370 if e
.status_code
not in [401, 403] or retries
== 0:
383 url
= '{}{}'.format(self
.base_url
, path
)
384 logger
.debug('%s REST API %s req: %s data: %s', self
.client_name
,
385 method
.upper(), path
, data
)
386 request_headers
= self
.headers
.copy()
388 request_headers
.update(headers
)
390 if method
.lower() == 'get':
391 resp
= self
.session
.get(
392 url
, headers
=request_headers
, params
=params
, auth
=self
.auth
)
393 elif method
.lower() == 'post':
394 resp
= self
.session
.post(
396 headers
=request_headers
,
400 elif method
.lower() == 'put':
401 resp
= self
.session
.put(
403 headers
=request_headers
,
407 elif method
.lower() == 'delete':
408 resp
= self
.session
.delete(
410 headers
=request_headers
,
415 raise RequestException('Method "{}" not supported'.format(
416 method
.upper()), None)
418 logger
.debug("%s REST API %s res status: %s content: %s",
419 self
.client_name
, method
.upper(),
420 resp
.status_code
, resp
.text
)
424 return resp
.json() if resp
.text
else None
427 "%s REST API failed %s req while decoding JSON "
429 self
.client_name
, method
.upper(), resp
.text
)
430 raise RequestException(
431 "{} REST API failed request while decoding JSON "
432 "response: {}".format(self
.client_name
, resp
.text
),
433 resp
.status_code
, resp
.text
)
436 "%s REST API failed %s req status: %s", self
.client_name
,
437 method
.upper(), resp
.status_code
)
438 from pprint
import pformat
as pf
440 raise RequestException(
441 "{} REST API failed request with status code {}\n"
443 .format(self
.client_name
, resp
.status_code
, pf(
447 except ConnectionError
as ex
:
449 if isinstance(ex
.args
[0], SSLError
):
451 strerror
= "SSL error. Probably trying to access a non " \
453 logger
.error("%s REST API failed %s, SSL error (url=%s).",
454 self
.client_name
, method
.upper(), ex
.request
.url
)
457 match
= re
.match(r
'.*: \[Errno (-?\d+)\] (.+)',
458 ex
.args
[0].reason
.args
[0])
459 except AttributeError:
462 errno
= match
.group(1)
463 strerror
= match
.group(2)
465 "%s REST API failed %s, connection error (url=%s): "
467 self
.client_name
, method
.upper(), ex
.request
.url
,
473 "%s REST API failed %s, connection error (url=%s).",
474 self
.client_name
, method
.upper(), ex
.request
.url
)
478 logger
.error("%s REST API failed %s, connection error (url=%s).",
479 self
.client_name
, method
.upper(), ex
.request
.url
)
483 "{} REST API cannot be reached: {} [errno {}]. "
484 "Please check your configuration and that the API endpoint"
486 .format(self
.client_name
, strerror
, errno
))
489 "{} REST API cannot be reached. Please check "
490 "your configuration and that the API endpoint is"
492 .format(self
.client_name
))
493 raise RequestException(
494 ex_msg
, conn_errno
=errno
, conn_strerror
=strerror
)
495 except InvalidURL
as ex
:
496 logger
.exception("%s REST API failed %s: %s", self
.client_name
,
497 method
.upper(), str(ex
))
498 raise RequestException(str(ex
))
499 except Timeout
as ex
:
500 msg
= "{} REST API {} timed out after {} seconds (url={}).".format(
501 self
.client_name
, ex
.request
.method
, Settings
.REST_REQUESTS_TIMEOUT
,
503 logger
.exception(msg
)
504 raise RequestException(msg
)
507 def api(path
, **api_kwargs
):
508 def call_decorator(func
):
509 def func_wrapper(self
, *args
, **kwargs
):
510 method
= api_kwargs
.get('method', None)
511 resp_structure
= api_kwargs
.get('resp_structure', None)
512 args_name
= inspect
.getargspec(func
).args
513 args_dict
= dict(zip(args_name
[1:], args
))
514 for key
, val
in kwargs
:
519 request
=_Request(method
, path
, args_dict
, self
,
525 return call_decorator
528 def api_get(path
, resp_structure
=None):
529 return RestClient
.api(
530 path
, method
='get', resp_structure
=resp_structure
)
533 def api_post(path
, resp_structure
=None):
534 return RestClient
.api(
535 path
, method
='post', resp_structure
=resp_structure
)
538 def api_put(path
, resp_structure
=None):
539 return RestClient
.api(
540 path
, method
='put', resp_structure
=resp_structure
)
543 def api_delete(path
, resp_structure
=None):
544 return RestClient
.api(
545 path
, method
='delete', resp_structure
=resp_structure
)