REST API for angular front end.
This is the start of the API to support the angular front end. It is missing endpoints not immediately used by angular WIPs other than the identity re-work, but is enough to start with. Changes: - handle additional HTTP status code attribute - move common helpers from test modules to here Partially Implements: blueprint angularize-identity-tables Change-Id: I7495f772be80125fdf52b02883a5b9942db34610
This commit is contained in:
parent
fe46feed7e
commit
cd735d44f3
24
openstack_dashboard/api/rest/__init__.py
Normal file
24
openstack_dashboard/api/rest/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Copyright 2014, Rackspace, US, 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.
|
||||
"""This package holds the REST API that supports the Horizon dashboard
|
||||
Javascript code.
|
||||
|
||||
It is not intended to be used outside of Horizon, and makes no promises of
|
||||
stability or fitness for purpose outside of that scope.
|
||||
|
||||
It does not promise to adhere to the general OpenStack API Guidelines set out
|
||||
in https://wiki.openstack.org/wiki/APIChangeGuidelines.
|
||||
"""
|
||||
|
||||
# import REST API modules here
|
33
openstack_dashboard/api/rest/urls.py
Normal file
33
openstack_dashboard/api/rest/urls.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Copyright 2014, Rackspace, US, 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.
|
||||
from django.conf import urls
|
||||
|
||||
urlpatterns = []
|
||||
|
||||
|
||||
# to register the URLs for your API endpoints, decorate the view class with
|
||||
# @register below, and the import the endpoint module in the
|
||||
# rest_api/__init__.py module
|
||||
def register(view):
|
||||
'''Register API views to respond to a regex pattern (url_regex on the
|
||||
view class).
|
||||
|
||||
The view should be a standard Django class-based view implementing an
|
||||
as_view() method. The url_regex attribute of the view should be a standard
|
||||
Django URL regex pattern.
|
||||
'''
|
||||
p = urls.url(view.url_regex, view.as_view())
|
||||
p.add_prefix('openstack_dashboard.rest_api')
|
||||
urlpatterns.append(p)
|
||||
return view
|
143
openstack_dashboard/api/rest/utils.py
Normal file
143
openstack_dashboard/api/rest/utils.py
Normal file
@ -0,0 +1,143 @@
|
||||
# Copyright 2014, Rackspace, US, 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.
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django import http
|
||||
from django.utils import decorators
|
||||
|
||||
from oslo.serialization import jsonutils
|
||||
|
||||
from horizon import exceptions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AjaxError(Exception):
|
||||
def __init__(self, http_status, msg):
|
||||
self.http_status = http_status
|
||||
super(AjaxError, self).__init__(msg)
|
||||
|
||||
http_errors = exceptions.UNAUTHORIZED + exceptions.NOT_FOUND + \
|
||||
exceptions.RECOVERABLE + (AjaxError, )
|
||||
|
||||
|
||||
class CreatedResponse(http.HttpResponse):
|
||||
def __init__(self, location, data=None):
|
||||
if data is not None:
|
||||
content = jsonutils.dumps(data, sort_keys=settings.DEBUG)
|
||||
content_type = 'application/json'
|
||||
else:
|
||||
content = ''
|
||||
content_type = None
|
||||
super(CreatedResponse, self).__init__(status=201, content=content,
|
||||
content_type=content_type)
|
||||
self['Location'] = location
|
||||
|
||||
|
||||
class JSONResponse(http.HttpResponse):
|
||||
def __init__(self, data, status=200):
|
||||
if status == 204:
|
||||
content = ''
|
||||
else:
|
||||
content = jsonutils.dumps(data, sort_keys=settings.DEBUG)
|
||||
|
||||
super(JSONResponse, self).__init__(
|
||||
status=status,
|
||||
content=content,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
|
||||
def ajax(authenticated=True, method=None):
|
||||
'''Provide a decorator to wrap a view method so that it may exist in an
|
||||
entirely AJAX environment:
|
||||
|
||||
- data decoded from JSON as input and data coded as JSON as output
|
||||
- result status is coded in the HTTP status code; any non-2xx response
|
||||
data will be coded as a JSON string, otherwise the response type (always
|
||||
JSON) is specific to the method called.
|
||||
|
||||
if authenticated is true then we'll make sure the current user is
|
||||
authenticated.
|
||||
|
||||
If method='POST' then we'll assert that there is a JSON body
|
||||
present with the minimum attributes of "action" and "data".
|
||||
|
||||
If method='PUT' then we'll assert that there is a JSON body
|
||||
present.
|
||||
|
||||
The wrapped view method should return either:
|
||||
|
||||
- JSON serialisable data
|
||||
- an object of the django http.HttpResponse subclass (one of JSONResponse
|
||||
or CreatedResponse is suggested)
|
||||
- nothing
|
||||
|
||||
Methods returning nothing (or None explicitly) will result in a 204 "NO
|
||||
CONTENT" being returned to the caller.
|
||||
'''
|
||||
def decorator(function, authenticated=authenticated, method=method):
|
||||
@functools.wraps(function,
|
||||
assigned=decorators.available_attrs(function))
|
||||
def _wrapped(self, request, *args, **kw):
|
||||
if authenticated and not request.user.is_authenticated():
|
||||
return JSONResponse('not logged in', 401)
|
||||
if not request.is_ajax():
|
||||
return JSONResponse('request must be AJAX', 400)
|
||||
|
||||
# decode the JSON body if present
|
||||
request.DATA = None
|
||||
if request.body:
|
||||
try:
|
||||
request.DATA = json.loads(request.body)
|
||||
except (TypeError, ValueError) as e:
|
||||
return JSONResponse('malformed JSON request: %s' % e, 400)
|
||||
|
||||
# if we're wrapping a POST action then ensure the action/data
|
||||
# expected parameters are present
|
||||
if method == 'POST':
|
||||
if not request.DATA:
|
||||
return JSONResponse('POST requires JSON body', 400)
|
||||
if 'action' not in request.DATA or 'data' not in request.DATA:
|
||||
return JSONResponse('POST JSON missing action/data', 400)
|
||||
|
||||
# if we're wrapping a PUT action then ensure there's JSON data
|
||||
if method == 'PUT':
|
||||
if not request.DATA:
|
||||
return JSONResponse('PUT requires JSON body', 400)
|
||||
|
||||
# invoke the wrapped function, handling exceptions sanely
|
||||
try:
|
||||
data = function(self, request, *args, **kw)
|
||||
if isinstance(data, http.HttpResponse):
|
||||
return data
|
||||
elif data is None:
|
||||
return JSONResponse('', status=204)
|
||||
return JSONResponse(data)
|
||||
except http_errors as e:
|
||||
# exception was raised with a specific HTTP status
|
||||
if hasattr(e, 'http_status'):
|
||||
http_status = e.http_status
|
||||
else:
|
||||
http_status = e.code
|
||||
return JSONResponse(str(e), http_status)
|
||||
except Exception as e:
|
||||
log.exception('error invoking apiclient')
|
||||
return JSONResponse(str(e), 500)
|
||||
|
||||
return _wrapped
|
||||
return decorator
|
29
openstack_dashboard/test/api_tests/rest_test_utils.py
Normal file
29
openstack_dashboard/test/api_tests/rest_test_utils.py
Normal file
@ -0,0 +1,29 @@
|
||||
# Copyright 2014, Rackspace, US, 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.
|
||||
import mock
|
||||
|
||||
|
||||
def mock_obj_to_dict(r):
|
||||
return mock.Mock(**{'to_dict.return_value': r})
|
||||
|
||||
|
||||
def construct_request(**args):
|
||||
mock_args = {
|
||||
'user.is_authenticated.return_value': True,
|
||||
'is_ajax.return_value': True,
|
||||
'policy.check.return_value': True,
|
||||
'body': ''
|
||||
}
|
||||
mock_args.update(args)
|
||||
return mock.Mock(**mock_args)
|
182
openstack_dashboard/test/api_tests/rest_util_tests.py
Normal file
182
openstack_dashboard/test/api_tests/rest_util_tests.py
Normal file
@ -0,0 +1,182 @@
|
||||
# Copyright 2014, Rackspace, US, 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.
|
||||
import mock
|
||||
import unittest2
|
||||
|
||||
from openstack_dashboard.api.rest import utils
|
||||
|
||||
|
||||
class RestUtilsTestCase(unittest2.TestCase):
|
||||
def assertStatusCode(self, response, expected_code):
|
||||
if response.status_code == expected_code:
|
||||
return
|
||||
self.fail('status code %r != %r: %s' % (response.status_code,
|
||||
expected_code,
|
||||
response.content))
|
||||
|
||||
def _construct_request(self, **args):
|
||||
mock_args = {
|
||||
'user.is_authenticated.return_value': True,
|
||||
'is_ajax.return_value': True,
|
||||
'policy.check.return_value': True,
|
||||
'body': ''
|
||||
}
|
||||
mock_args.update(args)
|
||||
return mock.Mock(**mock_args)
|
||||
|
||||
def test_api_success(self):
|
||||
@utils.ajax()
|
||||
def f(self, request):
|
||||
return 'ok'
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
request.is_authenticated.assert_called_once()
|
||||
self.assertStatusCode(response, 200)
|
||||
self.assertEqual(response.content, '"ok"')
|
||||
|
||||
def test_api_success_no_auth_ok(self):
|
||||
@utils.ajax(authenticated=False)
|
||||
def f(self, request):
|
||||
return 'ok'
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
request.is_authenticated.assert_not_called_once()
|
||||
self.assertStatusCode(response, 200)
|
||||
self.assertEqual(response.content, '"ok"')
|
||||
|
||||
def test_api_auth_required(self):
|
||||
@utils.ajax()
|
||||
def f(self, request):
|
||||
return 'ok'
|
||||
request = self._construct_request(**{
|
||||
'user.is_authenticated.return_value': False
|
||||
})
|
||||
response = f(None, request)
|
||||
request.is_authenticated.assert_called_once()
|
||||
self.assertStatusCode(response, 401)
|
||||
self.assertEqual(response.content, '"not logged in"')
|
||||
|
||||
def test_api_success_204(self):
|
||||
@utils.ajax()
|
||||
def f(self, request):
|
||||
pass
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 204)
|
||||
self.assertEqual(response.content, '')
|
||||
|
||||
def test_api_error(self):
|
||||
@utils.ajax()
|
||||
def f(self, request):
|
||||
raise utils.AjaxError(500, 'b0rk')
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 500)
|
||||
self.assertEqual(response.content, '"b0rk"')
|
||||
|
||||
def test_api_malformed_json(self):
|
||||
@utils.ajax()
|
||||
def f(self, request):
|
||||
assert False, "don't get here"
|
||||
request = self._construct_request(**{'body': 'spam'})
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 400)
|
||||
self.assertEqual(response.content, '"malformed JSON request: No JSON '
|
||||
'object could be decoded"')
|
||||
|
||||
def test_api_not_found(self):
|
||||
@utils.ajax()
|
||||
def f(self, request):
|
||||
raise utils.AjaxError(404, 'b0rk')
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 404)
|
||||
self.assertEqual(response.content, '"b0rk"')
|
||||
|
||||
def test_post_with_no_data(self):
|
||||
@utils.ajax(method='POST')
|
||||
def f(self, request):
|
||||
assert False, "don't get here"
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 400)
|
||||
self.assertEqual(response.content, '"POST requires JSON body"')
|
||||
|
||||
def test_post_with_no_post_action(self):
|
||||
self._test_bad_post('data')
|
||||
|
||||
def test_post_with_no_post_data(self):
|
||||
self._test_bad_post('action')
|
||||
|
||||
def _test_bad_post(self, arg):
|
||||
@utils.ajax(method='POST')
|
||||
def f(self, request):
|
||||
assert False, "don't get here"
|
||||
request = self._construct_request(**{'body': '{"%s": true}' % arg})
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 400)
|
||||
self.assertEqual(response.content, '"POST JSON missing action/data"')
|
||||
|
||||
def test_valid_post(self):
|
||||
@utils.ajax(method='POST')
|
||||
def f(self, request):
|
||||
return 'OK'
|
||||
request = self._construct_request(**{'body': '''
|
||||
{"action": true, "data": true}
|
||||
'''})
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 200)
|
||||
self.assertEqual(response.content, '"OK"')
|
||||
|
||||
def test_put_with_no_data(self):
|
||||
@utils.ajax(method='PUT')
|
||||
def f(self, request):
|
||||
assert False, "don't get here"
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 400)
|
||||
self.assertEqual(response.content, '"PUT requires JSON body"')
|
||||
|
||||
def test_valid_put(self):
|
||||
@utils.ajax(method='PUT')
|
||||
def f(self, request):
|
||||
return 'OK'
|
||||
request = self._construct_request(**{'body': '''
|
||||
{"current": true, "update": true}
|
||||
'''})
|
||||
response = f(None, request)
|
||||
self.assertStatusCode(response, 200)
|
||||
self.assertEqual(response.content, '"OK"')
|
||||
|
||||
def test_api_created_response(self):
|
||||
@utils.ajax()
|
||||
def f(self, request):
|
||||
return utils.CreatedResponse('/api/spam/spam123')
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
request.is_authenticated.assert_called_once()
|
||||
self.assertStatusCode(response, 201)
|
||||
self.assertEqual(response['location'], '/api/spam/spam123')
|
||||
self.assertEqual(response.content, '')
|
||||
|
||||
def test_api_created_response_content(self):
|
||||
@utils.ajax()
|
||||
def f(self, request):
|
||||
return utils.CreatedResponse('/api/spam/spam123', 'spam!')
|
||||
request = self._construct_request()
|
||||
response = f(None, request)
|
||||
request.is_authenticated.assert_called_once()
|
||||
self.assertStatusCode(response, 201)
|
||||
self.assertEqual(response['location'], '/api/spam/spam123')
|
||||
self.assertEqual(response.content, '"spam!"')
|
@ -29,12 +29,12 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns # noqa
|
||||
|
||||
import horizon
|
||||
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^$', 'openstack_dashboard.views.splash', name='splash'),
|
||||
url(r'^auth/', include('openstack_auth.urls')),
|
||||
url(r'', include(horizon.urls))
|
||||
url(r'^api/', include('openstack_dashboard.api.rest.urls')),
|
||||
url(r'', include(horizon.urls)),
|
||||
)
|
||||
|
||||
# Development static app and project media serving using the staticfiles app.
|
||||
|
Loading…
Reference in New Issue
Block a user