blob: 7d2eda02eca16585fb48040bf5722a1ad2551563 [file] [log] [blame]
Matthew Treinish9e26ca82016-02-23 11:43:20 -05001# Copyright 2012 OpenStack Foundation
2# Copyright 2013 IBM Corp.
3# All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may
6# not use this file except in compliance with the License. You may obtain
7# a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14# License for the specific language governing permissions and limitations
15# under the License.
16
17import collections
18import logging as real_logging
19import re
20import time
21
22import jsonschema
23from oslo_log import log as logging
24from oslo_serialization import jsonutils as json
25import six
26
27from tempest.lib.common import http
28from tempest.lib.common.utils import misc as misc_utils
29from tempest.lib import exceptions
30
31# redrive rate limited calls at most twice
32MAX_RECURSION_DEPTH = 2
33
34# All the successful HTTP status codes from RFC 7231 & 4918
35HTTP_SUCCESS = (200, 201, 202, 203, 204, 205, 206, 207)
36
37# All the redirection HTTP status codes from RFC 7231 & 4918
38HTTP_REDIRECTION = (300, 301, 302, 303, 304, 305, 306, 307)
39
40# JSON Schema validator and format checker used for JSON Schema validation
41JSONSCHEMA_VALIDATOR = jsonschema.Draft4Validator
42FORMAT_CHECKER = jsonschema.draft4_format_checker
43
44
45class RestClient(object):
46 """Unified OpenStack RestClient class
47
48 This class is used for building openstack api clients on top of. It is
49 intended to provide a base layer for wrapping outgoing http requests in
50 keystone auth as well as providing response code checking and error
51 handling.
52
53 :param auth_provider: an auth provider object used to wrap requests in auth
54 :param str service: The service name to use for the catalog lookup
55 :param str region: The region to use for the catalog lookup
56 :param str endpoint_type: The endpoint type to use for the catalog lookup
57 :param int build_interval: Time in seconds between to status checks in
58 wait loops
59 :param int build_timeout: Timeout in seconds to wait for a wait operation.
60 :param bool disable_ssl_certificate_validation: Set to true to disable ssl
61 certificate validation
62 :param str ca_certs: File containing the CA Bundle to use in verifying a
63 TLS server cert
64 :param str trace_request: Regex to use for specifying logging the entirety
65 of the request and response payload
66 """
67 TYPE = "json"
68
69 # The version of the API this client implements
70 api_version = None
71
72 LOG = logging.getLogger(__name__)
73
74 def __init__(self, auth_provider, service, region,
75 endpoint_type='publicURL',
76 build_interval=1, build_timeout=60,
77 disable_ssl_certificate_validation=False, ca_certs=None,
78 trace_requests=''):
79 self.auth_provider = auth_provider
80 self.service = service
81 self.region = region
82 self.endpoint_type = endpoint_type
83 self.build_interval = build_interval
84 self.build_timeout = build_timeout
85 self.trace_requests = trace_requests
86
87 self._skip_path = False
88 self.general_header_lc = set(('cache-control', 'connection',
89 'date', 'pragma', 'trailer',
90 'transfer-encoding', 'via',
91 'warning'))
92 self.response_header_lc = set(('accept-ranges', 'age', 'etag',
93 'location', 'proxy-authenticate',
94 'retry-after', 'server',
95 'vary', 'www-authenticate'))
96 dscv = disable_ssl_certificate_validation
97 self.http_obj = http.ClosingHttp(
98 disable_ssl_certificate_validation=dscv, ca_certs=ca_certs)
99
100 def _get_type(self):
101 return self.TYPE
102
103 def get_headers(self, accept_type=None, send_type=None):
104 """Return the default headers which will be used with outgoing requests
105
106 :param str accept_type: The media type to use for the Accept header, if
107 one isn't provided the object var TYPE will be
108 used
109 :param str send_type: The media-type to use for the Content-Type
110 header, if one isn't provided the object var
111 TYPE will be used
112 :rtype: dict
113 :return: The dictionary of headers which can be used in the headers
114 dict for outgoing request
115 """
116 if accept_type is None:
117 accept_type = self._get_type()
118 if send_type is None:
119 send_type = self._get_type()
120 return {'Content-Type': 'application/%s' % send_type,
121 'Accept': 'application/%s' % accept_type}
122
123 def __str__(self):
124 STRING_LIMIT = 80
125 str_format = ("service:%s, base_url:%s, "
126 "filters: %s, build_interval:%s, build_timeout:%s"
127 "\ntoken:%s..., \nheaders:%s...")
128 return str_format % (self.service, self.base_url,
129 self.filters, self.build_interval,
130 self.build_timeout,
131 str(self.token)[0:STRING_LIMIT],
132 str(self.get_headers())[0:STRING_LIMIT])
133
134 @property
135 def user(self):
136 """The username used for requests
137
138 :rtype: string
139 :return: The username being used for requests
140 """
141
142 return self.auth_provider.credentials.username
143
144 @property
145 def user_id(self):
146 """The user_id used for requests
147
148 :rtype: string
149 :return: The user id being used for requests
150 """
151 return self.auth_provider.credentials.user_id
152
153 @property
154 def tenant_name(self):
155 """The tenant/project being used for requests
156
157 :rtype: string
158 :return: The tenant/project name being used for requests
159 """
160 return self.auth_provider.credentials.tenant_name
161
162 @property
163 def tenant_id(self):
164 """The tenant/project id being used for requests
165
166 :rtype: string
167 :return: The tenant/project id being used for requests
168 """
169 return self.auth_provider.credentials.tenant_id
170
171 @property
172 def password(self):
173 """The password being used for requests
174
175 :rtype: string
176 :return: The password being used for requests
177 """
178 return self.auth_provider.credentials.password
179
180 @property
181 def base_url(self):
182 return self.auth_provider.base_url(filters=self.filters)
183
184 @property
185 def token(self):
186 return self.auth_provider.get_token()
187
188 @property
189 def filters(self):
190 _filters = dict(
191 service=self.service,
192 endpoint_type=self.endpoint_type,
193 region=self.region
194 )
195 if self.api_version is not None:
196 _filters['api_version'] = self.api_version
197 if self._skip_path:
198 _filters['skip_path'] = self._skip_path
199 return _filters
200
201 def skip_path(self):
202 """When set, ignore the path part of the base URL from the catalog"""
203 self._skip_path = True
204
205 def reset_path(self):
206 """When reset, use the base URL from the catalog as-is"""
207 self._skip_path = False
208
209 @classmethod
210 def expected_success(cls, expected_code, read_code):
211 """Check expected success response code against the http response
212
213 :param int expected_code: The response code that is expected.
214 Optionally a list of integers can be used
215 to specify multiple valid success codes
216 :param int read_code: The response code which was returned in the
217 response
218 :raises AssertionError: if the expected_code isn't a valid http success
219 response code
220 :raises exceptions.InvalidHttpSuccessCode: if the read code isn't an
221 expected http success code
222 """
223 assert_msg = ("This function only allowed to use for HTTP status"
224 "codes which explicitly defined in the RFC 7231 & 4918."
225 "{0} is not a defined Success Code!"
226 ).format(expected_code)
227 if isinstance(expected_code, list):
228 for code in expected_code:
229 assert code in HTTP_SUCCESS + HTTP_REDIRECTION, assert_msg
230 else:
231 assert expected_code in HTTP_SUCCESS + HTTP_REDIRECTION, assert_msg
232
233 # NOTE(afazekas): the http status code above 400 is processed by
234 # the _error_checker method
235 if read_code < 400:
236 pattern = """Unexpected http success status code {0},
237 The expected status code is {1}"""
238 if ((not isinstance(expected_code, list) and
239 (read_code != expected_code)) or
240 (isinstance(expected_code, list) and
241 (read_code not in expected_code))):
242 details = pattern.format(read_code, expected_code)
243 raise exceptions.InvalidHttpSuccessCode(details)
244
245 def post(self, url, body, headers=None, extra_headers=False):
246 """Send a HTTP POST request using keystone auth
247
248 :param str url: the relative url to send the post request to
249 :param dict body: the request body
250 :param dict headers: The headers to use for the request
251 :param dict extra_headers: If the headers returned by the get_headers()
252 method are to be used but additional headers
253 are needed in the request pass them in as a
254 dict
255 :return: a tuple with the first entry containing the response headers
256 and the second the response body
257 :rtype: tuple
258 """
259 return self.request('POST', url, extra_headers, headers, body)
260
261 def get(self, url, headers=None, extra_headers=False):
262 """Send a HTTP GET request using keystone service catalog and auth
263
264 :param str url: the relative url to send the post request to
265 :param dict headers: The headers to use for the request
266 :param dict extra_headers: If the headers returned by the get_headers()
267 method are to be used but additional headers
268 are needed in the request pass them in as a
269 dict
270 :return: a tuple with the first entry containing the response headers
271 and the second the response body
272 :rtype: tuple
273 """
274 return self.request('GET', url, extra_headers, headers)
275
276 def delete(self, url, headers=None, body=None, extra_headers=False):
277 """Send a HTTP DELETE request using keystone service catalog and auth
278
279 :param str url: the relative url to send the post request to
280 :param dict headers: The headers to use for the request
281 :param dict body: the request body
282 :param dict extra_headers: If the headers returned by the get_headers()
283 method are to be used but additional headers
284 are needed in the request pass them in as a
285 dict
286 :return: a tuple with the first entry containing the response headers
287 and the second the response body
288 :rtype: tuple
289 """
290 return self.request('DELETE', url, extra_headers, headers, body)
291
292 def patch(self, url, body, headers=None, extra_headers=False):
293 """Send a HTTP PATCH request using keystone service catalog and auth
294
295 :param str url: the relative url to send the post request to
296 :param dict body: the request body
297 :param dict headers: The headers to use for the request
298 :param dict extra_headers: If the headers returned by the get_headers()
299 method are to be used but additional headers
300 are needed in the request pass them in as a
301 dict
302 :return: a tuple with the first entry containing the response headers
303 and the second the response body
304 :rtype: tuple
305 """
306 return self.request('PATCH', url, extra_headers, headers, body)
307
308 def put(self, url, body, headers=None, extra_headers=False):
309 """Send a HTTP PUT request using keystone service catalog and auth
310
311 :param str url: the relative url to send the post request to
312 :param dict body: the request body
313 :param dict headers: The headers to use for the request
314 :param dict extra_headers: If the headers returned by the get_headers()
315 method are to be used but additional headers
316 are needed in the request pass them in as a
317 dict
318 :return: a tuple with the first entry containing the response headers
319 and the second the response body
320 :rtype: tuple
321 """
322 return self.request('PUT', url, extra_headers, headers, body)
323
324 def head(self, url, headers=None, extra_headers=False):
325 """Send a HTTP HEAD request using keystone service catalog and auth
326
327 :param str url: the relative url to send the post request to
328 :param dict headers: The headers to use for the request
329 :param dict extra_headers: If the headers returned by the get_headers()
330 method are to be used but additional headers
331 are needed in the request pass them in as a
332 dict
333 :return: a tuple with the first entry containing the response headers
334 and the second the response body
335 :rtype: tuple
336 """
337 return self.request('HEAD', url, extra_headers, headers)
338
339 def copy(self, url, headers=None, extra_headers=False):
340 """Send a HTTP COPY request using keystone service catalog and auth
341
342 :param str url: the relative url to send the post request to
343 :param dict headers: The headers to use for the request
344 :param dict extra_headers: If the headers returned by the get_headers()
345 method are to be used but additional headers
346 are needed in the request pass them in as a
347 dict
348 :return: a tuple with the first entry containing the response headers
349 and the second the response body
350 :rtype: tuple
351 """
352 return self.request('COPY', url, extra_headers, headers)
353
354 def get_versions(self):
355 """Get the versions on a endpoint from the keystone catalog
356
357 This method will make a GET request on the baseurl from the keystone
358 catalog to return a list of API versions. It is expected that a GET
359 on the endpoint in the catalog will return a list of supported API
360 versions.
361
362 :return tuple with response headers and list of version numbers
363 :rtype: tuple
364 """
365 resp, body = self.get('')
366 body = self._parse_resp(body)
367 versions = map(lambda x: x['id'], body)
368 return resp, versions
369
370 def _get_request_id(self, resp):
371 for i in ('x-openstack-request-id', 'x-compute-request-id'):
372 if i in resp:
373 return resp[i]
374 return ""
375
376 def _safe_body(self, body, maxlen=4096):
377 # convert a structure into a string safely
378 try:
379 text = six.text_type(body)
380 except UnicodeDecodeError:
381 # if this isn't actually text, return marker that
382 return "<BinaryData: removed>"
383 if len(text) > maxlen:
384 return text[:maxlen]
385 else:
386 return text
387
388 def _log_request_start(self, method, req_url, req_headers=None,
389 req_body=None):
390 if req_headers is None:
391 req_headers = {}
392 caller_name = misc_utils.find_test_caller()
393 if self.trace_requests and re.search(self.trace_requests, caller_name):
394 self.LOG.debug('Starting Request (%s): %s %s' %
395 (caller_name, method, req_url))
396
397 def _log_request_full(self, method, req_url, resp,
398 secs="", req_headers=None,
399 req_body=None, resp_body=None,
400 caller_name=None, extra=None):
401 if 'X-Auth-Token' in req_headers:
402 req_headers['X-Auth-Token'] = '<omitted>'
403 log_fmt = """Request - Headers: %s
404 Body: %s
405 Response - Headers: %s
406 Body: %s"""
407
408 self.LOG.debug(
409 log_fmt % (
410 str(req_headers),
411 self._safe_body(req_body),
412 str(resp),
413 self._safe_body(resp_body)),
414 extra=extra)
415
416 def _log_request(self, method, req_url, resp,
417 secs="", req_headers=None,
418 req_body=None, resp_body=None):
419 if req_headers is None:
420 req_headers = {}
421 # if we have the request id, put it in the right part of the log
422 extra = dict(request_id=self._get_request_id(resp))
423 # NOTE(sdague): while we still have 6 callers to this function
424 # we're going to just provide work around on who is actually
425 # providing timings by gracefully adding no content if they don't.
426 # Once we're down to 1 caller, clean this up.
427 caller_name = misc_utils.find_test_caller()
428 if secs:
429 secs = " %.3fs" % secs
430 self.LOG.info(
431 'Request (%s): %s %s %s%s' % (
432 caller_name,
433 resp['status'],
434 method,
435 req_url,
436 secs),
437 extra=extra)
438
439 # Also look everything at DEBUG if you want to filter this
440 # out, don't run at debug.
441 if self.LOG.isEnabledFor(real_logging.DEBUG):
442 self._log_request_full(method, req_url, resp, secs, req_headers,
443 req_body, resp_body, caller_name, extra)
444
445 def _parse_resp(self, body):
446 try:
447 body = json.loads(body)
448 except ValueError:
449 return body
450
451 # We assume, that if the first value of the deserialized body's
452 # item set is a dict or a list, that we just return the first value
453 # of deserialized body.
454 # Essentially "cutting out" the first placeholder element in a body
455 # that looks like this:
456 #
457 # {
458 # "users": [
459 # ...
460 # ]
461 # }
462 try:
463 # Ensure there are not more than one top-level keys
464 # NOTE(freerunner): Ensure, that JSON is not nullable to
465 # to prevent StopIteration Exception
466 if len(body.keys()) != 1:
467 return body
468 # Just return the "wrapped" element
469 first_key, first_item = six.next(six.iteritems(body))
470 if isinstance(first_item, (dict, list)):
471 return first_item
472 except (ValueError, IndexError):
473 pass
474 return body
475
476 def response_checker(self, method, resp, resp_body):
477 """A sanity check on the response from a HTTP request
478
479 This method does a sanity check on whether the response from an HTTP
480 request conforms the HTTP RFC.
481
482 :param str method: The HTTP verb of the request associated with the
483 response being passed in.
484 :param resp: The response headers
485 :param resp_body: The body of the response
486 :raises ResponseWithNonEmptyBody: If the response with the status code
487 is not supposed to have a body
488 :raises ResponseWithEntity: If the response code is 205 but has an
489 entity
490 """
491 if (resp.status in set((204, 205, 304)) or resp.status < 200 or
492 method.upper() == 'HEAD') and resp_body:
493 raise exceptions.ResponseWithNonEmptyBody(status=resp.status)
494 # NOTE(afazekas):
495 # If the HTTP Status Code is 205
496 # 'The response MUST NOT include an entity.'
497 # A HTTP entity has an entity-body and an 'entity-header'.
498 # In the HTTP response specification (Section 6) the 'entity-header'
499 # 'generic-header' and 'response-header' are in OR relation.
500 # All headers not in the above two group are considered as entity
501 # header in every interpretation.
502
503 if (resp.status == 205 and
504 0 != len(set(resp.keys()) - set(('status',)) -
505 self.response_header_lc - self.general_header_lc)):
506 raise exceptions.ResponseWithEntity()
507 # NOTE(afazekas)
508 # Now the swift sometimes (delete not empty container)
509 # returns with non json error response, we can create new rest class
510 # for swift.
511 # Usually RFC2616 says error responses SHOULD contain an explanation.
512 # The warning is normal for SHOULD/SHOULD NOT case
513
514 # Likely it will cause an error
515 if method != 'HEAD' and not resp_body and resp.status >= 400:
516 self.LOG.warning("status >= 400 response with empty body")
517
518 def _request(self, method, url, headers=None, body=None):
519 """A simple HTTP request interface."""
520 # Authenticate the request with the auth provider
521 req_url, req_headers, req_body = self.auth_provider.auth_request(
522 method, url, headers, body, self.filters)
523
524 # Do the actual request, and time it
525 start = time.time()
526 self._log_request_start(method, req_url)
527 resp, resp_body = self.raw_request(
528 req_url, method, headers=req_headers, body=req_body)
529 end = time.time()
530 self._log_request(method, req_url, resp, secs=(end - start),
531 req_headers=req_headers, req_body=req_body,
532 resp_body=resp_body)
533
534 # Verify HTTP response codes
535 self.response_checker(method, resp, resp_body)
536
537 return resp, resp_body
538
539 def raw_request(self, url, method, headers=None, body=None):
540 """Send a raw HTTP request without the keystone catalog or auth
541
542 This method sends a HTTP request in the same manner as the request()
543 method, however it does so without using keystone auth or the catalog
544 to determine the base url. Additionally no response handling is done
545 the results from the request are just returned.
546
547 :param str url: Full url to send the request
548 :param str method: The HTTP verb to use for the request
549 :param str headers: Headers to use for the request if none are specifed
550 the headers
Anh Trand44a8be2016-03-25 09:49:14 +0700551 :param str body: Body to send with the request
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500552 :rtype: tuple
553 :return: a tuple with the first entry containing the response headers
554 and the second the response body
555 """
556 if headers is None:
557 headers = self.get_headers()
558 return self.http_obj.request(url, method,
559 headers=headers, body=body)
560
561 def request(self, method, url, extra_headers=False, headers=None,
562 body=None):
563 """Send a HTTP request with keystone auth and using the catalog
564
565 This method will send an HTTP request using keystone auth in the
566 headers and the catalog to determine the endpoint to use for the
567 baseurl to send the request to. Additionally
568
569 When a response is received it will check it to see if an error
570 response was received. If it was an exception will be raised to enable
571 it to be handled quickly.
572
573 This method will also handle rate-limiting, if a 413 response code is
574 received it will retry the request after waiting the 'retry-after'
575 duration from the header.
576
577 :param str method: The HTTP verb to use for the request
578 :param str url: Relative url to send the request to
579 :param dict extra_headers: If specified without the headers kwarg the
580 headers sent with the request will be the
581 combination from the get_headers() method
582 and this kwarg
583 :param dict headers: Headers to use for the request if none are
584 specifed the headers returned from the
585 get_headers() method are used. If the request
586 explicitly requires no headers use an empty dict.
Anh Trand44a8be2016-03-25 09:49:14 +0700587 :param str body: Body to send with the request
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500588 :rtype: tuple
589 :return: a tuple with the first entry containing the response headers
590 and the second the response body
591 :raises UnexpectedContentType: If the content-type of the response
592 isn't an expect type
593 :raises Unauthorized: If a 401 response code is received
594 :raises Forbidden: If a 403 response code is received
595 :raises NotFound: If a 404 response code is received
596 :raises BadRequest: If a 400 response code is received
597 :raises Gone: If a 410 response code is received
598 :raises Conflict: If a 409 response code is received
599 :raises OverLimit: If a 413 response code is received and over_limit is
600 not in the response body
601 :raises RateLimitExceeded: If a 413 response code is received and
602 over_limit is in the response body
603 :raises InvalidContentType: If a 415 response code is received
604 :raises UnprocessableEntity: If a 422 response code is received
605 :raises InvalidHTTPResponseBody: The response body wasn't valid JSON
606 and couldn't be parsed
607 :raises NotImplemented: If a 501 response code is received
608 :raises ServerFault: If a 500 response code is received
609 :raises UnexpectedResponseCode: If a response code above 400 is
610 received and it doesn't fall into any
611 of the handled checks
612 """
613 # if extra_headers is True
614 # default headers would be added to headers
615 retry = 0
616
617 if headers is None:
618 # NOTE(vponomaryov): if some client do not need headers,
619 # it should explicitly pass empty dict
620 headers = self.get_headers()
621 elif extra_headers:
622 try:
623 headers = headers.copy()
624 headers.update(self.get_headers())
625 except (ValueError, TypeError):
626 headers = self.get_headers()
627
628 resp, resp_body = self._request(method, url,
629 headers=headers, body=body)
630
631 while (resp.status == 413 and
632 'retry-after' in resp and
633 not self.is_absolute_limit(
634 resp, self._parse_resp(resp_body)) and
635 retry < MAX_RECURSION_DEPTH):
636 retry += 1
637 delay = int(resp['retry-after'])
638 time.sleep(delay)
639 resp, resp_body = self._request(method, url,
640 headers=headers, body=body)
641 self._error_checker(method, url, headers, body,
642 resp, resp_body)
643 return resp, resp_body
644
645 def _error_checker(self, method, url,
646 headers, body, resp, resp_body):
647
648 # NOTE(mtreinish): Check for httplib response from glance_http. The
649 # object can't be used here because importing httplib breaks httplib2.
650 # If another object from a class not imported were passed here as
651 # resp this could possibly fail
652 if str(type(resp)) == "<type 'instance'>":
653 ctype = resp.getheader('content-type')
654 else:
655 try:
656 ctype = resp['content-type']
657 # NOTE(mtreinish): Keystone delete user responses doesn't have a
658 # content-type header. (They don't have a body) So just pretend it
659 # is set.
660 except KeyError:
661 ctype = 'application/json'
662
663 # It is not an error response
664 if resp.status < 400:
665 return
666
667 JSON_ENC = ['application/json', 'application/json; charset=utf-8']
668 # NOTE(mtreinish): This is for compatibility with Glance and swift
669 # APIs. These are the return content types that Glance api v1
670 # (and occasionally swift) are using.
671 TXT_ENC = ['text/plain', 'text/html', 'text/html; charset=utf-8',
672 'text/plain; charset=utf-8']
673
674 if ctype.lower() in JSON_ENC:
675 parse_resp = True
676 elif ctype.lower() in TXT_ENC:
677 parse_resp = False
678 else:
679 raise exceptions.UnexpectedContentType(str(resp.status),
680 resp=resp)
681
682 if resp.status == 401:
683 if parse_resp:
684 resp_body = self._parse_resp(resp_body)
685 raise exceptions.Unauthorized(resp_body, resp=resp)
686
687 if resp.status == 403:
688 if parse_resp:
689 resp_body = self._parse_resp(resp_body)
690 raise exceptions.Forbidden(resp_body, resp=resp)
691
692 if resp.status == 404:
693 if parse_resp:
694 resp_body = self._parse_resp(resp_body)
695 raise exceptions.NotFound(resp_body, resp=resp)
696
697 if resp.status == 400:
698 if parse_resp:
699 resp_body = self._parse_resp(resp_body)
700 raise exceptions.BadRequest(resp_body, resp=resp)
701
702 if resp.status == 410:
703 if parse_resp:
704 resp_body = self._parse_resp(resp_body)
705 raise exceptions.Gone(resp_body, resp=resp)
706
707 if resp.status == 409:
708 if parse_resp:
709 resp_body = self._parse_resp(resp_body)
710 raise exceptions.Conflict(resp_body, resp=resp)
711
712 if resp.status == 413:
713 if parse_resp:
714 resp_body = self._parse_resp(resp_body)
715 if self.is_absolute_limit(resp, resp_body):
716 raise exceptions.OverLimit(resp_body, resp=resp)
717 else:
718 raise exceptions.RateLimitExceeded(resp_body, resp=resp)
719
720 if resp.status == 415:
721 if parse_resp:
722 resp_body = self._parse_resp(resp_body)
723 raise exceptions.InvalidContentType(resp_body, resp=resp)
724
725 if resp.status == 422:
726 if parse_resp:
727 resp_body = self._parse_resp(resp_body)
728 raise exceptions.UnprocessableEntity(resp_body, resp=resp)
729
730 if resp.status in (500, 501):
731 message = resp_body
732 if parse_resp:
733 try:
734 resp_body = self._parse_resp(resp_body)
735 except ValueError:
736 # If response body is a non-json string message.
737 # Use resp_body as is and raise InvalidResponseBody
738 # exception.
739 raise exceptions.InvalidHTTPResponseBody(message)
740 else:
741 if isinstance(resp_body, dict):
742 # I'm seeing both computeFault
743 # and cloudServersFault come back.
744 # Will file a bug to fix, but leave as is for now.
745 if 'cloudServersFault' in resp_body:
746 message = resp_body['cloudServersFault']['message']
747 elif 'computeFault' in resp_body:
748 message = resp_body['computeFault']['message']
749 elif 'error' in resp_body:
750 message = resp_body['error']['message']
751 elif 'message' in resp_body:
752 message = resp_body['message']
753 else:
754 message = resp_body
755
756 if resp.status == 501:
757 raise exceptions.NotImplemented(resp_body, resp=resp,
758 message=message)
759 else:
760 raise exceptions.ServerFault(resp_body, resp=resp,
761 message=message)
762
763 if resp.status >= 400:
764 raise exceptions.UnexpectedResponseCode(str(resp.status),
765 resp=resp)
766
767 def is_absolute_limit(self, resp, resp_body):
768 if (not isinstance(resp_body, collections.Mapping) or
769 'retry-after' not in resp):
770 return True
771 over_limit = resp_body.get('overLimit', None)
772 if not over_limit:
773 return True
774 return 'exceed' in over_limit.get('message', 'blabla')
775
776 def wait_for_resource_deletion(self, id):
777 """Waits for a resource to be deleted
778
779 This method will loop over is_resource_deleted until either
780 is_resource_deleted returns True or the build timeout is reached. This
781 depends on is_resource_deleted being implemented
782
783 :param str id: The id of the resource to check
784 :raises TimeoutException: If the build_timeout has elapsed and the
785 resource still hasn't been deleted
786 """
787 start_time = int(time.time())
788 while True:
789 if self.is_resource_deleted(id):
790 return
791 if int(time.time()) - start_time >= self.build_timeout:
792 message = ('Failed to delete %(resource_type)s %(id)s within '
793 'the required time (%(timeout)s s).' %
794 {'resource_type': self.resource_type, 'id': id,
795 'timeout': self.build_timeout})
796 caller = misc_utils.find_test_caller()
797 if caller:
798 message = '(%s) %s' % (caller, message)
799 raise exceptions.TimeoutException(message)
800 time.sleep(self.build_interval)
801
802 def is_resource_deleted(self, id):
803 """Subclasses override with specific deletion detection."""
804 message = ('"%s" does not implement is_resource_deleted'
805 % self.__class__.__name__)
806 raise NotImplementedError(message)
807
808 @property
809 def resource_type(self):
810 """Returns the primary type of resource this client works with."""
811 return 'resource'
812
813 @classmethod
814 def validate_response(cls, schema, resp, body):
815 # Only check the response if the status code is a success code
816 # TODO(cyeoh): Eventually we should be able to verify that a failure
817 # code if it exists is something that we expect. This is explicitly
818 # declared in the V3 API and so we should be able to export this in
819 # the response schema. For now we'll ignore it.
820 if resp.status in HTTP_SUCCESS + HTTP_REDIRECTION:
821 cls.expected_success(schema['status_code'], resp.status)
822
823 # Check the body of a response
824 body_schema = schema.get('response_body')
825 if body_schema:
826 try:
827 jsonschema.validate(body, body_schema,
828 cls=JSONSCHEMA_VALIDATOR,
829 format_checker=FORMAT_CHECKER)
830 except jsonschema.ValidationError as ex:
831 msg = ("HTTP response body is invalid (%s)") % ex
832 raise exceptions.InvalidHTTPResponseBody(msg)
833 else:
834 if body:
835 msg = ("HTTP response body should not exist (%s)") % body
836 raise exceptions.InvalidHTTPResponseBody(msg)
837
838 # Check the header of a response
839 header_schema = schema.get('response_header')
840 if header_schema:
841 try:
842 jsonschema.validate(resp, header_schema,
843 cls=JSONSCHEMA_VALIDATOR,
844 format_checker=FORMAT_CHECKER)
845 except jsonschema.ValidationError as ex:
846 msg = ("HTTP response header is invalid (%s)") % ex
847 raise exceptions.InvalidHTTPResponseHeader(msg)
848
849
850class ResponseBody(dict):
851 """Class that wraps an http response and dict body into a single value.
852
853 Callers that receive this object will normally use it as a dict but
854 can extract the response if needed.
855 """
856
857 def __init__(self, response, body=None):
858 body_data = body or {}
859 self.update(body_data)
860 self.response = response
861
862 def __str__(self):
863 body = super(ResponseBody, self).__str__()
864 return "response: %s\nBody: %s" % (self.response, body)
865
866
867class ResponseBodyData(object):
868 """Class that wraps an http response and string data into a single value.
869
870 """
871
872 def __init__(self, response, data):
873 self.response = response
874 self.data = data
875
876 def __str__(self):
877 return "response: %s\nBody: %s" % (self.response, self.data)
878
879
880class ResponseBodyList(list):
881 """Class that wraps an http response and list body into a single value.
882
883 Callers that receive this object will normally use it as a list but
884 can extract the response if needed.
885 """
886
887 def __init__(self, response, body=None):
888 body_data = body or []
889 self.extend(body_data)
890 self.response = response
891
892 def __str__(self):
893 body = super(ResponseBodyList, self).__str__()
894 return "response: %s\nBody: %s" % (self.response, body)