Merge lp:~kelvin.li/canonical-identity-provider/error_normalization_tokens_discharge into lp:canonical-identity-provider/release
- error_normalization_tokens_discharge
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Kelvin Li |
Approved revision: | no longer in the source branch. |
Merge reported by: | Ubuntu One Auto Pilot |
Merged at revision: | not available |
Proposed branch: | lp:~kelvin.li/canonical-identity-provider/error_normalization_tokens_discharge |
Merge into: | lp:canonical-identity-provider/release |
Diff against target: |
582 lines (+319/-59) 4 files modified
docs/api.txt (+116/-0) src/api/v20/handlers.py (+28/-24) src/api/v20/tests/test_handlers.py (+22/-5) src/api/v20/utils.py (+153/-30) |
To merge this branch: | bzr merge lp:~kelvin.li/canonical-identity-provider/error_normalization_tokens_discharge |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Facundo Batista (community) | Approve | ||
Review via email: mp+303519@code.launchpad.net |
Commit message
update the snap related 2 endpoints to use the normalization error format.
Description of the change
update the snap related 2 endpoints to use the normalization error format.
Kelvin Li (kelvin.li) wrote : | # |
Thanks for the review, after putting the general format assertion in the test cases,
I fixed the related new issues in test cases. also applied your comments into the code.
Facundo Batista (facundo) wrote : | # |
Just for completeness, commenting here what we discussed in IRC: the "code" in the error_list items must not be the current ones, but some specific codes
Kelvin Li (kelvin.li) wrote : | # |
updated the standard error code in the response and related api doc.
Facundo Batista (facundo) wrote : | # |
As discussed in IRC: please include only the errors that SSO is using, and make the relationship between each error and its code something of the ERRORS structure itself.
Thanks!
Kelvin Li (kelvin.li) wrote : | # |
1. Clean the standard error codes to keep the ones SSO is using;
2. Update the API doc;
3. Move the error code into the ERRORS dict to create the relation mapping to the old code;
Facundo Batista (facundo) wrote : | # |
A couple of inline questions, thanks!
Kelvin Li (kelvin.li) wrote : | # |
Hi, Please see my replies in below inline.
Facundo Batista (facundo) wrote : | # |
Looks fine, thanks!
Preview Diff
1 | === modified file 'docs/api.txt' |
2 | --- docs/api.txt 2015-10-22 17:07:12 +0000 |
3 | +++ docs/api.txt 2016-09-02 11:35:34 +0000 |
4 | @@ -97,3 +97,119 @@ |
5 | and a reason why they failed. |
6 | |
7 | See the relevant documentation for errors specific to each api. |
8 | + |
9 | + |
10 | +New format Errors |
11 | +================= |
12 | + |
13 | +The *Snap Packages API* uses conventional HTTP response codes to indicate |
14 | +success or failure of an API request. |
15 | + |
16 | +In general, codes in the 2xx range indicate success, codes in the 4xx range |
17 | +indicate an error that resulted from the provided information (e.g. a |
18 | +required parameter was missing) and codes in the 5xx range indicate an |
19 | +error with our servers. |
20 | + |
21 | +Here is detailed the format for API responses that end in error. |
22 | + |
23 | +This applies to all the 4xx responses, but also to some 5xx ones (if |
24 | +possible, the client should be prepared to handle 5xx responses with no |
25 | +informational body). Note that this structure format does not apply to 2xx and |
26 | +3xx responses, as those are note errors. |
27 | + |
28 | +.. important:: |
29 | + |
30 | + Not all API endpoints are migrated yet to this new error format |
31 | + |
32 | + |
33 | +Format |
34 | +------ |
35 | + |
36 | +An error response body will contain the following field: |
37 | + |
38 | +- ``error_list``: a list of one or several items (never empty), each item |
39 | + described by... |
40 | + |
41 | + - ``message``: a text in English describing the error that happened, |
42 | + ready to show to the user. |
43 | + |
44 | + - ``code``: a short (but representative) string indicating concisely the |
45 | + error; it's aimed for clients to take specific actions and react to the |
46 | + problem. See below for the list of existing codes. |
47 | + |
48 | + |
49 | +Additionally and for backwards compatibility reasons, some other fields may |
50 | +be present as well, but are considered deprecated and will be removed in |
51 | +the near future. |
52 | + |
53 | +No status or success indication is returned inside the response body, the |
54 | +client should react properly to the received HTTP return code according to |
55 | +its `well stablished semantics |
56 | +<http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html>`_. |
57 | + |
58 | + |
59 | +Codes |
60 | +----- |
61 | + |
62 | +These are the codes used in the response and their meanings: |
63 | + |
64 | +- ``account-not-ready``: the account is deactivated, suspended or account email |
65 | + is not valildated. |
66 | + |
67 | +- ``bad-request``: there is a problem in the structure of the request. |
68 | + |
69 | +- ``internal-server-error``: some unexpected problem server side; this will be |
70 | + the code in all 5xx cases. |
71 | + |
72 | +- ``invalid-credentials``: the credentials for the authentication are not valid |
73 | + (e.g.: the username, password are not correct, the 2 factor is needed, etc). |
74 | + |
75 | +- ``invalid-data``: the data is incorrect or corrupt. |
76 | + |
77 | +- ``permission-required``: the macaroon authorization is missing in |
78 | + the received request or not enough for it to be fulfilled. |
79 | + |
80 | +- ``required``: the field in the request can't be empty or null. |
81 | + |
82 | +- ``resource-not-found``: one or more fields are included to specify a |
83 | + resource, but it is not found in the Store. |
84 | + |
85 | +- ``resource-not-ready``: the request actions on a resource that is not |
86 | + ready yet for that purpose; normally something else would need to be done |
87 | + first on the resource before this request can be repeated. |
88 | + |
89 | +- ``user-not-ready``: the user is not ready to issue the received request; |
90 | + normally some actions would need to be done in the user account before |
91 | + repeating the request. |
92 | + |
93 | + |
94 | +Examples |
95 | +-------- |
96 | + |
97 | +A simple error: |
98 | + |
99 | +.. sourcecode:: json |
100 | + |
101 | + { |
102 | + "error_list": [{ |
103 | + "message": "The field 'expiration' must be an integer", |
104 | + "code": "invalid-field" |
105 | + }] |
106 | + } |
107 | +A multiple error: |
108 | + |
109 | +.. sourcecode:: json |
110 | + |
111 | + { |
112 | + "error_list": [{ |
113 | + "message": "The 'foo' field is required", |
114 | + "code": "missing-field" |
115 | + }, { |
116 | + "message": "The 'bar' field is required", |
117 | + "code": "missing-field" |
118 | + }, { |
119 | + "message": "The 'baz' field must not be empty", |
120 | + "code": "invalid-field" |
121 | + }] |
122 | + } |
123 | + |
124 | |
125 | === modified file 'src/api/v20/handlers.py' |
126 | --- src/api/v20/handlers.py 2016-08-11 22:06:36 +0000 |
127 | +++ src/api/v20/handlers.py 2016-09-02 11:35:34 +0000 |
128 | @@ -94,13 +94,14 @@ |
129 | statsd.timing('view.{module}.{name}.{method}.{suffix}'.format(**data), ms) |
130 | |
131 | |
132 | -def valid_email(email): |
133 | +def valid_email(email, new_style=False): |
134 | if email is None: |
135 | - return errors.INVALID_DATA(email=[FIELD_REQUIRED]) |
136 | + return errors.INVALID_DATA(email=[FIELD_REQUIRED], new_style=new_style) |
137 | + |
138 | try: |
139 | validate_email(email) |
140 | except ValidationError as e: |
141 | - result = errors.INVALID_DATA(email=e.messages) |
142 | + result = errors.INVALID_DATA(email=e.messages, new_style=new_style) |
143 | else: |
144 | result = email |
145 | return result |
146 | @@ -110,8 +111,8 @@ |
147 | return openid |
148 | |
149 | |
150 | -def get_email_from_request_data(request): |
151 | - return valid_email(request.data.get('email')) |
152 | +def get_email_from_request_data(request, new_style): |
153 | + return valid_email(request.data.get('email'), new_style) |
154 | |
155 | |
156 | def get_token_from_args(request, token): |
157 | @@ -373,7 +374,7 @@ |
158 | |
159 | @require_mime('json') |
160 | @throttle(extra_callback=get_email_from_request_data) |
161 | - def create(self, request): |
162 | + def create(self, request, new_style=False): |
163 | data = request.data |
164 | try: |
165 | email = data['email'] |
166 | @@ -444,9 +445,10 @@ |
167 | |
168 | @require_mime('json') |
169 | @throttle(extra_callback=get_email_from_request_data) |
170 | - def create(self, request): |
171 | + def create(self, request, new_style=True): |
172 | + |
173 | if settings.CRYPTO_SSO_PRIVKEY is None: |
174 | - return errors.RESOURCE_NOT_FOUND() |
175 | + return errors.RESOURCE_NOT_FOUND(new_style=new_style) |
176 | |
177 | data = request.data |
178 | try: |
179 | @@ -455,7 +457,7 @@ |
180 | except KeyError: |
181 | expected = {'email', 'password'} |
182 | missing = dict((k, [FIELD_REQUIRED]) for k in expected - set(data)) |
183 | - return errors.INVALID_DATA(**missing) |
184 | + return errors.INVALID_DATA(new_style=new_style, **missing) |
185 | |
186 | if 'macaroon' in data: |
187 | simple_macaroon = True |
188 | @@ -473,24 +475,24 @@ |
189 | else: |
190 | # let's flag the new one only, not the deprecated ones |
191 | missing = {'caveat_id': [FIELD_REQUIRED]} |
192 | - return errors.INVALID_DATA(**missing) |
193 | + return errors.INVALID_DATA(new_style=new_style, **missing) |
194 | |
195 | account = None |
196 | response = None |
197 | try: |
198 | account = authenticate_user(email, password, request=request) |
199 | except AccountSuspended: |
200 | - response = errors.ACCOUNT_SUSPENDED() |
201 | + response = errors.ACCOUNT_SUSPENDED(new_style=new_style) |
202 | except AccountDeactivated: |
203 | - response = errors.ACCOUNT_DEACTIVATED() |
204 | + response = errors.ACCOUNT_DEACTIVATED(new_style=new_style) |
205 | except EmailInvalidated: |
206 | - response = errors.EMAIL_INVALIDATED() |
207 | + response = errors.EMAIL_INVALIDATED(new_style=new_style) |
208 | except PasswordPolicyError as e: |
209 | root_url = request.build_absolute_uri('/') |
210 | response = errors.PASSWORD_POLICY_ERROR( |
211 | - reason=unicode(e), location=root_url) |
212 | + reason=unicode(e), location=root_url, new_style=new_style) |
213 | except AuthenticationError: |
214 | - response = errors.INVALID_CREDENTIALS() |
215 | + response = errors.INVALID_CREDENTIALS(new_style=new_style) |
216 | |
217 | if account is not None: |
218 | otp = data.get('otp') |
219 | @@ -499,9 +501,9 @@ |
220 | twofactor.authenticate(account, otp) |
221 | except AuthenticationError: |
222 | account = None |
223 | - response = errors.TWOFACTOR_FAILURE() |
224 | + response = errors.TWOFACTOR_FAILURE(new_style=new_style) |
225 | elif account.twofactor_required: |
226 | - response = errors.TWOFACTOR_REQUIRED() |
227 | + response = errors.TWOFACTOR_REQUIRED(new_style=new_style) |
228 | |
229 | if account is None: |
230 | # track failed login attempt |
231 | @@ -525,9 +527,10 @@ |
232 | account, root_macaroon_raw) |
233 | all_discharges.append((mac_id, discharge.serialize())) |
234 | except ValidationError: |
235 | - return errors.INVALID_DATA() |
236 | + return errors.INVALID_DATA(new_style=new_style) |
237 | + |
238 | except AuthenticationError: |
239 | - return errors.INVALID_CREDENTIALS() |
240 | + return errors.INVALID_CREDENTIALS(new_style=new_style) |
241 | |
242 | response = rc.ALL_OK |
243 | if simple_macaroon: |
244 | @@ -546,24 +549,25 @@ |
245 | |
246 | @require_mime('json') |
247 | def create(self, request): |
248 | + |
249 | if settings.CRYPTO_SSO_PRIVKEY is None: |
250 | - return errors.RESOURCE_NOT_FOUND() |
251 | + return errors.RESOURCE_NOT_FOUND(new_style=True) |
252 | |
253 | data = request.data |
254 | try: |
255 | discharge_macaroon_raw = data['discharge_macaroon'] |
256 | except KeyError: |
257 | missing = {'discharge_macaroon': [FIELD_REQUIRED]} |
258 | - return errors.INVALID_DATA(**missing) |
259 | + return errors.INVALID_DATA(new_style=True, **missing) |
260 | |
261 | try: |
262 | new_discharge = auth.refresh_discharge(discharge_macaroon_raw) |
263 | except ValidationError: |
264 | - return errors.INVALID_DATA() |
265 | + return errors.INVALID_DATA(new_style=True) |
266 | except AccountDeactivated: |
267 | - return errors.ACCOUNT_DEACTIVATED() |
268 | + return errors.ACCOUNT_DEACTIVATED(new_style=True) |
269 | except AuthenticationError: |
270 | - return errors.INVALID_CREDENTIALS() |
271 | + return errors.INVALID_CREDENTIALS(new_style=True) |
272 | |
273 | response = rc.ALL_OK |
274 | response.content = dict(discharge_macaroon=new_discharge.serialize()) |
275 | |
276 | === modified file 'src/api/v20/tests/test_handlers.py' |
277 | --- src/api/v20/tests/test_handlers.py 2016-08-11 22:06:36 +0000 |
278 | +++ src/api/v20/tests/test_handlers.py 2016-09-02 11:35:34 +0000 |
279 | @@ -25,6 +25,7 @@ |
280 | from timeline import Timeline |
281 | |
282 | from api.v20 import handlers, whitelist |
283 | +from api.v20.utils import install_error_format_checker |
284 | from identityprovider.auth import build_discharge_macaroon |
285 | from identityprovider.login import PasswordPolicyError |
286 | from identityprovider.models import ( |
287 | @@ -2506,6 +2507,10 @@ |
288 | |
289 | url = reverse('api-macaroon-discharge') |
290 | |
291 | + def setUp(self): |
292 | + super(MacaroonDischargeHandlerTestCase, self).setUp() |
293 | + install_error_format_checker(self) |
294 | + |
295 | def do_post(self, data=None, expected_status_code=200, |
296 | content_type='application/json', **kwargs): |
297 | if data is None: |
298 | @@ -2564,7 +2569,13 @@ |
299 | self.assertEqual(json_body, { |
300 | 'code': 'INVALID_DATA', |
301 | 'extra': {'email': [handlers.FIELD_REQUIRED]}, |
302 | - 'message': 'Invalid request data'}, |
303 | + 'message': 'Invalid request data', |
304 | + 'error_list': [{ |
305 | + 'code': 'invalid-data', |
306 | + 'extra': {'email': [handlers.FIELD_REQUIRED]}, |
307 | + 'message': 'Invalid request data' |
308 | + }], |
309 | + }, |
310 | ) |
311 | self.assertEqual(self.login_failed_calls, []) |
312 | |
313 | @@ -2791,6 +2802,7 @@ |
314 | unbound_discharge) |
315 | |
316 | self.data = {'discharge_macaroon': unbound_discharge.serialize()} |
317 | + install_error_format_checker(self) |
318 | |
319 | def do_post(self, data=None, expected_status_code=200, |
320 | content_type='application/json', **kwargs): |
321 | @@ -2822,12 +2834,17 @@ |
322 | |
323 | def test_required_parameters_discharge(self): |
324 | json_body = self.do_post(expected_status_code=400, data={}) |
325 | - |
326 | - self.assertEqual(json_body, { |
327 | + expected = { |
328 | 'code': 'INVALID_DATA', |
329 | 'extra': {'discharge_macaroon': [handlers.FIELD_REQUIRED]}, |
330 | - 'message': 'Invalid request data'}, |
331 | - ) |
332 | + 'message': 'Invalid request data', |
333 | + 'error_list': [{ |
334 | + 'message': 'Invalid request data', |
335 | + 'code': 'invalid-data', |
336 | + 'extra': {'discharge_macaroon': ['Field required']} |
337 | + }] |
338 | + } |
339 | + self.assertEqual(json_body, expected) |
340 | self.assertEqual(self.login_failed_calls, []) |
341 | |
342 | def test_account_missing(self): |
343 | |
344 | === modified file 'src/api/v20/utils.py' |
345 | --- src/api/v20/utils.py 2016-08-04 12:03:24 +0000 |
346 | +++ src/api/v20/utils.py 2016-09-02 11:35:34 +0000 |
347 | @@ -17,12 +17,13 @@ |
348 | # response from inside the API are always expected to have a JSON |
349 | # content |
350 | msg = '%s: %s' % (exception.__class__.__name__, unicode(exception)) |
351 | - response = api_error(status=500, code='SERVER_ERROR', message=msg) |
352 | + response = api_error(status=500, code='SERVER_ERROR', message=msg, |
353 | + error_code=ErrorCode.INTERNAL_ERROR) |
354 | |
355 | return response |
356 | |
357 | |
358 | -def api_error(status, code, message, extra=None): |
359 | +def api_error(status, code, message, error_code, extra=None): |
360 | if extra is None: |
361 | extra = {} |
362 | |
363 | @@ -39,58 +40,128 @@ |
364 | 'message': unicode(message), |
365 | 'extra': extra_data, |
366 | } |
367 | + |
368 | + # check if we're going to use the new style error format |
369 | + new_style = extra_data.pop('new_style', False) |
370 | + if new_style: |
371 | + error_list_data = { |
372 | + 'code': error_code, |
373 | + 'message': data['message'], |
374 | + } |
375 | + if data['extra']: |
376 | + error_list_data['extra'] = data['extra'] |
377 | + data['error_list'] = [error_list_data] |
378 | + |
379 | response = HttpResponse(content=json.dumps(data), status=status, |
380 | content_type='application/json; charset=utf-8') |
381 | response['Vary'] = 'Accept' |
382 | return response |
383 | |
384 | |
385 | +class ErrorCode: |
386 | + ACCOUNT_NOT_READY = 'account-not-ready' |
387 | + BAD_REQUEST = 'bad-request' |
388 | + INTERNAL_ERROR = 'internal-server-error' |
389 | + INVALID_DATA = 'invalid-data' |
390 | + INVALID_CREDENTIALS = 'invalid-credentials' |
391 | + INVALID_REQUEST = 'invalid-request' |
392 | + PERMISSION_REQUIRED = 'permission-required' |
393 | + REQUIRED = 'field-required' |
394 | + RESOURCE_NOT_FOUND = 'resource-not-found' |
395 | + RESOURCE_NOT_READY = 'resource-not-ready' |
396 | + |
397 | + def __init__(self): |
398 | + self._codes = {getattr(self, code) for code in dir(self) |
399 | + if code[0] != "_" and code != 'is_valid'} |
400 | + |
401 | + def is_valid(self, code): |
402 | + return code in self._codes |
403 | + |
404 | + |
405 | ERRORS = dict( |
406 | - INVALID_DATA=(400, _("Invalid request data")), |
407 | + INVALID_DATA=(400, _("Invalid request data"), ErrorCode.INVALID_DATA), |
408 | |
409 | CAPTCHA_REQUIRED=( |
410 | - 401, _('A captcha challenge is required to complete the request.')), |
411 | - INVALID_CREDENTIALS=(401, _('Provided email/password is not correct.')), |
412 | - TWOFACTOR_REQUIRED=(401, _("2-factor authentication required.")), |
413 | + 401, _('A captcha challenge is required to complete the request.'), |
414 | + ErrorCode.REQUIRED), |
415 | + |
416 | + INVALID_CREDENTIALS=( |
417 | + 401, _('Provided email/password is not correct.'), |
418 | + ErrorCode.INVALID_CREDENTIALS), |
419 | + |
420 | + TWOFACTOR_REQUIRED=( |
421 | + 401, _("2-factor authentication required."), |
422 | + ErrorCode.REQUIRED), |
423 | |
424 | ACCOUNT_SUSPENDED=( |
425 | 403, _('Your account has been suspended. Please contact login ' |
426 | - 'support to re-enable it')), |
427 | + 'support to re-enable it'), |
428 | + ErrorCode.ACCOUNT_NOT_READY), |
429 | + |
430 | ACCOUNT_DEACTIVATED=( |
431 | 403, _('Your account has been deactivated. To reactivate it, ' |
432 | - 'please reset your password')), |
433 | + 'please reset your password'), |
434 | + ErrorCode.ACCOUNT_NOT_READY), |
435 | + |
436 | EMAIL_INVALIDATED=( |
437 | 403, _("This email address has been invalidated. Please contact " |
438 | - "login support.")), |
439 | - CAN_NOT_RESET_PASSWORD=(403, _('Can not reset password. Please contact ' |
440 | - 'login support')), |
441 | - CAPTCHA_FAILURE=(403, _('Failed response to captcha challenge.')), |
442 | - TOO_MANY_TOKENS=(403, _('Too many non-consumed tokens exist. Further ' |
443 | - 'token creation is not allowed until existing ' |
444 | - 'tokens are consumed.')), |
445 | + "login support."), |
446 | + ErrorCode.ACCOUNT_NOT_READY), |
447 | + |
448 | + CAN_NOT_RESET_PASSWORD=( |
449 | + 403, _('Can not reset password. Please contact login support'), |
450 | + ErrorCode.INVALID_REQUEST), |
451 | + |
452 | + CAPTCHA_FAILURE=( |
453 | + 403, _('Failed response to captcha challenge.'), |
454 | + ErrorCode.RESOURCE_NOT_FOUND), |
455 | + |
456 | + TOO_MANY_TOKENS=( |
457 | + 403, _('Too many non-consumed tokens exist. Further ' |
458 | + 'token creation is not allowed until existing ' |
459 | + 'tokens are consumed.'), |
460 | + ErrorCode.RESOURCE_NOT_READY), |
461 | + |
462 | TWOFACTOR_FAILURE=( |
463 | - 403, _("The provided 2-factor key is not recognised.")), |
464 | - PASSWORD_POLICY_ERROR = (403, _("Your password doesn't meet " |
465 | - "security constraints.")), |
466 | - FORBIDDEN=(403, _("Insufficient permissions.")), |
467 | - |
468 | - RESOURCE_NOT_FOUND=(404, _("The resource requested was not found.")), |
469 | - |
470 | - ALREADY_REGISTERED=(409, _("The email address is already registered")), |
471 | - |
472 | - TOO_MANY_REQUESTS=(429, _('Too many requests. Please try again later.')), |
473 | - |
474 | - CAPTCHA_ERROR=(502, _('Unable to get a valid response from ' |
475 | - 'reCaptcha service')), |
476 | + 403, _("The provided 2-factor key is not recognised."), |
477 | + ErrorCode.INVALID_CREDENTIALS), |
478 | + |
479 | + PASSWORD_POLICY_ERROR = ( |
480 | + 403, _("Your password doesn't meet security constraints."), |
481 | + ErrorCode.INVALID_CREDENTIALS), |
482 | + |
483 | + FORBIDDEN=( |
484 | + 403, _("Insufficient permissions."), |
485 | + ErrorCode.PERMISSION_REQUIRED), |
486 | + |
487 | + RESOURCE_NOT_FOUND=( |
488 | + 404, _("The resource requested was not found."), |
489 | + ErrorCode.RESOURCE_NOT_FOUND), |
490 | + |
491 | + ALREADY_REGISTERED=( |
492 | + 409, _("The email address is already registered"), |
493 | + ErrorCode.RESOURCE_NOT_READY), |
494 | + |
495 | + TOO_MANY_REQUESTS=( |
496 | + 429, _('Too many requests. Please try again later.'), |
497 | + ErrorCode.BAD_REQUEST), |
498 | + |
499 | + CAPTCHA_ERROR=( |
500 | + 502, _('Unable to get a valid response from reCaptcha service'), |
501 | + ErrorCode.INTERNAL_ERROR), |
502 | + |
503 | + SERVER_ERROR=( |
504 | + 500, _('internal server error'), |
505 | + ErrorCode.INTERNAL_ERROR) |
506 | ) |
507 | |
508 | |
509 | class Errors(object): |
510 | def wrap(self, code): |
511 | - status, message = ERRORS[code] |
512 | + status, message, error_code = ERRORS[code] |
513 | |
514 | def wrapper(**extra): |
515 | - return api_error(status, code, message, extra) |
516 | + return api_error(status, code, message, error_code, extra) |
517 | wrapper.__name__ = code |
518 | return wrapper |
519 | |
520 | @@ -100,6 +171,7 @@ |
521 | else: |
522 | raise AttributeError |
523 | |
524 | + |
525 | errors = Errors() |
526 | |
527 | |
528 | @@ -172,3 +244,54 @@ |
529 | token_secret=token.secret, |
530 | )) |
531 | return result |
532 | + |
533 | + |
534 | +def assert_response_error_format(resp): |
535 | + """Check that the error format is ok.""" |
536 | + if resp.status_code < 400: |
537 | + # not checked |
538 | + return |
539 | + |
540 | + if resp.status_code >= 500 and resp['content-type'] == 'text/plain': |
541 | + # simple crashing |
542 | + return |
543 | + |
544 | + # we have now a 4xx response or a 5xx with interesting body, verify it! |
545 | + assert resp['content-type'] == 'application/json; charset=utf-8', ( |
546 | + "bad CT: " + repr(resp['content-type'])) |
547 | + content = json.loads(resp.content) |
548 | + |
549 | + error_list = content['error_list'] |
550 | + assert error_list, "Empty error list" |
551 | + |
552 | + ec = ErrorCode() |
553 | + for item in error_list: |
554 | + code = item['code'] |
555 | + assert ec.is_valid(code), "Invalid code: %r" % (code,) |
556 | + |
557 | + message = item['message'] |
558 | + assert isinstance(message, basestring), "message must be a string" |
559 | + |
560 | + if 'extra' in item: |
561 | + assert isinstance(item['extra'], dict), ( |
562 | + "extra info must be a dict") |
563 | + |
564 | + |
565 | +def install_error_format_checker(testcase): |
566 | + # put the ApiResource import here to avoid the following error |
567 | + # AttributeError: 'module' object has no attribute 'test_handlers' |
568 | + from api.v20.auth import ApiResource |
569 | + """Install an error format checker in the TestCase instance.""" |
570 | + orig_method = ApiResource.__call__ |
571 | + |
572 | + def mitm(*a, **k): |
573 | + """Send resource/handler response for proper format assertion.""" |
574 | + resp = orig_method(*a, **k) |
575 | + try: |
576 | + assert_response_error_format(resp) |
577 | + except Exception as err: |
578 | + testcase.fail("[Response Format Error] %s(%s)" % ( |
579 | + err.__class__.__name__, err)) |
580 | + return resp |
581 | + |
582 | + testcase.patch(ApiResource, '__call__', new=mitm) |
Please include a general format assertion at test case level as I did in SCA, so we're sure ALL error responses are in the new format.
Also, added some notes and stuff to fix in the inline comments.
Thanks!