Significantly improve the error code.
This commit is contained in:
parent
d362883d72
commit
4aff4a8973
2
Makefile
2
Makefile
|
@ -20,7 +20,7 @@ test: test-api test-webapp
|
||||||
|
|
||||||
# Runs api tests
|
# Runs api tests
|
||||||
test-api: stop build-api
|
test-api: stop build-api
|
||||||
docker-compose up -d db api
|
docker-compose up -d --build db api
|
||||||
docker-compose run api inv test.style
|
docker-compose run api inv test.style
|
||||||
docker-compose run api \
|
docker-compose run api \
|
||||||
inv test.server --db-connection=$(DB_CONNECTION) --server-host=api:5000 --verbosity=3
|
inv test.server --db-connection=$(DB_CONNECTION) --server-host=api:5000 --verbosity=3
|
||||||
|
|
|
@ -21,7 +21,7 @@ def make_rookeries_app():
|
||||||
"""Creates and setups a Rookeries webapp."""
|
"""Creates and setups a Rookeries webapp."""
|
||||||
logger.info("*** Rookeries API Server - version %s ***", __version__)
|
logger.info("*** Rookeries API Server - version %s ***", __version__)
|
||||||
logger.info("Starting Rookeries API server...")
|
logger.info("Starting Rookeries API server...")
|
||||||
web_app = app.make_json_app(__name__)
|
web_app = app.make_api_app(__name__)
|
||||||
web_app.register_blueprint(main.rookeries_app)
|
web_app.register_blueprint(main.rookeries_app)
|
||||||
|
|
||||||
logger.info("Configuring Rookeries:")
|
logger.info("Configuring Rookeries:")
|
||||||
|
|
|
@ -5,54 +5,14 @@ Creates and configures the Rookeries Flask app
|
||||||
:license: AGPL v3+
|
:license: AGPL v3+
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import http
|
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
from werkzeug import exceptions
|
|
||||||
|
|
||||||
from rookeries import database, security
|
from rookeries import database, errors, security
|
||||||
|
|
||||||
|
|
||||||
def make_json_app(import_name, **kwargs):
|
def make_api_app(import_name, **kwargs):
|
||||||
"""Creates a JSON-oriented Flask app.
|
|
||||||
|
|
||||||
All error responses that you don't specifically manage yourself will have a application/json content type, and
|
|
||||||
will contain JSON in following format: ::
|
|
||||||
|
|
||||||
{ "error": {
|
|
||||||
"code": 405,
|
|
||||||
"message": "Method Not Allowed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Originally Pavel Repin's `snippet receipe for JSON response apps <http://flask.pocoo.org/snippets/83/>`_.
|
|
||||||
|
|
||||||
|
|
||||||
:param import_name: The name of the app to import.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def make_json_error(error):
|
|
||||||
"""
|
|
||||||
Make a JSON error response.
|
|
||||||
|
|
||||||
:param error: The error message to turn into JSON.
|
|
||||||
"""
|
|
||||||
error_message = error.name if isinstance(error, exceptions.HTTPException) else str(error)
|
|
||||||
error_code = http.HTTPStatus.INTERNAL_SERVER_ERROR
|
|
||||||
if isinstance(error, exceptions.HTTPException):
|
|
||||||
error_code = error.code
|
|
||||||
error_json = {'error': {'code': error_code, 'message': error_message}}
|
|
||||||
|
|
||||||
response = flask.jsonify(error_json)
|
|
||||||
response.status_code = error_code
|
|
||||||
return response
|
|
||||||
|
|
||||||
api_app = flask.Flask(import_name, **kwargs)
|
api_app = flask.Flask(import_name, **kwargs)
|
||||||
|
errors.attach_error_handlers_to_api_app(api_app)
|
||||||
for code in iter(exceptions.default_exceptions):
|
|
||||||
api_app.register_error_handler(code, make_json_error)
|
|
||||||
# TODO: Better handling of unexpected errors.
|
|
||||||
|
|
||||||
database.SQLAlchemy.init_app(api_app)
|
database.SQLAlchemy.init_app(api_app)
|
||||||
security.jwt.init_app(api_app)
|
security.jwt.init_app(api_app)
|
||||||
return api_app
|
return api_app
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""
|
||||||
|
Error handlers for the API.
|
||||||
|
|
||||||
|
:copyright: Copyright 2013-2017, Dorian Pula <dorian.pula@amber-penguin-software.ca>
|
||||||
|
:license: AGPL v3+
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import flask
|
||||||
|
from werkzeug import exceptions
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
HTTP_ERRORS_WITH_PATHS = [http.HTTPStatus.NOT_FOUND, http.HTTPStatus.UNAUTHORIZED]
|
||||||
|
HTTP_ERRORS_WITHOUT_PATHS = [
|
||||||
|
http_code
|
||||||
|
for http_code in exceptions.default_exceptions
|
||||||
|
if http_code not in HTTP_ERRORS_WITH_PATHS
|
||||||
|
]
|
||||||
|
|
||||||
|
MESSAGE_FOR_STATUS = {
|
||||||
|
http.HTTPStatus.NOT_FOUND.value: 'Resource not found.',
|
||||||
|
http.HTTPStatus.UNAUTHORIZED.value: 'Not authorized to access this resource.',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_message_for_status(http_code):
|
||||||
|
try:
|
||||||
|
http_status = http.HTTPStatus(http_code)
|
||||||
|
except ValueError:
|
||||||
|
http_status = http.HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
return MESSAGE_FOR_STATUS.get(http_status, http_status.description)
|
||||||
|
|
||||||
|
|
||||||
|
def error_message_from_http_code_with_resource(error):
|
||||||
|
error_message = {
|
||||||
|
'error': {
|
||||||
|
'status_code': error.code,
|
||||||
|
'message': get_message_for_status(error.code),
|
||||||
|
'resource': flask.request.url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return flask.make_response(flask.jsonify(error_message), error.code)
|
||||||
|
|
||||||
|
|
||||||
|
def error_message_from_http_code(error):
|
||||||
|
error_message = {
|
||||||
|
'error': {
|
||||||
|
'status_code': error.code,
|
||||||
|
'message': get_message_for_status(error.code),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return flask.make_response(flask.jsonify(error_message), error.code)
|
||||||
|
|
||||||
|
|
||||||
|
def error_message_from_exception(error: Exception):
|
||||||
|
error_message = {
|
||||||
|
'error': {
|
||||||
|
'status_code': http.HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
'message': f'{error}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return flask.make_response(flask.jsonify(error_message), http.HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
def attach_error_handlers_to_api_app(app):
|
||||||
|
for http_code in HTTP_ERRORS_WITH_PATHS:
|
||||||
|
app.register_error_handler(http_code, error_message_from_http_code_with_resource)
|
||||||
|
|
||||||
|
for http_code in HTTP_ERRORS_WITHOUT_PATHS:
|
||||||
|
app.register_error_handler(http_code, error_message_from_http_code)
|
||||||
|
|
||||||
|
app.register_error_handler(Exception, error_message_from_exception)
|
|
@ -68,8 +68,9 @@ def test_serve_landing_page_view_returns_about_page(api_base_uri, test_page):
|
||||||
def test_serve_page_view_returns_404_when_no_content_found(api_base_uri, test_page):
|
def test_serve_page_view_returns_404_when_no_content_found(api_base_uri, test_page):
|
||||||
expected_json = {
|
expected_json = {
|
||||||
'error': {
|
'error': {
|
||||||
'code': 404,
|
'status_code': 404,
|
||||||
'message': 'Not Found'
|
'message': 'Resource not found.',
|
||||||
|
'resource': f'{api_base_uri}/api/pages/missing',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,13 @@ def editor_user(db_engine):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='module')
|
||||||
|
def non_existent_user():
|
||||||
|
return {
|
||||||
|
'username': 'does-not-exist',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
SAMPLE_USERS_REQUEST = {
|
SAMPLE_USERS_REQUEST = {
|
||||||
'admin': {},
|
'admin': {},
|
||||||
'editor': {},
|
'editor': {},
|
||||||
|
@ -82,6 +89,11 @@ def test_editor_user_fetch_by_admin():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@bdd.scenario('user_management.feature', 'Admin user can not get an non-existent user')
|
||||||
|
def test_non_existent_user_fetch_by_admin():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@mark.skip(reason="Test scenarios need work")
|
@mark.skip(reason="Test scenarios need work")
|
||||||
@bdd.scenario('user_management.feature', 'Any user can not get an existing admin user')
|
@bdd.scenario('user_management.feature', 'Any user can not get an existing admin user')
|
||||||
def test_admin_user_fetch_by_anyone():
|
def test_admin_user_fetch_by_anyone():
|
||||||
|
@ -154,11 +166,12 @@ def test_editor_user_deletion_by_editor():
|
||||||
def jwt_token(user_role, api_base_uri, admin_user, editor_user):
|
def jwt_token(user_role, api_base_uri, admin_user, editor_user):
|
||||||
|
|
||||||
# TODO: Improve selection of fixtures.
|
# TODO: Improve selection of fixtures.
|
||||||
user_info = None
|
|
||||||
if user_role == models.UserRole.admin.name:
|
if user_role == models.UserRole.admin.name:
|
||||||
user_info = admin_user
|
user_info = admin_user
|
||||||
elif user_role == models.UserRole.editor.name:
|
elif user_role == models.UserRole.editor.name:
|
||||||
user_info = editor_user
|
user_info = editor_user
|
||||||
|
else:
|
||||||
|
user_info = non_existent_user
|
||||||
|
|
||||||
jwt_token = requests.post(
|
jwt_token = requests.post(
|
||||||
url=f'{api_base_uri}/auth',
|
url=f'{api_base_uri}/auth',
|
||||||
|
@ -185,13 +198,14 @@ def create_user_response(user_role, jwt_token, api_base_uri):
|
||||||
|
|
||||||
|
|
||||||
@bdd.given(parsers.parse('I get an {user_role} user'))
|
@bdd.given(parsers.parse('I get an {user_role} user'))
|
||||||
def get_user_response(user_role, jwt_token, api_base_uri, admin_user, editor_user):
|
def get_user_response(user_role, jwt_token, api_base_uri, admin_user, editor_user, non_existent_user):
|
||||||
|
|
||||||
test_user = None
|
|
||||||
if user_role == models.UserRole.admin.name:
|
if user_role == models.UserRole.admin.name:
|
||||||
test_user = admin_user
|
test_user = admin_user
|
||||||
elif user_role == models.UserRole.editor.name:
|
elif user_role == models.UserRole.editor.name:
|
||||||
test_user = editor_user
|
test_user = editor_user
|
||||||
|
else:
|
||||||
|
test_user = non_existent_user
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
url=f'{api_base_uri}/api/users/{test_user["username"]}',
|
url=f'{api_base_uri}/api/users/{test_user["username"]}',
|
||||||
|
@ -230,8 +244,21 @@ def assert_unauthorized_response(get_user_response: requests.Response):
|
||||||
|
|
||||||
expected_response_json = {
|
expected_response_json = {
|
||||||
'status_code': http.HTTPStatus.UNAUTHORIZED,
|
'status_code': http.HTTPStatus.UNAUTHORIZED,
|
||||||
'error': 'Unauthorized',
|
'message': 'Not authorized to access this resource.',
|
||||||
'description': 'Not authorized to access this resource.',
|
|
||||||
'resource': get_user_response.request.url,
|
'resource': get_user_response.request.url,
|
||||||
}
|
}
|
||||||
assert get_user_response.json() == expected_response_json
|
assert get_user_response.json() == expected_response_json
|
||||||
|
|
||||||
|
|
||||||
|
@bdd.then(parsers.parse('I can get a user can not be found message'))
|
||||||
|
def assert_unauthorized_response(get_user_response: requests.Response):
|
||||||
|
assert get_user_response.status_code == http.HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
expected_response_json = {
|
||||||
|
'error': {
|
||||||
|
'status_code': http.HTTPStatus.NOT_FOUND.value,
|
||||||
|
'message': 'Resource not found.',
|
||||||
|
'resource': get_user_response.request.url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert get_user_response.json() == expected_response_json
|
||||||
|
|
|
@ -34,6 +34,11 @@ Scenario: Admin user can get an existing editor user
|
||||||
And I get an editor user
|
And I get an editor user
|
||||||
Then I can get an editor user profile
|
Then I can get an editor user profile
|
||||||
|
|
||||||
|
Scenario: Admin user can not get an non-existent user
|
||||||
|
Given I am an admin user
|
||||||
|
And I get an non-existent user
|
||||||
|
Then I can get a user can not be found message
|
||||||
|
|
||||||
Scenario: Any user can not get an existing admin user
|
Scenario: Any user can not get an existing admin user
|
||||||
Given I am an unauthenticated user
|
Given I am an unauthenticated user
|
||||||
And I get an admin user
|
And I get an admin user
|
||||||
|
|
Loading…
Reference in New Issue