Create a new base REST API interface

* restapi module provides basic REST API support
  * uses dicts rather than Resource classes
  * JSON serialization/deserialization
  * log requests in 'curl' format
  * basic API boilerplate for create/delete/list/set/show verbs
* ignore H302 due to urllib import

Change-Id: I3cb91e44e631ee19e9f5dea19b6bac5d599d19ce
This commit is contained in:
Dean Troyer 2013-08-13 17:14:42 -05:00
parent b440986e6e
commit 17f13f7bf4
6 changed files with 552 additions and 1 deletions

@ -0,0 +1,188 @@
# Copyright 2013 Nebula Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""REST API bits"""
import json
import logging
import requests
try:
from urllib.parse import urlencode
except ImportError:
from urllib import urlencode
_logger = logging.getLogger(__name__)
class RESTApi(object):
"""A REST api client that handles the interface from us to the server
RESTApi is an extension of a requests.Session that knows
how to do:
* JSON serialization/deserialization
* log requests in 'curl' format
* basic API boilerplate for create/delete/list/set/show verbs
* authentication is handled elsewhere and a token is passed in
The expectation that there will be a RESTApi object per authentication
token in use, i.e. project/username/auth_endpoint
On the other hand, a Client knows details about the specific REST Api that
it communicates with, such as the available endpoints, API versions, etc.
"""
USER_AGENT = 'RAPI'
def __init__(
self,
os_auth=None,
user_agent=USER_AGENT,
debug=None,
**kwargs
):
self.set_auth(os_auth)
self.debug = debug
self.session = requests.Session(**kwargs)
self.set_header('User-Agent', user_agent)
self.set_header('Content-Type', 'application/json')
def set_auth(self, os_auth):
"""Sets the current auth blob"""
self.os_auth = os_auth
def set_header(self, header, content):
"""Sets passed in headers into the session headers
Replaces existing headers!!
"""
if content is None:
del self.session.headers[header]
else:
self.session.headers[header] = content
def request(self, method, url, **kwargs):
if self.os_auth:
self.session.headers.setdefault('X-Auth-Token', self.os_auth)
if 'data' in kwargs and isinstance(kwargs['data'], type({})):
kwargs['data'] = json.dumps(kwargs['data'])
log_request(method, url, headers=self.session.headers, **kwargs)
response = self.session.request(method, url, **kwargs)
log_response(response)
return self._error_handler(response)
def create(self, url, data=None, response_key=None, **kwargs):
response = self.request('POST', url, data=data, **kwargs)
if response_key:
return response.json()[response_key]
else:
return response.json()
#with self.completion_cache('human_id', self.resource_class, mode="a"):
# with self.completion_cache('uuid', self.resource_class, mode="a"):
# return self.resource_class(self, body[response_key])
def delete(self, url):
self.request('DELETE', url)
def list(self, url, data=None, response_key=None, **kwargs):
if data:
response = self.request('POST', url, data=data, **kwargs)
else:
kwargs.setdefault('allow_redirects', True)
response = self.request('GET', url, **kwargs)
return response.json()[response_key]
###hack this for keystone!!!
#data = body[response_key]
# NOTE(ja): keystone returns values as list as {'values': [ ... ]}
# unlike other services which just return the list...
#if isinstance(data, dict):
# try:
# data = data['values']
# except KeyError:
# pass
#with self.completion_cache('human_id', obj_class, mode="w"):
# with self.completion_cache('uuid', obj_class, mode="w"):
# return [obj_class(self, res, loaded=True)
# for res in data if res]
def set(self, url, data=None, response_key=None, **kwargs):
response = self.request('PUT', url, data=data)
if data:
if response_key:
return response.json()[response_key]
else:
return response.json()
else:
return None
def show(self, url, response_key=None, **kwargs):
response = self.request('GET', url, **kwargs)
if response_key:
return response.json()[response_key]
else:
return response.json()
def _error_handler(self, response):
if response.status_code < 200 or response.status_code >= 300:
_logger.debug(
"ERROR: %s",
response.text,
)
response.raise_for_status()
return response
def log_request(method, url, **kwargs):
# put in an early exit if debugging is not enabled?
if 'params' in kwargs and kwargs['params'] != {}:
url += '?' + urlencode(kwargs['params'])
string_parts = [
"curl -i",
"-X '%s'" % method,
"'%s'" % url,
]
for element in kwargs['headers']:
header = " -H '%s: %s'" % (element, kwargs['headers'][element])
string_parts.append(header)
_logger.debug("REQ: %s" % " ".join(string_parts))
if 'data' in kwargs:
_logger.debug("REQ BODY: %s\n" % (kwargs['data']))
def log_response(response):
_logger.debug(
"RESP: [%s] %s\n",
response.status_code,
response.headers,
)
if response._content_consumed:
_logger.debug(
"RESP BODY: %s\n",
response.text,
)
_logger.debug(
"encoding: %s",
response.encoding,
)

@ -115,6 +115,30 @@ def get_item_properties(item, fields, mixed_case_fields=[], formatters={}):
return tuple(row) return tuple(row)
def get_dict_properties(item, fields, mixed_case_fields=[], formatters={}):
"""Return a tuple containing the item properties.
:param item: a single dict resource
:param fields: tuple of strings with the desired field names
:param mixed_case_fields: tuple of field names to preserve case
:param formatters: dictionary mapping field names to callables
to format the values
"""
row = []
for field in fields:
if field in mixed_case_fields:
field_name = field.replace(' ', '_')
else:
field_name = field.lower().replace(' ', '_')
data = item[field_name] if field_name in item else ''
if field in formatters:
row.append(formatters[field](data))
else:
row.append(data)
return tuple(row)
def string_to_bool(arg): def string_to_bool(arg):
return arg.strip().lower() in ('t', 'true', 'yes', '1') return arg.strip().lower() in ('t', 'true', 'yes', '1')

@ -29,6 +29,7 @@ from openstackclient.common import clientmanager
from openstackclient.common import commandmanager from openstackclient.common import commandmanager
from openstackclient.common import exceptions as exc from openstackclient.common import exceptions as exc
from openstackclient.common import openstackkeyring from openstackclient.common import openstackkeyring
from openstackclient.common import restapi
from openstackclient.common import utils from openstackclient.common import utils
@ -368,6 +369,9 @@ class OpenStackShell(app.App):
if self.options.deferred_help: if self.options.deferred_help:
self.DeferredHelpAction(self.parser, self.parser, None, None) self.DeferredHelpAction(self.parser, self.parser, None, None)
# Set up common client session
self.restapi = restapi.RESTApi()
# If the user is not asking for help, make sure they # If the user is not asking for help, make sure they
# have given us auth. # have given us auth.
cmd_name = None cmd_name = None
@ -376,6 +380,7 @@ class OpenStackShell(app.App):
cmd_factory, cmd_name, sub_argv = cmd_info cmd_factory, cmd_name, sub_argv = cmd_info
if self.interactive_mode or cmd_name != 'help': if self.interactive_mode or cmd_name != 'help':
self.authenticate_user() self.authenticate_user()
self.restapi.set_auth(self.client_manager.identity.auth_token)
def prepare_to_run_command(self, cmd): def prepare_to_run_command(self, cmd):
"""Set up auth and API versions""" """Set up auth and API versions"""

@ -0,0 +1,14 @@
# Copyright 2013 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#

@ -0,0 +1,320 @@
# Copyright 2013 Nebula Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""Test rest module"""
import json
import mock
import requests
from openstackclient.common import restapi
from openstackclient.tests import utils
fake_auth = '11223344556677889900'
fake_url = 'http://gopher.com'
fake_key = 'gopher'
fake_keys = 'gophers'
fake_gopher_mac = {
'id': 'g1',
'name': 'mac',
'actor': 'Mel Blanc',
}
fake_gopher_tosh = {
'id': 'g2',
'name': 'tosh',
'actor': 'Stan Freeberg',
}
fake_gopher_single = {
fake_key: fake_gopher_mac,
}
fake_gopher_list = {
fake_keys:
[
fake_gopher_mac,
fake_gopher_tosh,
]
}
class FakeResponse(requests.Response):
def __init__(self, headers={}, status_code=None, data=None, encoding=None):
super(FakeResponse, self).__init__()
self.status_code = status_code
self.headers.update(headers)
self._content = json.dumps(data)
@mock.patch('openstackclient.common.restapi.requests.Session')
class TestRESTApi(utils.TestCase):
def test_request_get(self, session_mock):
resp = FakeResponse(status_code=200, data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
api = restapi.RESTApi()
gopher = api.request('GET', fake_url)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
)
self.assertEqual(gopher.status_code, 200)
self.assertEqual(gopher.json(), fake_gopher_single)
def test_request_get_return_300(self, session_mock):
resp = FakeResponse(status_code=300, data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
api = restapi.RESTApi()
gopher = api.request('GET', fake_url)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
)
self.assertEqual(gopher.status_code, 300)
self.assertEqual(gopher.json(), fake_gopher_single)
def test_request_get_fail_404(self, session_mock):
resp = FakeResponse(status_code=404, data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
api = restapi.RESTApi()
self.assertRaises(requests.HTTPError, api.request, 'GET', fake_url)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
)
def test_request_get_auth(self, session_mock):
resp = FakeResponse(data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
headers=mock.MagicMock(return_value={}),
)
api = restapi.RESTApi(os_auth=fake_auth)
gopher = api.request('GET', fake_url)
session_mock.return_value.headers.setdefault.assert_called_with(
'X-Auth-Token',
fake_auth,
)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
)
self.assertEqual(gopher.json(), fake_gopher_single)
def test_request_get_header(self, session_mock):
resp = FakeResponse(data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
headers=mock.MagicMock(return_value={}),
)
api = restapi.RESTApi(user_agent='fake_agent')
api.set_header('X-Fake-Header', 'wb')
gopher = api.request('GET', fake_url)
session_mock.return_value.headers.__setitem__.assert_any_call(
'Content-Type',
'application/json',
)
session_mock.return_value.headers.__setitem__.assert_any_call(
'User-Agent',
'fake_agent',
)
session_mock.return_value.headers.__setitem__.assert_any_call(
'X-Fake-Header',
'wb',
)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
)
self.assertEqual(gopher.json(), fake_gopher_single)
api.set_header('X-Fake-Header', None)
session_mock.return_value.headers.__delitem__.assert_any_call(
'X-Fake-Header',
)
def test_request_post(self, session_mock):
resp = FakeResponse(data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
api = restapi.RESTApi()
data = fake_gopher_tosh
gopher = api.request('POST', fake_url, data=data)
session_mock.return_value.request.assert_called_with(
'POST',
fake_url,
data=json.dumps(data),
)
self.assertEqual(gopher.json(), fake_gopher_single)
def test_create(self, session_mock):
resp = FakeResponse(data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
api = restapi.RESTApi()
data = fake_gopher_mac
# Test no key
gopher = api.create(fake_url, data=data)
session_mock.return_value.request.assert_called_with(
'POST',
fake_url,
data=json.dumps(data),
)
self.assertEqual(gopher, fake_gopher_single)
# Test with key
gopher = api.create(fake_url, data=data, response_key=fake_key)
session_mock.return_value.request.assert_called_with(
'POST',
fake_url,
data=json.dumps(data),
)
self.assertEqual(gopher, fake_gopher_mac)
def test_delete(self, session_mock):
resp = FakeResponse(data=None)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
api = restapi.RESTApi()
gopher = api.delete(fake_url)
session_mock.return_value.request.assert_called_with(
'DELETE',
fake_url,
)
self.assertEqual(gopher, None)
def test_list(self, session_mock):
resp = FakeResponse(data=fake_gopher_list)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
# test base
api = restapi.RESTApi()
gopher = api.list(fake_url, response_key=fake_keys)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
allow_redirects=True,
)
self.assertEqual(gopher, [fake_gopher_mac, fake_gopher_tosh])
# test body
api = restapi.RESTApi()
data = {'qwerty': 1}
gopher = api.list(fake_url, response_key=fake_keys, data=data)
session_mock.return_value.request.assert_called_with(
'POST',
fake_url,
data=json.dumps(data),
)
self.assertEqual(gopher, [fake_gopher_mac, fake_gopher_tosh])
# test query params
api = restapi.RESTApi()
params = {'qaz': '123'}
gophers = api.list(fake_url, response_key=fake_keys, params=params)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
allow_redirects=True,
params=params,
)
self.assertEqual(gophers, [fake_gopher_mac, fake_gopher_tosh])
def test_set(self, session_mock):
new_gopher = fake_gopher_single
new_gopher[fake_key]['name'] = 'Chip'
resp = FakeResponse(data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
api = restapi.RESTApi()
data = fake_gopher_mac
data['name'] = 'Chip'
# Test no data, no key
gopher = api.set(fake_url)
session_mock.return_value.request.assert_called_with(
'PUT',
fake_url,
data=None,
)
self.assertEqual(gopher, None)
# Test data, no key
gopher = api.set(fake_url, data=data)
session_mock.return_value.request.assert_called_with(
'PUT',
fake_url,
data=json.dumps(data),
)
self.assertEqual(gopher, fake_gopher_single)
# NOTE:(dtroyer): Key and no data is not tested as without data
# the response_key is moot
# Test data and key
gopher = api.set(fake_url, data=data, response_key=fake_key)
session_mock.return_value.request.assert_called_with(
'PUT',
fake_url,
data=json.dumps(data),
)
self.assertEqual(gopher, fake_gopher_mac)
def test_show(self, session_mock):
resp = FakeResponse(data=fake_gopher_single)
session_mock.return_value = mock.MagicMock(
request=mock.MagicMock(return_value=resp),
)
api = restapi.RESTApi()
# Test no key
gopher = api.show(fake_url)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
)
self.assertEqual(gopher, fake_gopher_single)
# Test with key
gopher = api.show(fake_url, response_key=fake_key)
session_mock.return_value.request.assert_called_with(
'GET',
fake_url,
)
self.assertEqual(gopher, fake_gopher_mac)

@ -23,6 +23,6 @@ commands = python setup.py testr --coverage --testr-args='{posargs}'
downloadcache = ~/cache/pip downloadcache = ~/cache/pip
[flake8] [flake8]
ignore = E126,E202,W602,H402 ignore = E126,E202,W602,H302,H402
show-source = True show-source = True
exclude = .venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build,tools exclude = .venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build,tools