Repository: deis/deis

Issue:
REST errors question
I'm using the deis API and I see one "annoying" thing: the error formats.
In case of an error (not relevant) the error returns a JSON object where the key changes and the value can be an array.
It's possible to define 1 format and wrap the errors just indicating the first error? Like:

```
{
  "message": "invalid credentials"
}
```

**Some errors responses from the API:**

```
{"id":["App with this Id already exists."]}
{"username":["User with this Username already exists."]}
```



Discussion:
I think this has to do with how we're handling these errors through Django Rest Framework's Serializer class. How this format works is the following:

```
{"<invalid_field_in_api_request>": [<reasons why>]}
```

Which makes sense, considering that multiple validation rules could be broken with a specific app ID (for example, using capital letters and making the name shorter than 4 characters in length).

The first step to standardizing these errors would be to identify all of the error cases and document them. If we document and throw these into the [API documentation](http://docs.deis.io/en/latest/reference/api-v1.1/) then we can take a closer look at all of the responses and standardize them accordingly.


Resolving patch:
diff --git a/controller/api/__init__.py b/controller/api/__init__.py
--- a/controller/api/__init__.py
+++ b/controller/api/__init__.py
@@ -2,4 +2,4 @@
 The **api** Django app presents a RESTful web API for interacting with the **deis** system.
 """
 
-__version__ = '1.1.1'
+__version__ = '1.1.2'
diff --git a/controller/api/views.py b/controller/api/views.py
--- a/controller/api/views.py
+++ b/controller/api/views.py
@@ -38,7 +38,8 @@ def get_object(self):
     def passwd(self, request, **kwargs):
         obj = self.get_object()
         if not obj.check_password(request.DATA['password']):
-            return Response("Current password did not match", status=status.HTTP_400_BAD_REQUEST)
+            return Response({'detail': 'Current password does not match'},
+                            status=status.HTTP_400_BAD_REQUEST)
         obj.set_password(request.DATA['new_password'])
         obj.save()
         return Response({'status': 'password set'})
@@ -60,7 +61,7 @@ def create(self, request, *args, **kwargs):
             return super(BaseDeisViewSet, self).create(request, *args, **kwargs)
         # If the scheduler oopsie'd
         except RuntimeError as e:
-            return Response(str(e), status=status.HTTP_503_SERVICE_UNAVAILABLE)
+            return Response({'detail': str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
 
 
 class AppResourceViewSet(BaseDeisViewSet):
@@ -116,22 +117,20 @@ def post_save(self, app):
 
     def scale(self, request, **kwargs):
         new_structure = {}
+        app = self.get_object()
         try:
             for target, count in request.DATA.items():
                 new_structure[target] = int(count)
-        except (TypeError, ValueError):
-            return Response('Invalid scaling format',
-                            status=status.HTTP_400_BAD_REQUEST)
-        app = self.get_object()
-        try:
             models.validate_app_structure(new_structure)
             app.scale(request.user, new_structure)
-        except (EnvironmentError, ValidationError) as e:
-            return Response(str(e), status=status.HTTP_400_BAD_REQUEST)
+        except (TypeError, ValueError):
+            return Response({'detail': 'Invalid scaling format'},
+                            status=status.HTTP_400_BAD_REQUEST)
+        except (ValidationError, EnvironmentError) as e:
+            return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
         except RuntimeError as e:
-            return Response(str(e), status=status.HTTP_503_SERVICE_UNAVAILABLE)
-        return Response(status=status.HTTP_204_NO_CONTENT,
-                        content_type='application/json')
+            return Response({'detail': str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
+        return Response(status=status.HTTP_204_NO_CONTENT)
 
     def logs(self, request, **kwargs):
         app = self.get_object()
@@ -150,9 +149,9 @@ def run(self, request, **kwargs):
         try:
             output_and_rc = app.run(self.request.user, command)
         except EnvironmentError as e:
-            return Response(str(e), status=status.HTTP_400_BAD_REQUEST)
+            return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
         except RuntimeError as e:
-            return Response(str(e), status=status.HTTP_503_SERVICE_UNAVAILABLE)
+            return Response({'detail': str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
         return Response(output_and_rc, status=status.HTTP_200_OK,
                         content_type='text/plain')
 
@@ -237,7 +236,7 @@ def rollback(self, request, **kwargs):
             response = {'version': new_release.version}
             return Response(response, status=status.HTTP_201_CREATED)
         except EnvironmentError as e:
-            return Response(str(e), status=status.HTTP_400_BAD_REQUEST)
+            return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
         except RuntimeError:
             new_release.delete()
             raise
diff --git a/controller/scheduler/chaos.py b/controller/scheduler/chaos.py
--- a/controller/scheduler/chaos.py
+++ b/controller/scheduler/chaos.py
@@ -50,7 +50,7 @@ def run(self, name, image, entrypoint, command):
         Run a one-off command
         """
         if random.random() < CREATE_ERROR_RATE:
-            raise RuntimeError
+            raise RuntimeError('exit code 1')
         return 0, ''
 
 SchedulerClient = ChaosSchedulerClient


Test patch:
diff --git a/controller/api/tests/test_app.py b/controller/api/tests/test_app.py
--- a/controller/api/tests/test_app.py
+++ b/controller/api/tests/test_app.py
@@ -248,7 +248,8 @@ def test_run_without_auth(self):
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEquals(response.status_code, 400)
-        self.assertEquals(response.data, 'Support for admin commands is not configured')
+        self.assertEquals(response.data, {'detail': 'Support for admin commands '
+                                                    'is not configured'})
 
     def test_run_without_release_should_error(self):
         """
@@ -265,8 +266,8 @@ def test_run_without_release_should_error(self):
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.data, "No build associated with this release "
-                                        "to run this command")
+        self.assertEqual(response.data, {'detail': 'No build associated with this '
+                                                   'release to run this command'})
 
     @unittest.expectedFailure
     def test_unauthorized_user_cannot_see_app(self):
diff --git a/controller/api/tests/test_auth.py b/controller/api/tests/test_auth.py
--- a/controller/api/tests/test_auth.py
+++ b/controller/api/tests/test_auth.py
@@ -133,6 +133,8 @@ def test_passwd(self):
         response = self.client.post(url, json.dumps(submit), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(token))
         self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.data, {'detail': 'Current password does not match'})
+        self.assertEqual(response.get('content-type'), 'application/json')
         submit = {
             'password': password,
             'new_password': 'password2',
diff --git a/controller/api/tests/test_container.py b/controller/api/tests/test_container.py
--- a/controller/api/tests/test_container.py
+++ b/controller/api/tests/test_container.py
@@ -304,7 +304,8 @@ def test_container_errors(self):
         body = {'web': 'not_an_int'}
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
-        self.assertContains(response, 'Invalid scaling format', status_code=400)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.data, {'detail': 'Invalid scaling format'})
         body = {'invalid': 1}
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
@@ -447,7 +448,7 @@ def test_scale_without_build_should_error(self):
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.data, "No build associated with this release")
+        self.assertEqual(response.data, {'detail': 'No build associated with this release'})
 
     def test_command_good(self):
         """Test the default command for each container workflow"""
diff --git a/controller/api/tests/test_release.py b/controller/api/tests/test_release.py
--- a/controller/api/tests/test_release.py
+++ b/controller/api/tests/test_release.py
@@ -147,6 +147,8 @@ def test_release_rollback(self):
         response = self.client.post(url, content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.data, {'detail': 'version cannot be below 0'})
+        self.assertEqual(response.get('content-type'), 'application/json')
         # update config to roll a new release
         url = '/v1/apps/{app_id}/config'.format(**locals())
         body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
diff --git a/controller/api/tests/test_scheduler.py b/controller/api/tests/test_scheduler.py
--- a/controller/api/tests/test_scheduler.py
+++ b/controller/api/tests/test_scheduler.py
@@ -69,6 +69,8 @@ def test_create_chaos(self):
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEqual(response.status_code, 503)
+        self.assertEqual(response.data, {'detail': 'aborting, failed to create some containers'})
+        self.assertEqual(response.get('content-type'), 'application/json')
         # inspect broken containers
         url = "/v1/apps/{app_id}/containers".format(**locals())
         response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
@@ -151,6 +153,8 @@ def test_destroy_chaos(self):
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEqual(response.status_code, 503)
+        self.assertEqual(response.data, {'detail': 'aborting, failed to destroy some containers'})
+        self.assertEqual(response.get('content-type'), 'application/json')
         # inspect broken containers
         url = "/v1/apps/{app_id}/containers".format(**locals())
         response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
@@ -167,7 +171,10 @@ def test_destroy_chaos(self):
             # break if we destroyed successfully
             if response.status_code == 204:
                 break
-            self.assertEquals(response.status_code, 503)
+            self.assertEqual(response.status_code, 503)
+            self.assertEqual(response.data, {'detail': 'aborting, failed to '
+                                                       'destroy some containers'})
+            self.assertEqual(response.get('content-type'), 'application/json')
             # inspect broken containers
             url = "/v1/apps/{app_id}/containers".format(**locals())
             response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
@@ -216,6 +223,8 @@ def test_build_chaos(self):
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEqual(response.status_code, 503)
+        self.assertEqual(response.data, {'detail': 'aborting, failed to create some containers'})
+        self.assertEqual(response.get('content-type'), 'application/json')
         # inspect releases
         url = "/v1/apps/{app_id}/releases".format(**locals())
         response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
@@ -267,6 +276,8 @@ def test_config_chaos(self):
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEqual(response.status_code, 503)
+        self.assertEqual(response.data, {'detail': 'aborting, failed to create some containers'})
+        self.assertEqual(response.get('content-type'), 'application/json')
         # inspect releases
         url = "/v1/apps/{app_id}/releases".format(**locals())
         response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
@@ -315,3 +326,5 @@ def test_run_chaos(self):
         response = self.client.post(url, json.dumps(body), content_type='application/json',
                                     HTTP_AUTHORIZATION='token {}'.format(self.token))
         self.assertEqual(response.status_code, 503)
+        self.assertEqual(response.data, {'detail': 'exit code 1'})
+        self.assertEqual(response.get('content-type'), 'application/json')