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