blob: fc86914f662a56137f39a605057a97e96aef7a8c [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
Takashi Kajinami6354f612021-07-17 00:37:34 +090017from collections import abc
Paul Glass119565a2016-04-06 11:41:42 -050018import email.utils
Matthew Treinish9e26ca82016-02-23 11:43:20 -050019import re
20import time
songwenping99d6e002021-01-05 03:07:46 +000021import urllib
Matthew Treinish9e26ca82016-02-23 11:43:20 -050022
23import jsonschema
24from oslo_log import log as logging
Rodolfo Alonso Hernandezc1449d42020-02-15 13:24:28 +000025from oslo_log import versionutils
Matthew Treinish9e26ca82016-02-23 11:43:20 -050026from oslo_serialization import jsonutils as json
Matthew Treinish9e26ca82016-02-23 11:43:20 -050027
28from tempest.lib.common import http
ghanshyamf9ded352016-04-12 17:03:01 +090029from tempest.lib.common import jsonschema_validator
Ilya Shakhat1291bb42017-11-29 18:08:16 +010030from tempest.lib.common import profiler
Jordan Pittier9e227c52016-02-09 14:35:18 +010031from tempest.lib.common.utils import test_utils
Matthew Treinish9e26ca82016-02-23 11:43:20 -050032from tempest.lib import exceptions
33
34# redrive rate limited calls at most twice
35MAX_RECURSION_DEPTH = 2
36
37# All the successful HTTP status codes from RFC 7231 & 4918
38HTTP_SUCCESS = (200, 201, 202, 203, 204, 205, 206, 207)
39
40# All the redirection HTTP status codes from RFC 7231 & 4918
41HTTP_REDIRECTION = (300, 301, 302, 303, 304, 305, 306, 307)
42
43# JSON Schema validator and format checker used for JSON Schema validation
ghanshyamf9ded352016-04-12 17:03:01 +090044JSONSCHEMA_VALIDATOR = jsonschema_validator.JSONSCHEMA_VALIDATOR
45FORMAT_CHECKER = jsonschema_validator.FORMAT_CHECKER
Matthew Treinish9e26ca82016-02-23 11:43:20 -050046
47
48class RestClient(object):
49 """Unified OpenStack RestClient class
50
51 This class is used for building openstack api clients on top of. It is
52 intended to provide a base layer for wrapping outgoing http requests in
53 keystone auth as well as providing response code checking and error
54 handling.
55
56 :param auth_provider: an auth provider object used to wrap requests in auth
57 :param str service: The service name to use for the catalog lookup
58 :param str region: The region to use for the catalog lookup
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -060059 :param str name: The endpoint name to use for the catalog lookup; this
60 returns only if the service exists
Matthew Treinish9e26ca82016-02-23 11:43:20 -050061 :param str endpoint_type: The endpoint type to use for the catalog lookup
62 :param int build_interval: Time in seconds between to status checks in
63 wait loops
64 :param int build_timeout: Timeout in seconds to wait for a wait operation.
65 :param bool disable_ssl_certificate_validation: Set to true to disable ssl
66 certificate validation
67 :param str ca_certs: File containing the CA Bundle to use in verifying a
68 TLS server cert
guo yunxian6f24cc42016-07-29 20:03:41 +080069 :param str trace_requests: Regex to use for specifying logging the entirety
Matthew Treinish9e26ca82016-02-23 11:43:20 -050070 of the request and response payload
zhufl071e94c2016-07-12 10:26:34 +080071 :param str http_timeout: Timeout in seconds to wait for the http request to
72 return
Matthew Treinish74514402016-09-01 11:44:57 -040073 :param str proxy_url: http proxy url to use.
Jens Harbott3ffa54e2018-07-04 11:59:49 +000074 :param bool follow_redirects: Set to false to stop following redirects.
Matthew Treinish9e26ca82016-02-23 11:43:20 -050075 """
Matthew Treinish9e26ca82016-02-23 11:43:20 -050076
77 # The version of the API this client implements
78 api_version = None
79
80 LOG = logging.getLogger(__name__)
81
82 def __init__(self, auth_provider, service, region,
83 endpoint_type='publicURL',
84 build_interval=1, build_timeout=60,
85 disable_ssl_certificate_validation=False, ca_certs=None,
Matthew Treinish74514402016-09-01 11:44:57 -040086 trace_requests='', name=None, http_timeout=None,
Jens Harbott3ffa54e2018-07-04 11:59:49 +000087 proxy_url=None, follow_redirects=True):
Matthew Treinish9e26ca82016-02-23 11:43:20 -050088 self.auth_provider = auth_provider
89 self.service = service
90 self.region = region
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -060091 self.name = name
Matthew Treinish9e26ca82016-02-23 11:43:20 -050092 self.endpoint_type = endpoint_type
93 self.build_interval = build_interval
94 self.build_timeout = build_timeout
95 self.trace_requests = trace_requests
96
97 self._skip_path = False
98 self.general_header_lc = set(('cache-control', 'connection',
99 'date', 'pragma', 'trailer',
100 'transfer-encoding', 'via',
101 'warning'))
102 self.response_header_lc = set(('accept-ranges', 'age', 'etag',
103 'location', 'proxy-authenticate',
104 'retry-after', 'server',
105 'vary', 'www-authenticate'))
zhufl8464cef2020-12-11 10:51:25 +0800106 self.dscv = disable_ssl_certificate_validation
Matthew Treinish74514402016-09-01 11:44:57 -0400107
108 if proxy_url:
109 self.http_obj = http.ClosingProxyHttp(
110 proxy_url,
zhufl8464cef2020-12-11 10:51:25 +0800111 disable_ssl_certificate_validation=self.dscv,
112 ca_certs=ca_certs,
Jens Harbott3ffa54e2018-07-04 11:59:49 +0000113 timeout=http_timeout, follow_redirects=follow_redirects)
Matthew Treinish74514402016-09-01 11:44:57 -0400114 else:
115 self.http_obj = http.ClosingHttp(
zhufl8464cef2020-12-11 10:51:25 +0800116 disable_ssl_certificate_validation=self.dscv,
117 ca_certs=ca_certs,
Jens Harbott3ffa54e2018-07-04 11:59:49 +0000118 timeout=http_timeout, follow_redirects=follow_redirects)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500119
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500120 def get_headers(self, accept_type=None, send_type=None):
121 """Return the default headers which will be used with outgoing requests
122
123 :param str accept_type: The media type to use for the Accept header, if
124 one isn't provided the object var TYPE will be
125 used
126 :param str send_type: The media-type to use for the Content-Type
127 header, if one isn't provided the object var
128 TYPE will be used
129 :rtype: dict
130 :return: The dictionary of headers which can be used in the headers
131 dict for outgoing request
132 """
133 if accept_type is None:
Masayuki Igawa189b92f2017-04-24 18:57:17 +0900134 accept_type = 'json'
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500135 if send_type is None:
Masayuki Igawa189b92f2017-04-24 18:57:17 +0900136 send_type = 'json'
Ilya Shakhat1291bb42017-11-29 18:08:16 +0100137 headers = {'Content-Type': 'application/%s' % send_type,
138 'Accept': 'application/%s' % accept_type}
139 headers.update(profiler.serialize_as_http_headers())
140 return headers
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500141
142 def __str__(self):
143 STRING_LIMIT = 80
144 str_format = ("service:%s, base_url:%s, "
145 "filters: %s, build_interval:%s, build_timeout:%s"
146 "\ntoken:%s..., \nheaders:%s...")
147 return str_format % (self.service, self.base_url,
148 self.filters, self.build_interval,
149 self.build_timeout,
150 str(self.token)[0:STRING_LIMIT],
151 str(self.get_headers())[0:STRING_LIMIT])
152
153 @property
154 def user(self):
155 """The username used for requests
156
157 :rtype: string
158 :return: The username being used for requests
159 """
160
161 return self.auth_provider.credentials.username
162
163 @property
164 def user_id(self):
165 """The user_id used for requests
166
167 :rtype: string
168 :return: The user id being used for requests
169 """
170 return self.auth_provider.credentials.user_id
171
172 @property
173 def tenant_name(self):
174 """The tenant/project being used for requests
175
176 :rtype: string
177 :return: The tenant/project name being used for requests
178 """
179 return self.auth_provider.credentials.tenant_name
180
181 @property
Rodolfo Alonso Hernandezc1449d42020-02-15 13:24:28 +0000182 def project_id(self):
183 """The project id being used for requests
184
185 :rtype: string
186 :return: The project id being used for requests
187 """
188 return self.auth_provider.credentials.tenant_id
189
190 @property
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500191 def tenant_id(self):
192 """The tenant/project id being used for requests
193
194 :rtype: string
195 :return: The tenant/project id being used for requests
196 """
Rodolfo Alonso Hernandezc1449d42020-02-15 13:24:28 +0000197 # NOTE(ralonsoh): this property should be deprecated, reference
198 # blueprint adopt-oslo-versioned-objects-for-db.
199 versionutils.report_deprecated_feature(
200 self.LOG, '"tenant_id" property is deprecated for removal, use '
201 '"project_id" instead')
202 return self.project_id
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500203
204 @property
205 def password(self):
206 """The password being used for requests
207
208 :rtype: string
209 :return: The password being used for requests
210 """
211 return self.auth_provider.credentials.password
212
213 @property
214 def base_url(self):
215 return self.auth_provider.base_url(filters=self.filters)
216
217 @property
218 def token(self):
219 return self.auth_provider.get_token()
220
221 @property
222 def filters(self):
223 _filters = dict(
224 service=self.service,
225 endpoint_type=self.endpoint_type,
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600226 region=self.region,
227 name=self.name
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500228 )
229 if self.api_version is not None:
230 _filters['api_version'] = self.api_version
231 if self._skip_path:
232 _filters['skip_path'] = self._skip_path
233 return _filters
234
235 def skip_path(self):
236 """When set, ignore the path part of the base URL from the catalog"""
237 self._skip_path = True
238
239 def reset_path(self):
240 """When reset, use the base URL from the catalog as-is"""
241 self._skip_path = False
242
243 @classmethod
244 def expected_success(cls, expected_code, read_code):
245 """Check expected success response code against the http response
246
247 :param int expected_code: The response code that is expected.
248 Optionally a list of integers can be used
249 to specify multiple valid success codes
250 :param int read_code: The response code which was returned in the
251 response
252 :raises AssertionError: if the expected_code isn't a valid http success
253 response code
254 :raises exceptions.InvalidHttpSuccessCode: if the read code isn't an
255 expected http success code
256 """
ghanshyamc3074202016-04-18 15:20:45 +0900257 if not isinstance(read_code, int):
258 raise TypeError("'read_code' must be an int instead of (%s)"
259 % type(read_code))
260
Hanxi2f977db2016-09-01 17:31:28 +0800261 assert_msg = ("This function only allowed to use for HTTP status "
262 "codes which explicitly defined in the RFC 7231 & 4918. "
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500263 "{0} is not a defined Success Code!"
264 ).format(expected_code)
265 if isinstance(expected_code, list):
266 for code in expected_code:
267 assert code in HTTP_SUCCESS + HTTP_REDIRECTION, assert_msg
268 else:
269 assert expected_code in HTTP_SUCCESS + HTTP_REDIRECTION, assert_msg
270
271 # NOTE(afazekas): the http status code above 400 is processed by
272 # the _error_checker method
273 if read_code < 400:
zhufl4d2f5152017-01-17 11:16:12 +0800274 pattern = ("Unexpected http success status code {0}, "
275 "The expected status code is {1}")
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500276 if ((not isinstance(expected_code, list) and
277 (read_code != expected_code)) or
278 (isinstance(expected_code, list) and
279 (read_code not in expected_code))):
280 details = pattern.format(read_code, expected_code)
281 raise exceptions.InvalidHttpSuccessCode(details)
282
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200283 def post(self, url, body, headers=None, extra_headers=False,
284 chunked=False):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500285 """Send a HTTP POST request using keystone auth
286
287 :param str url: the relative url to send the post request to
288 :param dict body: the request body
289 :param dict headers: The headers to use for the request
vsaienko9eb846b2016-04-09 00:35:47 +0300290 :param bool extra_headers: Boolean value than indicates if the headers
291 returned by the get_headers() method are to
292 be used but additional headers are needed in
293 the request pass them in as a dict.
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200294 :param bool chunked: sends the body with chunked encoding
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500295 :return: a tuple with the first entry containing the response headers
296 and the second the response body
297 :rtype: tuple
298 """
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200299 return self.request('POST', url, extra_headers, headers, body, chunked)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500300
301 def get(self, url, headers=None, extra_headers=False):
302 """Send a HTTP GET request using keystone service catalog and auth
303
Pallav Gupta1f6cc862019-02-22 01:17:35 +0530304 :param str url: the relative url to send the get request to
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500305 :param dict headers: The headers to use for the request
vsaienko9eb846b2016-04-09 00:35:47 +0300306 :param bool extra_headers: Boolean value than indicates if the headers
307 returned by the get_headers() method are to
308 be used but additional headers are needed in
309 the request pass them in as a dict.
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500310 :return: a tuple with the first entry containing the response headers
311 and the second the response body
312 :rtype: tuple
313 """
314 return self.request('GET', url, extra_headers, headers)
315
316 def delete(self, url, headers=None, body=None, extra_headers=False):
317 """Send a HTTP DELETE request using keystone service catalog and auth
318
Pallav Gupta1f6cc862019-02-22 01:17:35 +0530319 :param str url: the relative url to send the delete request to
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500320 :param dict headers: The headers to use for the request
321 :param dict body: the request body
vsaienko9eb846b2016-04-09 00:35:47 +0300322 :param bool extra_headers: Boolean value than indicates if the headers
323 returned by the get_headers() method are to
324 be used but additional headers are needed in
325 the request pass them in as a dict.
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500326 :return: a tuple with the first entry containing the response headers
327 and the second the response body
328 :rtype: tuple
329 """
330 return self.request('DELETE', url, extra_headers, headers, body)
331
332 def patch(self, url, body, headers=None, extra_headers=False):
333 """Send a HTTP PATCH request using keystone service catalog and auth
334
Pallav Gupta1f6cc862019-02-22 01:17:35 +0530335 :param str url: the relative url to send the patch request to
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500336 :param dict body: the request body
337 :param dict headers: The headers to use for the request
vsaienko9eb846b2016-04-09 00:35:47 +0300338 :param bool extra_headers: Boolean value than indicates if the headers
339 returned by the get_headers() method are to
340 be used but additional headers are needed in
341 the request pass them in as a dict.
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500342 :return: a tuple with the first entry containing the response headers
343 and the second the response body
344 :rtype: tuple
345 """
346 return self.request('PATCH', url, extra_headers, headers, body)
347
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200348 def put(self, url, body, headers=None, extra_headers=False, chunked=False):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500349 """Send a HTTP PUT request using keystone service catalog and auth
350
Pallav Gupta1f6cc862019-02-22 01:17:35 +0530351 :param str url: the relative url to send the put request to
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500352 :param dict body: the request body
353 :param dict headers: The headers to use for the request
vsaienko9eb846b2016-04-09 00:35:47 +0300354 :param bool extra_headers: Boolean value than indicates if the headers
355 returned by the get_headers() method are to
356 be used but additional headers are needed in
357 the request pass them in as a dict.
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200358 :param bool chunked: sends the body with chunked encoding
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500359 :return: a tuple with the first entry containing the response headers
360 and the second the response body
361 :rtype: tuple
362 """
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200363 return self.request('PUT', url, extra_headers, headers, body, chunked)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500364
365 def head(self, url, headers=None, extra_headers=False):
366 """Send a HTTP HEAD request using keystone service catalog and auth
367
Pallav Gupta1f6cc862019-02-22 01:17:35 +0530368 :param str url: the relative url to send the head request to
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500369 :param dict headers: The headers to use for the request
vsaienko9eb846b2016-04-09 00:35:47 +0300370 :param bool extra_headers: Boolean value than indicates if the headers
371 returned by the get_headers() method are to
372 be used but additional headers are needed in
373 the request pass them in as a dict.
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500374 :return: a tuple with the first entry containing the response headers
375 and the second the response body
376 :rtype: tuple
377 """
378 return self.request('HEAD', url, extra_headers, headers)
379
380 def copy(self, url, headers=None, extra_headers=False):
381 """Send a HTTP COPY request using keystone service catalog and auth
382
Pallav Gupta1f6cc862019-02-22 01:17:35 +0530383 :param str url: the relative url to send the copy request to
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500384 :param dict headers: The headers to use for the request
vsaienko9eb846b2016-04-09 00:35:47 +0300385 :param bool extra_headers: Boolean value than indicates if the headers
386 returned by the get_headers() method are to
387 be used but additional headers are needed in
388 the request pass them in as a dict.
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500389 :return: a tuple with the first entry containing the response headers
390 and the second the response body
391 :rtype: tuple
392 """
393 return self.request('COPY', url, extra_headers, headers)
394
395 def get_versions(self):
sunqingliang699690f62018-11-09 15:03:17 +0800396 """Get the versions on an endpoint from the keystone catalog
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500397
398 This method will make a GET request on the baseurl from the keystone
399 catalog to return a list of API versions. It is expected that a GET
400 on the endpoint in the catalog will return a list of supported API
401 versions.
402
junboli872ca872017-07-21 13:24:38 +0800403 :return: tuple with response headers and list of version numbers
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500404 :rtype: tuple
405 """
406 resp, body = self.get('')
407 body = self._parse_resp(body)
408 versions = map(lambda x: x['id'], body)
409 return resp, versions
410
411 def _get_request_id(self, resp):
412 for i in ('x-openstack-request-id', 'x-compute-request-id'):
413 if i in resp:
414 return resp[i]
415 return ""
416
Stephen Finucane8aa5f892022-05-10 18:20:52 +0100417 def _get_global_request_id(self, resp):
418 if 'x-openstack-request-id' in resp:
419 return resp['x-openstack-request-id']
420 return ''
421
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500422 def _safe_body(self, body, maxlen=4096):
423 # convert a structure into a string safely
424 try:
likui19b70a32020-12-02 13:25:18 +0800425 text = str(body)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500426 except UnicodeDecodeError:
427 # if this isn't actually text, return marker that
428 return "<BinaryData: removed>"
429 if len(text) > maxlen:
430 return text[:maxlen]
431 else:
432 return text
433
guo yunxian9f749f92016-08-25 10:55:04 +0800434 def _log_request_start(self, method, req_url):
Jordan Pittier9e227c52016-02-09 14:35:18 +0100435 caller_name = test_utils.find_test_caller()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500436 if self.trace_requests and re.search(self.trace_requests, caller_name):
Jordan Pittier525ec712016-12-07 17:51:26 +0100437 self.LOG.debug('Starting Request (%s): %s %s', caller_name,
438 method, req_url)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500439
guo yunxian9f749f92016-08-25 10:55:04 +0800440 def _log_request_full(self, resp, req_headers=None, req_body=None,
441 resp_body=None, extra=None):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500442 if 'X-Auth-Token' in req_headers:
443 req_headers['X-Auth-Token'] = '<omitted>'
Ken'ichi Ohmichi2902a7b2018-07-14 02:31:03 +0000444 if 'X-Subject-Token' in req_headers:
445 req_headers['X-Subject-Token'] = '<omitted>'
Andrea Frittoli (andreaf)a1edb2d2016-05-10 16:09:59 +0100446 # A shallow copy is sufficient
447 resp_log = resp.copy()
448 if 'x-subject-token' in resp_log:
449 resp_log['x-subject-token'] = '<omitted>'
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500450 log_fmt = """Request - Headers: %s
451 Body: %s
452 Response - Headers: %s
453 Body: %s"""
454
455 self.LOG.debug(
Jordan Pittier525ec712016-12-07 17:51:26 +0100456 log_fmt,
457 str(req_headers),
458 self._safe_body(req_body),
459 str(resp_log),
460 self._safe_body(resp_body),
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500461 extra=extra)
462
463 def _log_request(self, method, req_url, resp,
464 secs="", req_headers=None,
465 req_body=None, resp_body=None):
466 if req_headers is None:
467 req_headers = {}
468 # if we have the request id, put it in the right part of the log
Stephen Finucane8aa5f892022-05-10 18:20:52 +0100469 extra = {
470 'request_id': self._get_request_id(resp),
471 'global_request_id': self._get_global_request_id(resp),
472 }
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500473 # NOTE(sdague): while we still have 6 callers to this function
474 # we're going to just provide work around on who is actually
475 # providing timings by gracefully adding no content if they don't.
476 # Once we're down to 1 caller, clean this up.
Jordan Pittier9e227c52016-02-09 14:35:18 +0100477 caller_name = test_utils.find_test_caller()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500478 if secs:
479 secs = " %.3fs" % secs
480 self.LOG.info(
Jordan Pittier525ec712016-12-07 17:51:26 +0100481 'Request (%s): %s %s %s%s',
482 caller_name,
483 resp['status'],
484 method,
485 req_url,
486 secs,
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500487 extra=extra)
488
489 # Also look everything at DEBUG if you want to filter this
490 # out, don't run at debug.
Anusha Raminenif3eb9472017-01-13 08:54:01 +0530491 if self.LOG.isEnabledFor(logging.DEBUG):
guo yunxian9f749f92016-08-25 10:55:04 +0800492 self._log_request_full(resp, req_headers, req_body,
493 resp_body, extra)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500494
495 def _parse_resp(self, body):
496 try:
497 body = json.loads(body)
498 except ValueError:
499 return body
500
501 # We assume, that if the first value of the deserialized body's
502 # item set is a dict or a list, that we just return the first value
503 # of deserialized body.
504 # Essentially "cutting out" the first placeholder element in a body
505 # that looks like this:
506 #
507 # {
508 # "users": [
509 # ...
510 # ]
511 # }
512 try:
513 # Ensure there are not more than one top-level keys
514 # NOTE(freerunner): Ensure, that JSON is not nullable to
515 # to prevent StopIteration Exception
Ken'ichi Ohmichi69a8edc2017-04-28 11:41:20 -0700516 if not hasattr(body, "keys") or len(body.keys()) != 1:
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500517 return body
518 # Just return the "wrapped" element
songwenpinge6623072021-02-22 14:47:34 +0800519 _, first_item = tuple(body.items())[0]
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500520 if isinstance(first_item, (dict, list)):
521 return first_item
522 except (ValueError, IndexError):
523 pass
524 return body
525
526 def response_checker(self, method, resp, resp_body):
527 """A sanity check on the response from a HTTP request
528
529 This method does a sanity check on whether the response from an HTTP
530 request conforms the HTTP RFC.
531
532 :param str method: The HTTP verb of the request associated with the
533 response being passed in.
534 :param resp: The response headers
535 :param resp_body: The body of the response
536 :raises ResponseWithNonEmptyBody: If the response with the status code
537 is not supposed to have a body
538 :raises ResponseWithEntity: If the response code is 205 but has an
539 entity
540 """
541 if (resp.status in set((204, 205, 304)) or resp.status < 200 or
542 method.upper() == 'HEAD') and resp_body:
543 raise exceptions.ResponseWithNonEmptyBody(status=resp.status)
544 # NOTE(afazekas):
545 # If the HTTP Status Code is 205
546 # 'The response MUST NOT include an entity.'
547 # A HTTP entity has an entity-body and an 'entity-header'.
548 # In the HTTP response specification (Section 6) the 'entity-header'
549 # 'generic-header' and 'response-header' are in OR relation.
550 # All headers not in the above two group are considered as entity
551 # header in every interpretation.
552
553 if (resp.status == 205 and
554 0 != len(set(resp.keys()) - set(('status',)) -
555 self.response_header_lc - self.general_header_lc)):
Matt Riedemann91d92422019-01-29 16:19:49 -0500556 raise exceptions.ResponseWithEntity()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500557 # NOTE(afazekas)
558 # Now the swift sometimes (delete not empty container)
559 # returns with non json error response, we can create new rest class
560 # for swift.
561 # Usually RFC2616 says error responses SHOULD contain an explanation.
562 # The warning is normal for SHOULD/SHOULD NOT case
563
564 # Likely it will cause an error
565 if method != 'HEAD' and not resp_body and resp.status >= 400:
566 self.LOG.warning("status >= 400 response with empty body")
567
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200568 def _request(self, method, url, headers=None, body=None, chunked=False):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500569 """A simple HTTP request interface."""
570 # Authenticate the request with the auth provider
571 req_url, req_headers, req_body = self.auth_provider.auth_request(
572 method, url, headers, body, self.filters)
573
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500574 resp, resp_body = self.raw_request(
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200575 req_url, method, headers=req_headers, body=req_body,
576 chunked=chunked
577 )
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500578 # Verify HTTP response codes
579 self.response_checker(method, resp, resp_body)
580
581 return resp, resp_body
582
Ghanshyam Mann47a41992019-10-02 16:56:26 +0000583 def raw_request(self, url, method, headers=None, body=None, chunked=False,
584 log_req_body=None):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500585 """Send a raw HTTP request without the keystone catalog or auth
586
587 This method sends a HTTP request in the same manner as the request()
588 method, however it does so without using keystone auth or the catalog
589 to determine the base url. Additionally no response handling is done
590 the results from the request are just returned.
591
592 :param str url: Full url to send the request
593 :param str method: The HTTP verb to use for the request
zhuflcf35f652018-08-17 10:13:43 +0800594 :param dict headers: Headers to use for the request. If none are
595 specified, then the headers returned from the
596 get_headers() method are used. If the request
597 explicitly requires no headers use an empty dict.
Anh Trand44a8be2016-03-25 09:49:14 +0700598 :param str body: Body to send with the request
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200599 :param bool chunked: sends the body with chunked encoding
Ghanshyam Mann47a41992019-10-02 16:56:26 +0000600 :param str log_req_body: Whether to log the request body or not.
601 It is default to None which means request
602 body is safe to log otherwise pass any string
603 you want to log in place of request body.
604 For example: '<omitted>'
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500605 :rtype: tuple
606 :return: a tuple with the first entry containing the response headers
607 and the second the response body
608 """
609 if headers is None:
610 headers = self.get_headers()
Ghanshyam Manncb3cf032019-09-26 00:02:54 +0000611 # Do the actual request, and time it
612 start = time.time()
613 self._log_request_start(method, url)
614 resp, resp_body = self.http_obj.request(
615 url, method, headers=headers,
616 body=body, chunked=chunked)
617 end = time.time()
Ghanshyam Mann47a41992019-10-02 16:56:26 +0000618 req_body = body if log_req_body is None else log_req_body
Ghanshyam Manncb3cf032019-09-26 00:02:54 +0000619 self._log_request(method, url, resp, secs=(end - start),
Ghanshyam Mann47a41992019-10-02 16:56:26 +0000620 req_headers=headers, req_body=req_body,
Ghanshyam Manncb3cf032019-09-26 00:02:54 +0000621 resp_body=resp_body)
622 return resp, resp_body
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500623
624 def request(self, method, url, extra_headers=False, headers=None,
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200625 body=None, chunked=False):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500626 """Send a HTTP request with keystone auth and using the catalog
627
628 This method will send an HTTP request using keystone auth in the
629 headers and the catalog to determine the endpoint to use for the
630 baseurl to send the request to. Additionally
631
632 When a response is received it will check it to see if an error
633 response was received. If it was an exception will be raised to enable
634 it to be handled quickly.
635
636 This method will also handle rate-limiting, if a 413 response code is
637 received it will retry the request after waiting the 'retry-after'
638 duration from the header.
639
640 :param str method: The HTTP verb to use for the request
641 :param str url: Relative url to send the request to
vsaienko9eb846b2016-04-09 00:35:47 +0300642 :param bool extra_headers: Boolean value than indicates if the headers
643 returned by the get_headers() method are to
644 be used but additional headers are needed in
645 the request pass them in as a dict.
zhuflcf35f652018-08-17 10:13:43 +0800646 :param dict headers: Headers to use for the request. If none are
647 specified, then the headers returned from the
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500648 get_headers() method are used. If the request
649 explicitly requires no headers use an empty dict.
Anh Trand44a8be2016-03-25 09:49:14 +0700650 :param str body: Body to send with the request
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200651 :param bool chunked: sends the body with chunked encoding
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500652 :rtype: tuple
653 :return: a tuple with the first entry containing the response headers
654 and the second the response body
655 :raises UnexpectedContentType: If the content-type of the response
656 isn't an expect type
657 :raises Unauthorized: If a 401 response code is received
658 :raises Forbidden: If a 403 response code is received
659 :raises NotFound: If a 404 response code is received
660 :raises BadRequest: If a 400 response code is received
661 :raises Gone: If a 410 response code is received
662 :raises Conflict: If a 409 response code is received
Kevin Bentona82bc862017-02-13 01:16:13 -0800663 :raises PreconditionFailed: If a 412 response code is received
zhuflcf35f652018-08-17 10:13:43 +0800664 :raises OverLimit: If a 413 response code is received and retry-after
665 is not in the response body or its retry operation
666 exceeds the limits defined by the server
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500667 :raises RateLimitExceeded: If a 413 response code is received and
zhuflcf35f652018-08-17 10:13:43 +0800668 retry-after is in the response body and
669 its retry operation does not exceeds the
670 limits defined by the server
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500671 :raises InvalidContentType: If a 415 response code is received
672 :raises UnprocessableEntity: If a 422 response code is received
673 :raises InvalidHTTPResponseBody: The response body wasn't valid JSON
674 and couldn't be parsed
675 :raises NotImplemented: If a 501 response code is received
676 :raises ServerFault: If a 500 response code is received
677 :raises UnexpectedResponseCode: If a response code above 400 is
678 received and it doesn't fall into any
679 of the handled checks
680 """
681 # if extra_headers is True
682 # default headers would be added to headers
683 retry = 0
684
685 if headers is None:
686 # NOTE(vponomaryov): if some client do not need headers,
687 # it should explicitly pass empty dict
688 headers = self.get_headers()
689 elif extra_headers:
690 try:
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500691 headers.update(self.get_headers())
692 except (ValueError, TypeError):
693 headers = self.get_headers()
694
Jordan Pittier4408c4a2016-04-29 15:05:09 +0200695 resp, resp_body = self._request(method, url, headers=headers,
696 body=body, chunked=chunked)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500697
698 while (resp.status == 413 and
699 'retry-after' in resp and
700 not self.is_absolute_limit(
701 resp, self._parse_resp(resp_body)) and
702 retry < MAX_RECURSION_DEPTH):
703 retry += 1
Paul Glass119565a2016-04-06 11:41:42 -0500704 delay = self._get_retry_after_delay(resp)
705 self.LOG.debug(
706 "Sleeping %s seconds based on retry-after header", delay
707 )
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500708 time.sleep(delay)
709 resp, resp_body = self._request(method, url,
710 headers=headers, body=body)
Ken'ichi Ohmichi17051e82016-10-20 11:46:06 -0700711 self._error_checker(resp, resp_body)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500712 return resp, resp_body
713
Paul Glass119565a2016-04-06 11:41:42 -0500714 def _get_retry_after_delay(self, resp):
715 """Extract the delay from the retry-after header.
716
717 This supports both integer and HTTP date formatted retry-after headers
718 per RFC 2616.
719
720 :param resp: The response containing the retry-after headers
721 :rtype: int
722 :return: The delay in seconds, clamped to be at least 1 second
723 :raises ValueError: On failing to parse the delay
724 """
725 delay = None
726 try:
727 delay = int(resp['retry-after'])
728 except (ValueError, KeyError):
729 pass
730
731 try:
732 retry_timestamp = self._parse_http_date(resp['retry-after'])
733 date_timestamp = self._parse_http_date(resp['date'])
734 delay = int(retry_timestamp - date_timestamp)
735 except (ValueError, OverflowError, KeyError):
736 pass
737
738 if delay is None:
739 raise ValueError(
740 "Failed to parse retry-after header %r as either int or "
741 "HTTP-date." % resp.get('retry-after')
742 )
743
744 # Retry-after headers do not have sub-second precision. Clients may
745 # receive a delay of 0. After sleeping 0 seconds, we would (likely) hit
746 # another 413. To avoid this, always sleep at least 1 second.
747 return max(1, delay)
748
749 def _parse_http_date(self, val):
750 """Parse an HTTP date, like 'Fri, 31 Dec 1999 23:59:59 GMT'.
751
752 Return an epoch timestamp (float), as returned by time.mktime().
753 """
754 parts = email.utils.parsedate(val)
755 if not parts:
756 raise ValueError("Failed to parse date %s" % val)
757 return time.mktime(parts)
758
Ken'ichi Ohmichi17051e82016-10-20 11:46:06 -0700759 def _error_checker(self, resp, resp_body):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500760
761 # NOTE(mtreinish): Check for httplib response from glance_http. The
762 # object can't be used here because importing httplib breaks httplib2.
763 # If another object from a class not imported were passed here as
764 # resp this could possibly fail
765 if str(type(resp)) == "<type 'instance'>":
766 ctype = resp.getheader('content-type')
767 else:
768 try:
769 ctype = resp['content-type']
770 # NOTE(mtreinish): Keystone delete user responses doesn't have a
771 # content-type header. (They don't have a body) So just pretend it
772 # is set.
773 except KeyError:
774 ctype = 'application/json'
775
776 # It is not an error response
777 if resp.status < 400:
778 return
779
zhipenghd1db0c72017-02-21 04:40:07 -0500780 # NOTE(zhipengh): There is a purposefully duplicate of content-type
781 # with the only difference is with or without spaces, as specified
782 # in RFC7231.
783 JSON_ENC = ['application/json', 'application/json; charset=utf-8',
784 'application/json;charset=utf-8']
785
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500786 # NOTE(mtreinish): This is for compatibility with Glance and swift
787 # APIs. These are the return content types that Glance api v1
788 # (and occasionally swift) are using.
zhipenghd1db0c72017-02-21 04:40:07 -0500789 # NOTE(zhipengh): There is a purposefully duplicate of content-type
790 # with the only difference is with or without spaces, as specified
791 # in RFC7231.
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500792 TXT_ENC = ['text/plain', 'text/html', 'text/html; charset=utf-8',
zhipenghd1db0c72017-02-21 04:40:07 -0500793 'text/plain; charset=utf-8', 'text/html;charset=utf-8',
794 'text/plain;charset=utf-8']
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500795
796 if ctype.lower() in JSON_ENC:
797 parse_resp = True
798 elif ctype.lower() in TXT_ENC:
799 parse_resp = False
800 else:
801 raise exceptions.UnexpectedContentType(str(resp.status),
802 resp=resp)
803
804 if resp.status == 401:
805 if parse_resp:
806 resp_body = self._parse_resp(resp_body)
807 raise exceptions.Unauthorized(resp_body, resp=resp)
808
809 if resp.status == 403:
810 if parse_resp:
811 resp_body = self._parse_resp(resp_body)
812 raise exceptions.Forbidden(resp_body, resp=resp)
813
814 if resp.status == 404:
815 if parse_resp:
816 resp_body = self._parse_resp(resp_body)
817 raise exceptions.NotFound(resp_body, resp=resp)
818
819 if resp.status == 400:
820 if parse_resp:
821 resp_body = self._parse_resp(resp_body)
822 raise exceptions.BadRequest(resp_body, resp=resp)
823
824 if resp.status == 410:
825 if parse_resp:
826 resp_body = self._parse_resp(resp_body)
827 raise exceptions.Gone(resp_body, resp=resp)
828
829 if resp.status == 409:
830 if parse_resp:
831 resp_body = self._parse_resp(resp_body)
832 raise exceptions.Conflict(resp_body, resp=resp)
833
Kevin Bentona82bc862017-02-13 01:16:13 -0800834 if resp.status == 412:
835 if parse_resp:
836 resp_body = self._parse_resp(resp_body)
837 raise exceptions.PreconditionFailed(resp_body, resp=resp)
838
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500839 if resp.status == 413:
840 if parse_resp:
841 resp_body = self._parse_resp(resp_body)
842 if self.is_absolute_limit(resp, resp_body):
843 raise exceptions.OverLimit(resp_body, resp=resp)
844 else:
845 raise exceptions.RateLimitExceeded(resp_body, resp=resp)
846
847 if resp.status == 415:
848 if parse_resp:
849 resp_body = self._parse_resp(resp_body)
850 raise exceptions.InvalidContentType(resp_body, resp=resp)
851
852 if resp.status == 422:
853 if parse_resp:
854 resp_body = self._parse_resp(resp_body)
855 raise exceptions.UnprocessableEntity(resp_body, resp=resp)
856
857 if resp.status in (500, 501):
858 message = resp_body
859 if parse_resp:
860 try:
861 resp_body = self._parse_resp(resp_body)
862 except ValueError:
863 # If response body is a non-json string message.
864 # Use resp_body as is and raise InvalidResponseBody
865 # exception.
866 raise exceptions.InvalidHTTPResponseBody(message)
867 else:
868 if isinstance(resp_body, dict):
869 # I'm seeing both computeFault
870 # and cloudServersFault come back.
871 # Will file a bug to fix, but leave as is for now.
872 if 'cloudServersFault' in resp_body:
873 message = resp_body['cloudServersFault']['message']
874 elif 'computeFault' in resp_body:
875 message = resp_body['computeFault']['message']
876 elif 'error' in resp_body:
877 message = resp_body['error']['message']
878 elif 'message' in resp_body:
879 message = resp_body['message']
880 else:
881 message = resp_body
882
883 if resp.status == 501:
884 raise exceptions.NotImplemented(resp_body, resp=resp,
885 message=message)
886 else:
887 raise exceptions.ServerFault(resp_body, resp=resp,
888 message=message)
889
890 if resp.status >= 400:
891 raise exceptions.UnexpectedResponseCode(str(resp.status),
892 resp=resp)
893
894 def is_absolute_limit(self, resp, resp_body):
Takashi Kajinami6354f612021-07-17 00:37:34 +0900895 if (not isinstance(resp_body, abc.Mapping) or
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500896 'retry-after' not in resp):
897 return True
Paul Glass119565a2016-04-06 11:41:42 -0500898 return 'exceed' in resp_body.get('message', 'blabla')
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500899
Benny Kopilovf5e277c2021-02-08 12:27:37 +0200900 def wait_for_resource_deletion(self, id, *args, **kwargs):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500901 """Waits for a resource to be deleted
902
903 This method will loop over is_resource_deleted until either
904 is_resource_deleted returns True or the build timeout is reached. This
905 depends on is_resource_deleted being implemented
906
907 :param str id: The id of the resource to check
908 :raises TimeoutException: If the build_timeout has elapsed and the
909 resource still hasn't been deleted
910 """
911 start_time = int(time.time())
912 while True:
Benny Kopilovf5e277c2021-02-08 12:27:37 +0200913 if self.is_resource_deleted(id, *args, **kwargs):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500914 return
915 if int(time.time()) - start_time >= self.build_timeout:
916 message = ('Failed to delete %(resource_type)s %(id)s within '
Sampat Ponnagantief552162021-03-17 18:07:36 +0000917 'the required time (%(timeout)s s). Timer started '
Eric Harney941043f2022-01-07 14:21:50 -0500918 'at %(start_time)s. Timer ended at %(end_time)s. '
919 'Waited for %(wait_time)s s.' %
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500920 {'resource_type': self.resource_type, 'id': id,
Sampat Ponnagantief552162021-03-17 18:07:36 +0000921 'timeout': self.build_timeout,
922 'start_time': start_time,
923 'end_time': int(time.time()),
924 'wait_time': int(time.time()) - start_time})
Jordan Pittier9e227c52016-02-09 14:35:18 +0100925 caller = test_utils.find_test_caller()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500926 if caller:
927 message = '(%s) %s' % (caller, message)
928 raise exceptions.TimeoutException(message)
929 time.sleep(self.build_interval)
930
Abhishek Kekane7cff1302020-07-16 10:30:13 +0000931 def wait_for_resource_activation(self, id):
932 """Waits for a resource to become active
933
934 This method will loop over is_resource_active until either
935 is_resource_active returns True or the build timeout is reached. This
936 depends on is_resource_active being implemented
937
938 :param str id: The id of the resource to check
939 :raises TimeoutException: If the build_timeout has elapsed and the
940 resource still hasn't been active
941 """
942 start_time = int(time.time())
943 while True:
944 if self.is_resource_active(id):
945 return
946 if int(time.time()) - start_time >= self.build_timeout:
947 message = ('Failed to reach active state %(resource_type)s '
948 '%(id)s within the required time (%(timeout)s s).' %
949 {'resource_type': self.resource_type, 'id': id,
950 'timeout': self.build_timeout})
951 caller = test_utils.find_test_caller()
952 if caller:
953 message = '(%s) %s' % (caller, message)
954 raise exceptions.TimeoutException(message)
955 time.sleep(self.build_interval)
956
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500957 def is_resource_deleted(self, id):
958 """Subclasses override with specific deletion detection."""
959 message = ('"%s" does not implement is_resource_deleted'
960 % self.__class__.__name__)
961 raise NotImplementedError(message)
962
Abhishek Kekane7cff1302020-07-16 10:30:13 +0000963 def is_resource_active(self, id):
964 """Subclasses override with specific active detection."""
965 message = ('"%s" does not implement is_resource_active'
966 % self.__class__.__name__)
967 raise NotImplementedError(message)
968
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500969 @property
970 def resource_type(self):
971 """Returns the primary type of resource this client works with."""
972 return 'resource'
973
974 @classmethod
975 def validate_response(cls, schema, resp, body):
976 # Only check the response if the status code is a success code
977 # TODO(cyeoh): Eventually we should be able to verify that a failure
978 # code if it exists is something that we expect. This is explicitly
979 # declared in the V3 API and so we should be able to export this in
980 # the response schema. For now we'll ignore it.
981 if resp.status in HTTP_SUCCESS + HTTP_REDIRECTION:
982 cls.expected_success(schema['status_code'], resp.status)
983
984 # Check the body of a response
985 body_schema = schema.get('response_body')
986 if body_schema:
987 try:
988 jsonschema.validate(body, body_schema,
989 cls=JSONSCHEMA_VALIDATOR,
990 format_checker=FORMAT_CHECKER)
991 except jsonschema.ValidationError as ex:
guo yunxiana3f55282016-08-10 14:35:16 +0800992 msg = ("HTTP response body is invalid (%s)" % ex)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500993 raise exceptions.InvalidHTTPResponseBody(msg)
994 else:
995 if body:
guo yunxiana3f55282016-08-10 14:35:16 +0800996 msg = ("HTTP response body should not exist (%s)" % body)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500997 raise exceptions.InvalidHTTPResponseBody(msg)
998
999 # Check the header of a response
1000 header_schema = schema.get('response_header')
1001 if header_schema:
1002 try:
1003 jsonschema.validate(resp, header_schema,
1004 cls=JSONSCHEMA_VALIDATOR,
1005 format_checker=FORMAT_CHECKER)
1006 except jsonschema.ValidationError as ex:
guo yunxiana3f55282016-08-10 14:35:16 +08001007 msg = ("HTTP response header is invalid (%s)" % ex)
Matthew Treinish9e26ca82016-02-23 11:43:20 -05001008 raise exceptions.InvalidHTTPResponseHeader(msg)
1009
Ken'ichi Ohmichida26b162017-03-03 15:53:46 -08001010 def _get_base_version_url(self):
1011 # TODO(oomichi): This method can be used for auth's replace_version().
1012 # So it is nice to have common logic for the maintenance.
1013 endpoint = self.base_url
1014 url = urllib.parse.urlsplit(endpoint)
1015 new_path = re.split(r'(^|/)+v\d+(\.\d+)?', url.path)[0]
1016 url = list(url)
1017 url[2] = new_path + '/'
1018 return urllib.parse.urlunsplit(url)
1019
Matthew Treinish9e26ca82016-02-23 11:43:20 -05001020
1021class ResponseBody(dict):
1022 """Class that wraps an http response and dict body into a single value.
1023
1024 Callers that receive this object will normally use it as a dict but
1025 can extract the response if needed.
1026 """
1027
1028 def __init__(self, response, body=None):
1029 body_data = body or {}
1030 self.update(body_data)
1031 self.response = response
1032
1033 def __str__(self):
1034 body = super(ResponseBody, self).__str__()
1035 return "response: %s\nBody: %s" % (self.response, body)
1036
1037
1038class ResponseBodyData(object):
1039 """Class that wraps an http response and string data into a single value.
1040
1041 """
1042
1043 def __init__(self, response, data):
1044 self.response = response
1045 self.data = data
1046
1047 def __str__(self):
1048 return "response: %s\nBody: %s" % (self.response, self.data)
1049
1050
1051class ResponseBodyList(list):
1052 """Class that wraps an http response and list body into a single value.
1053
1054 Callers that receive this object will normally use it as a list but
1055 can extract the response if needed.
1056 """
1057
1058 def __init__(self, response, body=None):
1059 body_data = body or []
1060 self.extend(body_data)
1061 self.response = response
1062
1063 def __str__(self):
1064 body = super(ResponseBodyList, self).__str__()
1065 return "response: %s\nBody: %s" % (self.response, body)