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 logging
import datetime import datetime
import json import json
from enum import Enum
from app import app, metric_queue 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 import Resource, abort, Api, reqparse
from flask.ext.restful.utils.cors import crossdomain from flask.ext.restful.utils.cors import crossdomain
from calendar import timegm from calendar import timegm
@ -33,7 +34,50 @@ api.decorators = [csrf_protect,
process_oauth, time_decorator(api_bp.name, metric_queue)] 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): 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): def __init__(self, error_type, status_code, error_description, payload=None):
Exception.__init__(self) Exception.__init__(self)
self.error_description = error_description self.error_description = error_description
@ -41,76 +85,80 @@ class ApiException(Exception):
self.payload = payload self.payload = payload
self.error_type = error_type self.error_type = error_type
print(self)
def to_dict(self): def to_dict(self):
rv = dict(self.payload or ()) 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 return rv
class ExternalServiceTimeout(ApiException): class ExternalServiceTimeout(ApiException):
def __init__(self, error_description, payload=None): 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): class InvalidRequest(ApiException):
def __init__(self, error_description, payload=None): 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): class InvalidResponse(ApiException):
def __init__(self, error_description, payload=None): 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): class InvalidToken(ApiException):
def __init__(self, error_description, payload=None): 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): class Unauthorized(ApiException):
def __init__(self, payload=None): def __init__(self, payload=None):
user = get_authenticated_user() user = get_authenticated_user()
if user is None or user.organization: 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: else:
ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload) ApiException.__init__(self, ApiErrorType.insufficient_scope, 403, 'Unauthorized', payload)
class FreshLoginRequired(ApiException): class FreshLoginRequired(ApiException):
def __init__(self, payload=None): 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): class ExceedsLicenseException(ApiException):
def __init__(self, payload=None): 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): class NotFound(ApiException):
def __init__(self, payload=None): 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): class DownstreamIssue(ApiException):
def __init__(self, payload=None): 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) @api_bp.app_errorhandler(ApiException)
@crossdomain(origin='*', headers=['Authorization', 'Content-Type']) @crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
def handle_api_error(error): def handle_api_error(error):
response = jsonify(error.to_dict()) print(error)
response.status_code = error.status_code response = Response(json.dumps(error.to_dict()), error.status_code, mimetype='application/problem+json')
if error.error_type is not None: if error.status_code is 401:
response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' % response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' %
(error.error_type, error.error_description)) (error.error_type, error.error_description))
return response return response
def resource(*urls, **kwargs): def resource(*urls, **kwargs):
def wrapper(api_resource): def wrapper(api_resource):
if not api_resource: if not api_resource:
@ -383,6 +431,8 @@ def request_error(exception=None, **kwargs):
message = 'Request error.' message = 'Request error.'
if exception: if exception:
message = exception.message message = exception.message
if 'message' in data.keys():
message = data.pop('message')
raise InvalidRequest(message, data) raise InvalidRequest(message, data)
@ -427,6 +477,7 @@ def define_json_response(schema_name):
import endpoints.api.billing import endpoints.api.billing
import endpoints.api.build import endpoints.api.build
import endpoints.api.discovery import endpoints.api.discovery
import endpoints.api.error
import endpoints.api.image import endpoints.api.image
import endpoints.api.logs import endpoints.api.logs
import endpoints.api.organization 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. // Handle session expiration.
Restangular.setErrorInterceptor(function(response) { Restangular.setErrorInterceptor(function(response) {
if (response.status == 401 && response.data['error_type'] == 'invalid_token' && var invalid_token = response.data['title'] == 'invalid_token' || response.data['error_type'] == 'invalid_token';
response.data['session_required'] !== false) { if (response.status == 401 && invalid_token && response.data['session_required'] !== false) {
$('#sessionexpiredModal').modal({}); $('#sessionexpiredModal').modal({});
return false; return false;
} }

View file

@ -129,7 +129,7 @@
$scope.showInterface = true; $scope.showInterface = true;
}, function(resp) { }, function(resp) {
$scope.users = []; $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(); var deferred = $q.defer();
// If the error is a fresh login required, show the dialog. // 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() { var retryOperation = function() {
apiService[opName].apply(apiService, opArgs).then(function(resp) { apiService[opName].apply(apiService, opArgs).then(function(resp) {
deferred.resolve(resp); deferred.resolve(resp);
@ -293,7 +294,7 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
apiService.getErrorMessage = function(resp, defaultMessage) { apiService.getErrorMessage = function(resp, defaultMessage) {
var message = defaultMessage; var message = defaultMessage;
if (resp['data']) { 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; return message;

View file

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