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
16 from .settings
import Settings
17 from .tools
import build_url
21 from requests
.exceptions
import ConnectionError
, InvalidURL
, Timeout
25 from requests
.packages
.urllib3
.exceptions
import SSLError
27 from urllib3
.exceptions
import SSLError
30 class TimeoutRequestsSession(requests
.Session
):
32 Set timeout argument for all requests if this is not already done.
34 def request(self
, *args
, **kwargs
):
35 if ((args
[8] if len(args
) > 8 else None) is None) \
36 and kwargs
.get('timeout') is None:
37 if Settings
.REST_REQUESTS_TIMEOUT
> 0:
38 kwargs
['timeout'] = Settings
.REST_REQUESTS_TIMEOUT
39 return super(TimeoutRequestsSession
, self
).request(*args
, **kwargs
)
42 class RequestException(Exception):
49 super(RequestException
, self
).__init
__(message
)
50 self
.status_code
= status_code
51 self
.content
= content
52 self
.conn_errno
= conn_errno
53 self
.conn_strerror
= conn_strerror
56 class BadResponseFormatException(RequestException
):
57 def __init__(self
, message
):
58 super(BadResponseFormatException
, self
).__init
__(
59 "Bad response format" if message
is None else message
, None)
62 class _ResponseValidator(object):
63 """Simple JSON schema validator
65 This class implements a very simple validator for the JSON formatted
66 messages received by request responses from a RestClient instance.
68 The validator validates the JSON response against a "structure" string that
69 specifies the structure that the JSON response must comply. The validation
70 procedure raises a BadResponseFormatException in case of a validation
73 The structure syntax is given by the following grammar:
76 Level ::= Path | Path '&' Level
77 Path ::= Step | Step '>'+ Path
78 Step ::= Key | '?' Key | '*' | '(' Level ')'
79 Key ::= <string> | Array+
80 Array ::= '[' <int> ']' | '[' '*' ']' | '[' '+' ']'
82 The symbols enclosed in ' ' are tokens of the language, and the + symbol
83 denotes repetition of of the preceding token at least once.
89 structure = "return > *"
90 response = { 'return': { ... } }
92 In the above example the structure will validate against any response
93 that contains a key named "return" in the root of the response
94 dictionary and its value is also a dictionary.
101 In the above example the structure will validate against any response
102 that is an array of any size.
106 structure = "return[*]"
107 response = { 'return': [....] }
109 In the above example the structure will validate against any response
110 that contains a key named "return" in the root of the response
111 dictionary and its value is an array.
115 structure = "return[0] > token"
116 response = { 'return': [ { 'token': .... } ] }
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, and the first element of the
121 array is a dictionary that contains the key 'token'.
125 structure = "return[0][*] > key1"
126 response = { 'return': [ [ { 'key1': ... } ], ...] }
128 In the above example the structure will validate against any response
129 that contains a key named "return" in the root of the response
130 dictionary where its value is an array, and the first value of this
131 array is also an array where all it's values must be a dictionary
132 containing a key named "key1".
136 structure = "return > (key1[*] & key2 & ?key3 > subkey)"
137 response = { 'return': { 'key1': [...], 'key2: .... } ] }
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 and its value is a dictionary that must contain a key named
142 "key1" that is an array, a key named "key2", and optionally a key named
143 "key3" that is a dictionary that contains a key named "subkey".
147 structure = "return >> roles[*]"
148 response = { 'return': { 'key1': { 'roles': [...] }, 'key2': { 'roles': [...] } } }
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 for any key present in
153 the dictionary their value is also a dictionary that must contain a key
154 named 'roles' that is an array. Please note that you can use any
155 number of successive '>' to denote the level in the JSON tree that you
156 want to match next step in the path.
161 def validate(structure
, response
):
162 if structure
is None:
165 _ResponseValidator
._validate
_level
(structure
, response
)
168 def _validate_level(level
, resp
):
169 if not isinstance(resp
, dict) and not isinstance(resp
, list):
170 raise BadResponseFormatException(
171 "{} is neither a dict nor a list".format(resp
))
173 paths
= _ResponseValidator
._parse
_level
_paths
(level
)
175 path_sep
= path
.find('>')
177 level_next
= path
[path_sep
+ 1:].strip()
181 key
= path
[:path_sep
].strip()
185 elif key
== '': # check all keys
186 for k
in resp
.keys():
187 _ResponseValidator
._validate
_key
(k
, level_next
, resp
)
189 _ResponseValidator
._validate
_key
(key
, level_next
, resp
)
192 def _validate_array(array_seq
, level_next
, resp
):
194 if not isinstance(resp
, list):
195 raise BadResponseFormatException(
196 "{} is not an array".format(resp
))
197 if array_seq
[0].isdigit():
198 idx
= int(array_seq
[0])
200 raise BadResponseFormatException(
201 "length of array {} is lower than the index {}".format(
203 _ResponseValidator
._validate
_array
(array_seq
[1:], level_next
,
205 elif array_seq
[0] == '*':
207 _ResponseValidator
._validate
_array
(array_seq
[1:],
209 elif array_seq
[0] == '+':
211 raise BadResponseFormatException(
212 "array should not be empty")
214 _ResponseValidator
._validate
_array
(array_seq
[1:],
218 "Response structure is invalid: only <int> | '*' are "
219 "allowed as array index arguments")
222 _ResponseValidator
._validate
_level
(level_next
, resp
)
225 def _validate_key(key
, level_next
, resp
):
226 array_access
= [a
.strip() for a
in key
.split("[")]
227 key
= array_access
[0]
229 optional
= key
[0] == '?'
235 raise BadResponseFormatException(
236 "key {} is not in dict {}".format(key
, resp
))
237 resp_next
= resp
[key
]
240 if len(array_access
) > 1:
241 _ResponseValidator
._validate
_array
(
242 [a
[0:-1] for a
in array_access
[1:]], level_next
, resp_next
)
245 _ResponseValidator
._validate
_level
(level_next
, resp_next
)
248 def _parse_level_paths(level
):
249 level
= level
.strip()
258 for i
, c
in enumerate(level
):
259 if c
== '&' and nested
== 0:
260 paths
.append(level
[lp
:i
].strip())
266 paths
.append(level
[lp
:].strip())
270 class _Request(object):
271 def __init__(self
, method
, path
, path_params
, rest_client
, resp_structure
):
274 self
.path_params
= path_params
275 self
.rest_client
= rest_client
276 self
.resp_structure
= resp_structure
280 matches
= re
.finditer(r
'\{(\w+?)\}', self
.path
)
281 for match
in matches
:
283 param_key
= match
.group(1)
284 if param_key
in self
.path_params
:
285 new_path
= new_path
.replace(
286 match
.group(0), self
.path_params
[param_key
])
288 raise RequestException(
289 'Invalid path. Param "{}" was not specified'
290 .format(param_key
), None)
299 method
= method
if method
else self
.method
301 raise Exception('No HTTP request method specified')
305 raise Exception('Ambiguous source of GET params')
309 raise Exception('Ambiguous source of {} data'.format(
312 resp
= self
.rest_client
.do_request(method
, self
._gen
_path
(), params
,
314 if raw_content
and self
.resp_structure
:
315 raise Exception("Cannot validate response in raw format")
316 _ResponseValidator
.validate(self
.resp_structure
, resp
)
320 class RestClient(object):
321 def __init__(self
, host
, port
, client_name
=None, ssl
=False, auth
=None, ssl_verify
=True):
322 super(RestClient
, self
).__init
__()
323 self
.client_name
= client_name
if client_name
else ''
326 self
.base_url
= build_url(
327 scheme
='https' if ssl
else 'http', host
=host
, port
=port
)
328 logger
.debug("REST service base URL: %s", self
.base_url
)
329 self
.headers
= {'Accept': 'application/json'}
331 self
.session
= TimeoutRequestsSession()
332 self
.session
.verify
= ssl_verify
334 def _login(self
, request
=None):
337 def _is_logged_in(self
):
340 def _reset_login(self
):
343 def is_service_online(self
, request
=None):
347 def requires_login(func
):
348 def func_wrapper(self
, *args
, **kwargs
):
352 if not self
._is
_logged
_in
():
354 resp
= func(self
, *args
, **kwargs
)
356 except RequestException
as e
:
357 if isinstance(e
, BadResponseFormatException
):
360 if e
.status_code
not in [401, 403] or retries
== 0:
372 url
= '{}{}'.format(self
.base_url
, path
)
373 logger
.debug('%s REST API %s req: %s data: %s', self
.client_name
,
374 method
.upper(), path
, data
)
376 if method
.lower() == 'get':
377 resp
= self
.session
.get(
378 url
, headers
=self
.headers
, params
=params
, auth
=self
.auth
)
379 elif method
.lower() == 'post':
380 resp
= self
.session
.post(
382 headers
=self
.headers
,
386 elif method
.lower() == 'put':
387 resp
= self
.session
.put(
389 headers
=self
.headers
,
393 elif method
.lower() == 'delete':
394 resp
= self
.session
.delete(
396 headers
=self
.headers
,
401 raise RequestException('Method "{}" not supported'.format(
402 method
.upper()), None)
404 logger
.debug("%s REST API %s res status: %s content: %s",
405 self
.client_name
, method
.upper(),
406 resp
.status_code
, resp
.text
)
410 return resp
.json() if resp
.text
else None
413 "%s REST API failed %s req while decoding JSON "
415 self
.client_name
, method
.upper(), resp
.text
)
416 raise RequestException(
417 "{} REST API failed request while decoding JSON "
418 "response: {}".format(self
.client_name
, resp
.text
),
419 resp
.status_code
, resp
.text
)
422 "%s REST API failed %s req status: %s", self
.client_name
,
423 method
.upper(), resp
.status_code
)
424 from pprint
import pprint
as pp
425 from pprint
import pformat
as pf
427 raise RequestException(
428 "{} REST API failed request with status code {}\n"
430 .format(self
.client_name
, resp
.status_code
, pf(
434 except ConnectionError
as ex
:
436 if isinstance(ex
.args
[0], SSLError
):
438 strerror
= "SSL error. Probably trying to access a non " \
440 logger
.error("%s REST API failed %s, SSL error.",
441 self
.client_name
, method
.upper())
444 match
= re
.match(r
'.*: \[Errno (-?\d+)\] (.+)',
445 ex
.args
[0].reason
.args
[0])
446 except AttributeError:
449 errno
= match
.group(1)
450 strerror
= match
.group(2)
452 "%s REST API failed %s, connection error: "
454 self
.client_name
, method
.upper(), errno
, strerror
)
459 "%s REST API failed %s, connection error.",
460 self
.client_name
, method
.upper())
464 logger
.error("%s REST API failed %s, connection error.",
465 self
.client_name
, method
.upper())
469 "{} REST API cannot be reached: {} [errno {}]. "
470 "Please check your configuration and that the API endpoint"
472 .format(self
.client_name
, strerror
, errno
))
475 "{} REST API cannot be reached. Please check "
476 "your configuration and that the API endpoint is"
478 .format(self
.client_name
))
479 raise RequestException(
480 ex_msg
, conn_errno
=errno
, conn_strerror
=strerror
)
481 except InvalidURL
as ex
:
482 logger
.exception("%s REST API failed %s: %s", self
.client_name
,
483 method
.upper(), str(ex
))
484 raise RequestException(str(ex
))
485 except Timeout
as ex
:
486 msg
= "{} REST API {} timed out after {} seconds (url={}).".format(
487 self
.client_name
, ex
.request
.method
, Settings
.REST_REQUESTS_TIMEOUT
,
489 logger
.exception(msg
)
490 raise RequestException(msg
)
493 def api(path
, **api_kwargs
):
494 def call_decorator(func
):
495 def func_wrapper(self
, *args
, **kwargs
):
496 method
= api_kwargs
.get('method', None)
497 resp_structure
= api_kwargs
.get('resp_structure', None)
498 args_name
= inspect
.getargspec(func
).args
499 args_dict
= dict(zip(args_name
[1:], args
))
500 for key
, val
in kwargs
:
505 request
=_Request(method
, path
, args_dict
, self
,
511 return call_decorator
514 def api_get(path
, resp_structure
=None):
515 return RestClient
.api(
516 path
, method
='get', resp_structure
=resp_structure
)
519 def api_post(path
, resp_structure
=None):
520 return RestClient
.api(
521 path
, method
='post', resp_structure
=resp_structure
)
524 def api_put(path
, resp_structure
=None):
525 return RestClient
.api(
526 path
, method
='put', resp_structure
=resp_structure
)
529 def api_delete(path
, resp_structure
=None):
530 return RestClient
.api(
531 path
, method
='delete', resp_structure
=resp_structure
)