Repository: greyli/apiflask (python)

Issue:
Support to add custom error classes based on `HTTPError`
Refactor the `HTTPError` class to support adding custom error classes, for example:

```python
from apiflask import HTTPError

class NotAuthor(HTTPError):
    status_code = 400
    message = 'Only the author can perform this action!'
    extra_data = {
        'error_code': 1234,
        'docs': 'http://example.com'
    }
```

Raise the error in the view:

```python
@app.get('/')
def hello():
    if not author:
        raise NotAuthor
    return 'Hello!'
```

Pull request description:
Add custom error classes support for HTTPError
Add custom error classes support for HTTPError, the `status_code` now is a keyword argument, defaults to 500.

- fixes #172

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the `docs` folder and in code docstring.
- [x] Add an entry in `CHANGES.md` summarizing the change and linking to the issue and your username.
- [x] Add `*Version changed*` or `*Version added*` note in any relevant docs and docstring.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 9dac7ad..8226c78 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,11 @@
 
 [0.11.0 milestone](https://github.com/greyli/apiflask/milestone/7)
 
+- Support creating custom error classes based on `HTTPError`. The position argument `status_code` of
+  `HTTPError` is changed to a keyword argument, defaults to `500`.([issue #172][issue_172]).
+
+[issue_172]: https://github.com/greyli/apiflask/issues/172
+
 
 ## Version 0.10.1
 
diff --git a/docs/error-handling.md b/docs/error-handling.md
index a903afd..4e506b2 100644
--- a/docs/error-handling.md
+++ b/docs/error-handling.md
@@ -3,9 +3,10 @@
 The error handling in APIFlask is based on the following basic concepts:
 
 - All the automatic errors (404, 405, 500) will be in JSON format by default.
-- Use `abort` and `HTTPError` to generate an error response.
+- Use `abort`, `HTTPError` to generate an error response.
 - Use `app.errror_processor` (`app` is an instance of `apiflask.APIFlask`) to register a
   custom error response processor.
+- Subclass `HTTPError` to create custom error classes for your errors.
 
 !!! tip
 
@@ -87,7 +88,7 @@ Here are all the parameters you can pass to `abort` and `HTTPError`:
 
 | Name | Type | Default Value | Description |
 | ---- | ---- | ------------- | ----------- |
-| `status_code` | int | - | The status code of the error response. |
+| `status_code` | int | - | The HTTP status code of the error response. |
 | `message` | string | - | The error message. Used as the `message` field in the response body. |
 | `detail` | dict | `{}` | The detailed information of the validation error. Used as the `detail` field in the response body. |
 | `headers` | dict | - | The headers of the error response. |
@@ -116,6 +117,69 @@ will produce the below response:
 }
 ```
 
+In most cases, you should create custom error classes with preset values instead of passing them to
+`abort` or `HTTPError` directly. See more details in the section below.
+
+
+## Custom error classes
+
+!!! warning "Version >= 0.11.0"
+
+    This feature was added in the [version 0.11.0](/changelog/#version-0110).
+
+To reuse errors, you can create custom error classes with preset error information. The
+custom error classes should be inherited from `HTTPError`, and you can use the following attributes
+in the error class:
+
+- status_code
+- message
+- detail
+- extra_data
+- headers
+
+Here is a simple example:
+
+```python
+from apiflask import HTTPError
+
+class PetNotFound(HTTPError):
+    status_code = 404
+    message = 'This pet is missing.'
+    extra_data = {
+        'error_code': '2323',
+        'error_docs': 'https://example.com/docs/missing'
+    }
+```
+
+Then you can `raise` this exception class in your view function:
+
+```python hl_lines="5"
+@app.get('/pets/<pet_id>')
+def get_pet(pet_id):
+    pets = [1, 2, 3]
+    if pet_id not in pets:
+        raise PetNotFound
+    return {'message': 'Pet'}
+```
+
+!!! tips "Use exception classes from Werkzeug"
+
+    If you didn't set the `json_errors` to `False` when creating `app` instance,
+    APIFlask will catch all the Werkzeug exceptions, including the one you raised
+    directly:
+
+    ```python
+    from werkzeug.exceptions import NotFound
+
+    @app.get('/')
+    def say_hello():
+        if user is None:
+            raise NotFound
+        return {'message': 'Hello!'}
+    ```
+
+    However, the `description` and `body` of the exception will be discarded.
+
 
 ## Custom error status code and description
 
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index fd23364..aa9caaa 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -34,7 +34,7 @@ from .helpers import get_reason_phrase
 from .route import route_shortcuts
 from .route import route_patch
 from .schemas import Schema
-from .types import ResponseType
+from .types import ResponseReturnValueType
 from .types import ViewFuncType
 from .types import ErrorCallbackType
 from .types import SpecCallbackType
@@ -315,7 +315,7 @@ class APIFlask(Flask):
         self.json_errors = json_errors
 
         self.spec_callback: t.Optional[SpecCallbackType] = None
-        self.error_callback: ErrorCallbackType = self._error_handler  # type: ignore
+        self.error_callback: ErrorCallbackType = self._error_handler
         self.schema_name_resolver = self._schema_name_resolver
 
         self._spec: t.Optional[t.Union[dict, str]] = None
@@ -332,25 +332,25 @@ class APIFlask(Flask):
         @self.errorhandler(HTTPError)
         def handle_http_errors(
             error: HTTPError
-        ) -> ResponseType:
+        ) -> ResponseReturnValueType:
             return self.error_callback(error)
 
         if self.json_errors:
             @self.errorhandler(WerkzeugHTTPException)  # type: ignore
             def handle_werkzeug_errors(
                 e: WerkzeugHTTPException
-            ) -> ResponseType:
+            ) -> ResponseReturnValueType:
                 headers = dict(e.get_headers())
                 # remove the original MIME header
                 del headers['Content-Type']
                 error = HTTPError(
-                    e.code,  # type: ignore
+                    e.code,
                     message=e.name,
                     headers=headers
                 )
                 return self.error_callback(error)
 
-    def dispatch_request(self) -> ResponseType:
+    def dispatch_request(self) -> ResponseReturnValueType:
         """Overwrite the default dispatch method in Flask.
 
         With this overwrite, view arguments are passed as positional
@@ -395,7 +395,7 @@ class APIFlask(Flask):
     @staticmethod
     def _error_handler(
         error: HTTPError
-    ) -> t.Union[t.Tuple[dict, int], t.Tuple[dict, int, t.Mapping[str, str]]]:
+    ) -> ResponseReturnValueType:
         """The default error handler.
 
         Arguments:
@@ -523,7 +523,7 @@ class APIFlask(Flask):
 
         if self.spec_path:
             @bp.route(self.spec_path)
-            def spec() -> ResponseType:
+            def spec() -> ResponseReturnValueType:
                 if self.config['SPEC_FORMAT'] == 'json':
                     response = jsonify(self._get_spec('json'))
                     response.mimetype = self.config['JSON_SPEC_MIMETYPE']
diff --git a/src/apiflask/decorators.py b/src/apiflask/decorators.py
index 32374ca..2898ba5 100644
--- a/src/apiflask/decorators.py
+++ b/src/apiflask/decorators.py
@@ -17,7 +17,7 @@ from .types import DictSchemaType
 from .types import HTTPAuthType
 from .types import OpenAPISchemaType
 from .types import RequestType
-from .types import ResponseType
+from .types import ResponseReturnValueType
 from .types import SchemaType
 
 
@@ -341,7 +341,7 @@ def output(
             return jsonify(data, *args, **kwargs)
 
         @wraps(f)
-        def _response(*args: t.Any, **kwargs: t.Any) -> ResponseType:
+        def _response(*args: t.Any, **kwargs: t.Any) -> ResponseReturnValueType:
             if hasattr(current_app, 'ensure_sync'):  # pragma: no cover
                 rv = current_app.ensure_sync(f)(*args, **kwargs)
             else:  # pragma: no cover
diff --git a/src/apiflask/exceptions.py b/src/apiflask/exceptions.py
index a9a8bd1..1d5258f 100644
--- a/src/apiflask/exceptions.py
+++ b/src/apiflask/exceptions.py
@@ -3,6 +3,7 @@ import typing as t
 from werkzeug.exceptions import default_exceptions
 
 from .helpers import get_reason_phrase
+from .types import ResponseHeaderType
 
 _bad_schema_message = 'The schema must be a marshmallow schema class or an OpenAPI schema dict.'
 
@@ -25,19 +26,24 @@ class HTTPError(Exception):
         return f'Hello, escape{name}'!
     ```
     """
+    status_code: int = 500
+    message: t.Optional[str] = None
+    detail: t.Any = {}
+    headers: ResponseHeaderType = {}
+    extra_data: t.Mapping[str, t.Any] = {}
 
     def __init__(
         self,
-        status_code: int,
+        status_code: t.Optional[int] = None,
         message: t.Optional[str] = None,
         detail: t.Optional[t.Any] = None,
-        headers: t.Optional[t.Mapping[str, str]] = None,
-        extra_data: t.Optional[dict] = None
+        headers: t.Optional[ResponseHeaderType] = None,
+        extra_data: t.Optional[t.Mapping[str, t.Any]] = None
     ) -> None:
         """Initialize the error response.
 
         Arguments:
-            status_code: The status code of the error (4XX and 5xx).
+            status_code: The status code of the error (4XX and 5xx), defaults to 500.
             message: The simple description of the error. If not provided,
                 the reason phrase of the status code will be used.
             detail: The detailed information of the error, you can use it to
@@ -54,19 +60,33 @@ class HTTPError(Exception):
         *Version changed: 0.10.0*
 
         - Add `extra_data` parameter to accept additional error information.
+
+        *Version changed: 0.11.0*
+
+        - Change `status_code` from position argument to keyword argument, defaults to 500.
+          Add class attributes with default values to support error subclasses.
         """
         super().__init__()
-        if status_code not in default_exceptions:
-            raise LookupError(
-                f'No exception for status code "{status_code}",'
-                ' valid error status code are "4XX" and "5XX".'
-            )
-        self.status_code = status_code
-        self.detail = detail or {}
-        self.headers = headers or {}
-        self.message = get_reason_phrase(status_code, 'Unknown error') \
-            if message is None else message
-        self.extra_data = extra_data or {}
+        if status_code is not None:
+            # TODO: support use custom error status code?
+            if status_code not in default_exceptions:
+                raise LookupError(
+                    f'No exception for status code "{status_code}",'
+                    ' valid error status code are "4XX" and "5XX".'
+                )
+            self.status_code = status_code
+        if detail is not None:
+            self.detail = detail
+        if headers is not None:
+            self.headers = headers
+        if message is not None:
+            self.message = message
+        if extra_data is not None:
+            self.extra_data = extra_data
+
+        if self.message is None:
+            # make sure the error message is not empty
+            self.message: str = get_reason_phrase(self.status_code, 'Unknown error')
 
 
 class _ValidationError(HTTPError):
@@ -79,7 +99,7 @@ def abort(
     status_code: int,
     message: t.Optional[str] = None,
     detail: t.Optional[t.Any] = None,
-    headers: t.Optional[t.Mapping[str, str]] = None,
+    headers: t.Optional[ResponseHeaderType] = None,
     extra_data: t.Optional[dict] = None
 ) -> None:
     """A function to raise HTTPError exception.
@@ -98,6 +118,7 @@ def abort(
     def hello(name):
         if name == 'Foo':
             abort(404, 'This man is missing.')
+            # or just `abort(404)`
         return f'Hello, escape{name}'!
     ```
 
diff --git a/src/apiflask/security.py b/src/apiflask/security.py
index 9915fa6..c17eb2d 100644
--- a/src/apiflask/security.py
+++ b/src/apiflask/security.py
@@ -7,6 +7,7 @@ from flask_httpauth import HTTPTokenAuth as BaseHTTPTokenAuth
 
 from .exceptions import HTTPError
 from .types import ErrorCallbackType
+from .types import ResponseReturnValueType
 
 
 class _AuthBase:
@@ -23,7 +24,7 @@ class _AuthBase:
     @staticmethod
     def _auth_error_handler(
         status_code: int
-    ) -> t.Union[t.Tuple[str, int], t.Tuple[dict, int], t.Tuple[dict, int, t.Mapping[str, str]]]:
+    ) -> ResponseReturnValueType:
         """The default error handler for Flask-HTTPAuth.
 
         This handler will return JSON response when setting `APIFlask(json_errors=True)` (default).
@@ -36,7 +37,7 @@ class _AuthBase:
         error = HTTPError(status_code)
         if current_app.json_errors:  # type: ignore
             return current_app.error_callback(error)  # type: ignore
-        return error.message, status_code
+        return error.message, status_code  # type: ignore
 
     def error_processor(
         self,
diff --git a/src/apiflask/types.py b/src/apiflask/types.py
index cf466e1..148121a 100644
--- a/src/apiflask/types.py
+++ b/src/apiflask/types.py
@@ -21,19 +21,25 @@ if t.TYPE_CHECKING:  # pragma: no cover
 DecoratedType = t.TypeVar('DecoratedType', bound=t.Callable[..., t.Any])
 RequestType = t.TypeVar('RequestType')
 
-_Body = t.Union[str, bytes, t.Dict[str, t.Any], t.Generator[str, None, None], 'Response']
-_Status = t.Union[str, int]
-_Header = t.Union[str, t.List[str], t.Tuple[str, ...]]
-_Headers = t.Union[t.Dict[str, _Header], t.List[t.Tuple[str, _Header]], 'Headers']
-ResponseType = t.Union[
-    _Body,
-    t.Tuple[_Body, _Status],
-    t.Tuple[_Body, _Headers],
-    t.Tuple[_Body, _Status, _Headers],
+ResponseBodyType = t.Union[str, bytes, t.Dict[str, t.Any], t.Generator[str, None, None], 'Response']
+ResponseStatusType = t.Union[str, int]
+_HeaderName = str
+_HeaderValue = t.Union[str, t.List[str], t.Tuple[str, ...]]
+ResponseHeaderType = t.Union[
+    t.Dict[_HeaderName, _HeaderValue],
+    t.Mapping[_HeaderName, _HeaderValue],
+    t.List[t.Tuple[_HeaderName, _HeaderValue]],
+    'Headers'
+]
+ResponseReturnValueType = t.Union[
+    ResponseBodyType,
+    t.Tuple[ResponseBodyType, ResponseStatusType],
+    t.Tuple[ResponseBodyType, ResponseHeaderType],
+    t.Tuple[ResponseBodyType, ResponseStatusType, ResponseHeaderType],
     'WSGIApplication'
 ]
 SpecCallbackType = t.Callable[[t.Union[dict, str]], t.Union[dict, str]]
-ErrorCallbackType = t.Callable[['HTTPError'], ResponseType]
+ErrorCallbackType = t.Callable[['HTTPError'], ResponseReturnValueType]
 
 DictSchemaType = t.Dict[str, t.Union['Field', type]]
 SchemaType = t.Union['Schema', t.Type['Schema'], DictSchemaType]
diff --git a/tox.ini b/tox.ini
index 37c9b1e..6e91a46 100644
--- a/tox.ini
+++ b/tox.ini
@@ -20,7 +20,7 @@ commands =
 deps = pre-commit
 skip_install = true
 commands =
-    pre-commit run --all-files --show-diff-on-failure
+    pre-commit run --all-files
 
 [testenv:docs]
 deps = -r requirements/docs.txt


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index e578982..0ea56a2 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -31,6 +31,8 @@ def test_json_errors(app, client):
     assert rv.headers['Content-Type'] == 'application/json'
     assert 'message' in rv.json
     assert 'detail' in rv.json
+    assert rv.json['message'] == 'Not Found'
+    assert rv.json['detail'] == {}
 
     app = APIFlask(__name__, json_errors=False)
     assert app.json_errors is False
@@ -41,6 +43,7 @@ def test_json_errors(app, client):
 
 
 def test_json_errors_reuse_werkzeug_headers(app, client):
+    # test automatic 405
     @app.get('/foo')
     def foo():
         pass
@@ -49,6 +52,17 @@ def test_json_errors_reuse_werkzeug_headers(app, client):
     assert rv.status_code == 405
     assert 'Allow' in rv.headers
 
+    # test manually raise 405
+    @app.get('/bar')
+    def bar():
+        from werkzeug.exceptions import MethodNotAllowed
+        raise MethodNotAllowed(valid_methods=['GET'])
+
+    rv = client.get('/bar')
+    assert rv.status_code == 405
+    assert rv.headers['Content-Type'] == 'application/json'
+    assert rv.headers['Allow'] == 'GET'
+
 
 def test_view_function_arguments_order(app, client):
 
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 775ef79..fd0f7e9 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -15,6 +15,10 @@ def test_httperror(app, client, kwargs):
     def foo():
         raise HTTPError(400, **kwargs)
 
+    @app.get('/bar')
+    def bar():
+        raise HTTPError
+
     rv = client.get('/foo')
     assert rv.status_code == 400
     if 'message' not in kwargs:
@@ -28,6 +32,12 @@ def test_httperror(app, client, kwargs):
     if 'headers' in kwargs:
         assert rv.headers['X-FOO'] == 'bar'
 
+    # test default error status code
+    rv = client.get('/bar')
+    assert rv.status_code == 500
+    assert rv.json['message'] == 'Internal Server Error'
+    assert rv.json['detail'] == {}
+
 
 @pytest.mark.parametrize('kwargs', [
     {},
@@ -86,3 +96,45 @@ def test_invalid_error_status_code():
 
     with pytest.raises(LookupError):
         raise HTTPError(204)
+
+
+def test_custom_error_classes(app, client):
+    class PetDown(HTTPError):
+        status_code = 400
+        message = 'The pet is down.'
+        extra_data = {
+            'error_code': '123',
+            'error_docs': 'https://example.com/docs/down'
+        }
+        headers = {'X-Foo': 'bar'}
+
+    class PetNotFound(HTTPError):
+        status_code = 404
+        message = 'The pet is gone.'
+        extra_data = {
+            'error_code': '345',
+            'error_docs': 'https://example.com/docs/gone'
+        }
+
+    @app.get('/foo')
+    def foo():
+        raise PetDown
+
+    @app.get('/bar')
+    def bar():
+        raise PetNotFound
+
+    rv = client.get('/foo')
+    assert rv.status_code == 400
+    assert rv.json['message'] == 'The pet is down.'
+    assert rv.json['error_code'] == '123'
+    assert rv.json['error_docs'] == 'https://example.com/docs/down'
+    assert rv.json['detail'] == {}
+    assert rv.headers['X-FOO'] == 'bar'
+
+    rv = client.get('/bar')
+    assert rv.status_code == 404
+    assert rv.json['message'] == 'The pet is gone.'
+    assert rv.json['error_code'] == '345'
+    assert rv.json['error_docs'] == 'https://example.com/docs/gone'
+    assert rv.json['detail'] == {}