Repository: falconry/falcon

Issue:
How do I force an immediate response?
In Falcon I can force an immediate error code response to return from anywhere in my code by raising a Falcon exception with the appropriate error code.

Is there a way to force an immediate return of the response to the client for VALID (i.e. not errors) responses?



Discussion:
@dukedougal You could register a custom error handler and then raise exceptions of that type. It is a bit of a hack, but will work with 0.2. We'll look at doing something more elegant in 0.3 or 0.4.


Resolving patch:
diff --git a/falcon/api.py b/falcon/api.py
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -17,6 +17,7 @@
 from falcon import api_helpers as helpers
 from falcon import DEFAULT_MEDIA_TYPE
 from falcon.http_error import HTTPError
+from falcon.http_status import HTTPStatus
 from falcon.request import Request, RequestOptions
 from falcon.response import Response
 import falcon.responders
@@ -205,6 +206,11 @@ def __call__(self, env, start_response):
                     self._call_resp_mw(middleware_stack, req, resp, resource)
                     raise
 
+        except HTTPStatus as ex:
+            self._compose_status_response(req, resp, ex)
+            self._call_after_hooks(req, resp, resource)
+            self._call_resp_mw(middleware_stack, req, resp, resource)
+
         except HTTPError as ex:
             self._compose_error_response(req, resp, ex)
             self._call_after_hooks(req, resp, resource)
@@ -475,13 +481,22 @@ def _get_responder(self, req):
 
         return (responder, params, resource)
 
+    def _compose_status_response(self, req, resp, http_status):
+        """Composes a response for the given HTTPStatus instance."""
+
+        resp.status = http_status.status
+
+        if http_status.headers is not None:
+            resp.set_headers(http_status.headers)
+
+        if getattr(http_status, "body", None) is not None:
+            resp.body = http_status.body
+
     def _compose_error_response(self, req, resp, error):
         """Composes a response for the given HTTPError instance."""
 
-        resp.status = error.status
-
-        if error.headers is not None:
-            resp.set_headers(error.headers)
+        # Use the HTTPStatus handler function to set status/headers
+        self._compose_status_response(req, resp, error)
 
         if error.has_representation:
             media_type, body = self._serialize_error(req, error)
diff --git a/falcon/http_status.py b/falcon/http_status.py
new file mode 100644
--- /dev/null
+++ b/falcon/http_status.py
@@ -0,0 +1,45 @@
+# Copyright 2015 by Hurricane Labs LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+class HTTPStatus(Exception):
+    """Represents a generic HTTP status.
+
+    Raise this class from a hook, middleware, or a responder to stop handling
+    the request and skip to the response handling.
+
+    Attributes:
+        status (str): HTTP status line, e.g. '748 Confounded by Ponies'.
+        headers (dict): Extra headers to add to the response.
+        body (str or unicode): String representing response content. If
+            Unicode, Falcon will encode as UTF-8 in the response.
+
+    Args:
+        status (str): HTTP status code and text, such as
+            '748 Confounded by Ponies'.
+        headers (dict): Extra headers to add to the response.
+        body (str or unicode): String representing response content. If
+            Unicode, Falcon will encode as UTF-8 in the response.
+    """
+
+    __slots__ = (
+        'status',
+        'headers',
+        'body'
+    )
+
+    def __init__(self, status, headers=None, body=None):
+        self.status = status
+        self.headers = headers
+        self.body = body


Test patch:
diff --git a/tests/test_httpstatus.py b/tests/test_httpstatus.py
new file mode 100644
--- /dev/null
+++ b/tests/test_httpstatus.py
@@ -0,0 +1,196 @@
+# -*- coding: utf-8
+
+import falcon.testing as testing
+import falcon
+from falcon.http_status import HTTPStatus
+
+
+def before_hook(req, resp, params):
+    raise HTTPStatus(falcon.HTTP_200,
+                     headers={"X-Failed": "False"},
+                     body="Pass")
+
+
+def after_hook(req, resp, resource):
+    resp.status = falcon.HTTP_200
+    resp.set_header("X-Failed", "False")
+    resp.body = "Pass"
+
+
+def noop_after_hook(req, resp, resource):
+    pass
+
+
+class TestStatusResource:
+
+    @falcon.before(before_hook)
+    def on_get(self, req, resp):
+        resp.status = falcon.HTTP_500
+        resp.set_header("X-Failed", "True")
+        resp.body = "Fail"
+
+    def on_post(self, req, resp):
+        resp.status = falcon.HTTP_500
+        resp.set_header("X-Failed", "True")
+        resp.body = "Fail"
+
+        raise HTTPStatus(falcon.HTTP_200,
+                         headers={"X-Failed": "False"},
+                         body="Pass")
+
+    @falcon.after(after_hook)
+    def on_put(self, req, resp):
+        resp.status = falcon.HTTP_500
+        resp.set_header("X-Failed", "True")
+        resp.body = "Fail"
+
+    def on_patch(self, req, resp):
+        raise HTTPStatus(falcon.HTTP_200,
+                         body=None)
+
+    @falcon.after(noop_after_hook)
+    def on_delete(self, req, resp):
+        raise HTTPStatus(falcon.HTTP_200,
+                         headers={"X-Failed": "False"},
+                         body="Pass")
+
+
+class TestHookResource:
+
+    def on_get(self, req, resp):
+        resp.status = falcon.HTTP_500
+        resp.set_header("X-Failed", "True")
+        resp.body = "Fail"
+
+    def on_patch(self, req, resp):
+        raise HTTPStatus(falcon.HTTP_200,
+                         body=None)
+
+    def on_delete(self, req, resp):
+        raise HTTPStatus(falcon.HTTP_200,
+                         headers={"X-Failed": "False"},
+                         body="Pass")
+
+
+class TestHTTPStatus(testing.TestBase):
+    def before(self):
+        self.resource = TestStatusResource()
+        self.api.add_route('/status', self.resource)
+
+    def test_raise_status_in_before_hook(self):
+        """ Make sure we get the 200 raised by before hook """
+        body = self.simulate_request('/status', method='GET', decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_in_responder(self):
+        """ Make sure we get the 200 raised by responder """
+        body = self.simulate_request('/status', method='POST', decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_runs_after_hooks(self):
+        """ Make sure after hooks still run """
+        body = self.simulate_request('/status', method='PUT', decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_survives_after_hooks(self):
+        """ Make sure after hook doesn't overwrite our status """
+        body = self.simulate_request('/status', method='DELETE',
+                                     decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_empty_body(self):
+        """ Make sure passing None to body results in empty body """
+        body = self.simulate_request('/status', method='PATCH', decode='utf-8')
+        self.assertEqual(body, '')
+
+
+class TestHTTPStatusWithGlobalHooks(testing.TestBase):
+    def before(self):
+        self.resource = TestHookResource()
+
+    def test_raise_status_in_before_hook(self):
+        """ Make sure we get the 200 raised by before hook """
+        self.api = falcon.API(before=[before_hook])
+        self.api.add_route('/status', self.resource)
+
+        body = self.simulate_request('/status', method='GET', decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_runs_after_hooks(self):
+        """ Make sure we still run after hooks """
+        self.api = falcon.API(after=[after_hook])
+        self.api.add_route('/status', self.resource)
+
+        body = self.simulate_request('/status', method='GET', decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_survives_after_hooks(self):
+        """ Make sure after hook doesn't overwrite our status """
+        self.api = falcon.API(after=[noop_after_hook])
+        self.api.add_route('/status', self.resource)
+
+        body = self.simulate_request('/status', method='DELETE',
+                                     decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_in_process_request(self):
+        """ Make sure we can raise status from middleware process request """
+        class TestMiddleware:
+            def process_request(self, req, resp):
+                raise HTTPStatus(falcon.HTTP_200,
+                                 headers={"X-Failed": "False"},
+                                 body="Pass")
+
+        self.api = falcon.API(middleware=TestMiddleware())
+        self.api.add_route('/status', self.resource)
+
+        body = self.simulate_request('/status', method='GET', decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_in_process_resource(self):
+        """ Make sure we can raise status from middleware process resource """
+        class TestMiddleware:
+            def process_resource(self, req, resp, resource):
+                raise HTTPStatus(falcon.HTTP_200,
+                                 headers={"X-Failed": "False"},
+                                 body="Pass")
+
+        self.api = falcon.API(middleware=TestMiddleware())
+        self.api.add_route('/status', self.resource)
+
+        body = self.simulate_request('/status', method='GET', decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')
+
+    def test_raise_status_runs_process_response(self):
+        """ Make sure process_response still runs """
+        class TestMiddleware:
+            def process_response(self, req, resp, response):
+                resp.status = falcon.HTTP_200
+                resp.set_header("X-Failed", "False")
+                resp.body = "Pass"
+
+        self.api = falcon.API(middleware=TestMiddleware())
+        self.api.add_route('/status', self.resource)
+
+        body = self.simulate_request('/status', method='GET', decode='utf-8')
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertIn(('x-failed', 'False'), self.srmock.headers)
+        self.assertEqual(body, 'Pass')