Return application/problem+json format errors and provide error endpoint

to dereference error codes.
This commit is contained in:
Evan Cordell 2016-04-11 14:51:58 -04:00
parent 8c81915f38
commit 9c08717173
7 changed files with 156 additions and 39 deletions

View file

@ -1,9 +1,10 @@
import logging
import datetime
import json
from enum import Enum
from app import app, metric_queue
from flask import Blueprint, request, make_response, jsonify, session
from flask import Blueprint, Response, request, make_response, jsonify, session, url_for
from flask.ext.restful import Resource, abort, Api, reqparse
from flask.ext.restful.utils.cors import crossdomain
from calendar import timegm
@ -33,7 +34,50 @@ api.decorators = [csrf_protect,
process_oauth, time_decorator(api_bp.name, metric_queue)]
class ApiErrorType(Enum):
external_service_timeout = 'external_service_timeout'
invalid_request = 'invalid_request'
invalid_response = 'invalid_response'
invalid_token = 'invalid_token'
insufficient_scope = 'insufficient_scope'
fresh_login_required = 'fresh_login_required'
exceeds_license = 'exceeds_license'
not_found = 'not_found'
downstream_issue = 'downstream_issue'
ERROR_DESCRIPTION = {
ApiErrorType.external_service_timeout.value: "An external service timed out. Retrying the request may resolve the issue.",
ApiErrorType.invalid_request.value: "The request was invalid. It may have contained invalid values or was improperly formatted.",
ApiErrorType.invalid_response.value: "The response was invalid.",
ApiErrorType.invalid_token.value: "The access token provided was invalid. It may have expired.",
ApiErrorType.insufficient_scope.value: "The access token did not have sufficient scope to access the requested resource.",
ApiErrorType.fresh_login_required.value: "The action requires a fresh login to succeed.",
ApiErrorType.exceeds_license.value: "The action was refused because the current license does not allow it.",
ApiErrorType.not_found.value: "The resource was not found.",
ApiErrorType.downstream_issue.value: "An error occurred in a downstream service.",
}
class ApiException(Exception):
"""
o "type" (string) - A URI reference that identifies the
problem type.
o "title" (string) - A short, human-readable summary of the problem
type. It SHOULD NOT change from occurrence to occurrence of the
problem, except for purposes of localization
o "status" (number) - The HTTP status code
o "detail" (string) - A human-readable explanation specific to this
occurrence of the problem.
o "instance" (string) - A URI reference that identifies the specific
occurrence of the problem. It may or may not yield further
information if dereferenced.
"""
def __init__(self, error_type, status_code, error_description, payload=None):
Exception.__init__(self)
self.error_description = error_description
@ -41,76 +85,80 @@ class ApiException(Exception):
self.payload = payload
self.error_type = error_type
print(self)
def to_dict(self):
rv = dict(self.payload or ())
if self.error_description is not None:
rv['error_description'] = self.error_description
rv['error_type'] = self.error_type
if self.error_description is not None:
rv['detail'] = self.error_description
rv['title'] = self.error_type.value
rv['type'] = url_for('error', error_type=self.error_type.value, _external=True)
rv['status'] = self.status_code
return rv
class ExternalServiceTimeout(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, 'external_service_timeout', 520, error_description, payload)
ApiException.__init__(self, ApiErrorType.external_service_timeout, 520, error_description, payload)
class InvalidRequest(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, 'invalid_request', 400, error_description, payload)
ApiException.__init__(self, ApiErrorType.invalid_request, 400, error_description, payload)
class InvalidResponse(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, 'invalid_response', 400, error_description, payload)
ApiException.__init__(self, ApiErrorType.invalid_response, 400, error_description, payload)
class InvalidToken(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, 'invalid_token', 401, error_description, payload)
ApiException.__init__(self, ApiErrorType.invalid_token, 401, error_description, payload)
class Unauthorized(ApiException):
def __init__(self, payload=None):
user = get_authenticated_user()
if user is None or user.organization:
ApiException.__init__(self, 'invalid_token', 401, "Requires authentication", payload)
ApiException.__init__(self, ApiErrorType.invalid_token, 401, "Requires authentication", payload)
else:
ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload)
ApiException.__init__(self, ApiErrorType.insufficient_scope, 403, 'Unauthorized', payload)
class FreshLoginRequired(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, 'fresh_login_required', 401, "Requires fresh login", payload)
ApiException.__init__(self, ApiErrorType.fresh_login_required, 401, "Requires fresh login", payload)
class ExceedsLicenseException(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, None, 402, 'Payment Required', payload)
ApiException.__init__(self, ApiErrorType.exceeds_license, 402, 'Payment Required', payload)
class NotFound(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, None, 404, 'Not Found', payload)
ApiException.__init__(self, ApiErrorType.not_found, 404, 'Not Found', payload)
class DownstreamIssue(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, None, 520, 'Downstream Issue', payload)
ApiException.__init__(self, ApiErrorType.downstream_issue, 520, 'Downstream Issue', payload)
@api_bp.app_errorhandler(ApiException)
@crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
def handle_api_error(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
if error.error_type is not None:
print(error)
response = Response(json.dumps(error.to_dict()), error.status_code, mimetype='application/problem+json')
if error.status_code is 401:
response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' %
(error.error_type, error.error_description))
return response
def resource(*urls, **kwargs):
def wrapper(api_resource):
if not api_resource:
@ -383,6 +431,8 @@ def request_error(exception=None, **kwargs):
message = 'Request error.'
if exception:
message = exception.message
if 'message' in data.keys():
message = data.pop('message')
raise InvalidRequest(message, data)
@ -427,6 +477,7 @@ def define_json_response(schema_name):
import endpoints.api.billing
import endpoints.api.build
import endpoints.api.discovery
import endpoints.api.error
import endpoints.api.image
import endpoints.api.logs
import endpoints.api.organization

56
endpoints/api/error.py Normal file
View file

@ -0,0 +1,56 @@
""" Error details API """
from flask import url_for
from enum import Enum
from endpoints.api import (resource, nickname, ApiResource, NotFound, path_param,
define_json_response, ApiErrorType, ERROR_DESCRIPTION)
def error_view(error_type):
return {
'type': url_for('error', error_type=error_type, _external=True),
'title': error_type,
'description': ERROR_DESCRIPTION[error_type]
}
@resource('/v1/error/<error_type>')
@path_param('error_type', 'The error code identifying the type of error.')
class Error(ApiResource):
""" Resource for manging an organization's teams. """
schemas = {
'ApiError': {
'type': 'object',
'description': 'Description of an error',
'required': [
'type',
'properties',
'title',
],
'properties': {
'type': {
'type': 'string',
'description': 'A reference to the error type resource'
},
'title': {
'type': 'string',
'description': 'The title of the error. Can be used to uniquely identify the kind of error.',
'enum': list(ApiErrorType.__members__)
},
'description': {
'type': 'string',
'description': 'A more detailed description of the error that may include help for fixing the issue.'
}
},
},
}
@define_json_response('ApiError')
@nickname('getError')
def get(self, error_type):
""" Get a detailed description of the error """
if error_type in ERROR_DESCRIPTION.keys():
return error_view(error_type), 200
else:
raise NotFound()

View file

@ -211,8 +211,8 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
// Handle session expiration.
Restangular.setErrorInterceptor(function(response) {
if (response.status == 401 && response.data['error_type'] == 'invalid_token' &&
response.data['session_required'] !== false) {
var invalid_token = response.data['title'] == 'invalid_token' || response.data['error_type'] == 'invalid_token';
if (response.status == 401 && invalid_token && response.data['session_required'] !== false) {
$('#sessionexpiredModal').modal({});
return false;
}

View file

@ -129,7 +129,7 @@
$scope.showInterface = true;
}, function(resp) {
$scope.users = [];
$scope.usersError = resp['data']['message'] || resp['data']['error_description'];
$scope.usersError = resp['data']['message'] || resp['data']['error_description'] || resp['data']['detail'];
});
};

View file

@ -124,7 +124,8 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
var deferred = $q.defer();
// If the error is a fresh login required, show the dialog.
if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') {
var fresh_login_required = resp.data['title'] == 'fresh_login_required' || resp.data['error_type'] == 'fresh_login_required';
if (resp.status == 401 && fresh_login_required) {
var retryOperation = function() {
apiService[opName].apply(apiService, opArgs).then(function(resp) {
deferred.resolve(resp);
@ -293,7 +294,7 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
apiService.getErrorMessage = function(resp, defaultMessage) {
var message = defaultMessage;
if (resp['data']) {
message = resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message;
message = resp['data']['detail'] || resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message;
}
return message;

View file

@ -138,7 +138,7 @@ angular.module('quay').factory('UIService', ['$timeout', '$rootScope', '$locatio
};
uiService.showFormError = function(elem, result, opt_placement) {
var message = result.data['message'] || result.data['error_description'] || '';
var message = result.data['message'] || result.data['error_description'] || result.data['detail'] || '';
if (message) {
uiService.showPopover(elem, message, opt_placement || 'bottom');
} else {

View file

@ -44,6 +44,7 @@ from endpoints.api.logs import UserLogs, OrgLogs, OrgAggregateLogs, UserAggregat
from endpoints.api.billing import (UserCard, UserPlan, ListPlans, OrganizationCard,
OrganizationPlan)
from endpoints.api.discovery import DiscoveryResource
from endpoints.api.error import ApiError
from endpoints.api.organization import (OrganizationList, OrganizationMember,
OrgPrivateRepositories, OrganizationMemberList,
Organization, ApplicationInformation,
@ -236,6 +237,14 @@ class TestDiscovery(ApiTestCase):
assert 'paths' in json
class TestError(ApiTestCase):
def test_get_error(self):
json = self.getJsonResponse(APIError, data=dict(error='not_found'))
assert json['title'] == 'not_found'
assert type in json
assert description in json
class TestPlans(ApiTestCase):
def test_plans(self):
json = self.getJsonResponse(ListPlans)
@ -276,7 +285,7 @@ class TestUserStarredRepositoryList(ApiTestCase):
},
expected_code=401)
def test_star_and_unstar_repo_user(self):
def test_star_and_uznstar_repo_user(self):
self.login(READ_ACCESS_USER)
# Queries: Base + the list query
@ -346,7 +355,7 @@ class TestGetUserPrivateAllowed(ApiTestCase):
assert json['privateCount'] == 0
assert not json['privateAllowed']
def test_allowed(self):
def test_allowedz(self):
self.login(ADMIN_ACCESS_USER)
# Change the subscription of the namespace.
@ -372,7 +381,7 @@ class TestConvertToOrganization(ApiTestCase):
'plan': 'free'},
expected_code=400)
self.assertEqual('The admin user is not valid', json['message'])
self.assertEqual('The admin user is not valid', json['detail'])
def test_sameadminuser_by_email(self):
self.login(READ_ACCESS_USER)
@ -382,7 +391,7 @@ class TestConvertToOrganization(ApiTestCase):
'plan': 'free'},
expected_code=400)
self.assertEqual('The admin user is not valid', json['message'])
self.assertEqual('The admin user is not valid', json['detail'])
def test_invalidadminuser(self):
self.login(READ_ACCESS_USER)
@ -393,7 +402,7 @@ class TestConvertToOrganization(ApiTestCase):
expected_code=400)
self.assertEqual('The admin user credentials are not valid',
json['message'])
json['detail'])
def test_invalidadminpassword(self):
self.login(READ_ACCESS_USER)
@ -404,7 +413,7 @@ class TestConvertToOrganization(ApiTestCase):
expected_code=400)
self.assertEqual('The admin user credentials are not valid',
json['message'])
json['detail'])
def test_convert(self):
self.login(READ_ACCESS_USER)
@ -489,7 +498,7 @@ class TestCreateNewUser(ApiTestCase):
email='test@example.com'),
expected_code=400)
self.assertEquals('The username already exists', json['message'])
self.assertEquals('The username already exists', json['detail'])
def test_trycreatetooshort(self):
json = self.postJsonResponse(User,
@ -499,7 +508,7 @@ class TestCreateNewUser(ApiTestCase):
expected_code=400)
self.assertEquals('Invalid username a: Username must be between 4 and 30 characters in length',
json['error_description'])
json['detail'])
def test_trycreateregexmismatch(self):
json = self.postJsonResponse(User,
@ -509,7 +518,7 @@ class TestCreateNewUser(ApiTestCase):
expected_code=400)
self.assertEquals('Invalid username auserName: Username must match expression [a-z0-9_]+',
json['error_description'])
json['detail'])
def test_createuser(self):
data = self.postJsonResponse(User, data=NEW_USER_DETAILS, expected_code=200)
@ -740,7 +749,7 @@ class TestCreateOrganization(ApiTestCase):
expected_code=400)
self.assertEquals('A user or organization with this name already exists',
json['message'])
json['detail'])
def test_existingorg(self):
self.login(ADMIN_ACCESS_USER)
@ -750,7 +759,7 @@ class TestCreateOrganization(ApiTestCase):
expected_code=400)
self.assertEquals('A user or organization with this name already exists',
json['message'])
json['detail'])
def test_createorg(self):
self.login(ADMIN_ACCESS_USER)
@ -845,7 +854,7 @@ class TestCreateOrganizationPrototypes(ApiTestCase):
delegate={'kind': 'team', 'name': 'owners'}),
expected_code=400)
self.assertEquals('Unknown activating user', json['message'])
self.assertEquals('Unknown activating user', json['detail'])
def test_missingdelegate(self):
@ -1330,7 +1339,7 @@ class TestCreateRepo(ApiTestCase):
description=''),
expected_code=400)
self.assertEquals('Invalid repository name', json['error_description'])
self.assertEquals('Invalid repository name', json['detail'])
def test_duplicaterepo(self):
self.login(ADMIN_ACCESS_USER)
@ -1341,7 +1350,7 @@ class TestCreateRepo(ApiTestCase):
description=''),
expected_code=400)
self.assertEquals('Repository already exists', json['message'])
self.assertEquals('Repository already exists', json['detail'])
def test_createrepo(self):