Repository: greyli/apiflask (python)

Issue:
Deprecate the single value parameters (tag, role)
These two parameters are just shortcuts for the actual parameters (tags, roles). Removing them will keep consistent with `methods` (there isn't a `method` parameter), and users won't be confused with `example` and `examples` parameters (they are valid keys in [OpenAPI3](https://swagger.io/docs/specification/adding-examples/)).

- Remove usage of these params in examples and docs
- Add deprecate warning in 1.0
- Remove them in 1.1

Pull request description:
Deprecate the role and tag parameters
Use roles and tags instead:

```py
@app.get('/')
@app.auth_required(auth, roles=['admin'])
@app.doc(tags=['Foo'])
def hello():
    return 'Hello'
```

- fixes #237

<!--
If needed, ensure each step in the checklist below is complete. If only docs were changed, these aren't relevant and can be removed.
-->

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 7c89bfe..c757e6c 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -4,6 +4,11 @@
 
 Released: --
 
+- Remove the deprecated standalone decorators: `input`, `output`, `doc`, and `auth_required`.
+  Use app/blueprint decorators instead (e.g. `input()` -> `app.input()`/`bp.input()`).
+- Deprecate the following parameters ([issue #237][issue_237]):
+  - `role` in `app.auth_required()`/`bp.auth_required()`, use `roles` and always pass a list.
+  - `tag` in `app.doc()`/`bp.doc()`, use `tags` and always pass a list.
 - Add a base class (`apiflask.scaffold.APIScaffold`) for common logic of `APIFlask` and
   `APIBlueprint`, move route decorators and API-related decorators to this base class
   to improve the IDE auto-completion ([issue #231][issue_231]).
@@ -15,6 +20,7 @@ Released: --
 [issue_211]: https://github.com/greyli/apiflask/issues/211
 [issue_231]: https://github.com/greyli/apiflask/issues/231
 [pull_232]: https://github.com/greyli/apiflask/pull/232
+[issue_237]: https://github.com/greyli/apiflask/issues/237
 
 
 ## Version 0.12.0
diff --git a/docs/openapi.md b/docs/openapi.md
index d3c79fb..816165e 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -389,7 +389,7 @@ app.config['TAGS'] = [
 
 !!! tips
 
-    The `app.tags` attribute is equels to the configuration variable `TAGS`, so you
+    The `app.tags` attribute is equals to the configuration variable `TAGS`, so you
     can also use:
 
     ```python
@@ -722,23 +722,16 @@ def hello():
 
 ### Operation `tags`
 
-There are two parameters for `tags` available:
+When you are using blueprints in your application, APIFlask provides an automatic tagging system,
+see [Tags](#tags) for more details.
 
-- `tag`, accepts a tag name string. It will be used in most cases:
+You only need to set the tag if you are not using a blueprint or you want to control the tags by
+yourself. The `tags` parameter accepts a list of tag name string, they should match the values you
+passed in `TAGS` config or `app.tags` attribute:
 
 ```python hl_lines="2"
 @app.get('/')
-@app.doc(tag='Foo')
-def hello():
-    return 'Hello'
-```
-
-- `tags`, accepts a list of tag name string. Only needed when you want to set multiple
-tags for one route (OpenAPI operation):
-
-```python hl_lines="2"
-@app.get('/')
-@app.doc(tags=['Foo', 'Bar', 'Baz'])
+@app.doc(tags=['Foo'])
 def hello():
     return 'Hello'
 ```
diff --git a/examples/openapi/app.py b/examples/openapi/app.py
index 3450d15..edcce87 100755
--- a/examples/openapi/app.py
+++ b/examples/openapi/app.py
@@ -116,7 +116,7 @@ class PetOutSchema(Schema):
 
 
 @app.get('/')
-@app.doc(tag='Hello')
+@app.doc(tags=['Hello'])
 def say_hello():
     """Just Say Hello
 
@@ -130,7 +130,7 @@ def say_hello():
 
 @app.get('/pets/<int:pet_id>')
 @app.output(PetOutSchema, description='The pet with given ID')
-@app.doc(tag='Pet', operation_id='getPet')
+@app.doc(tags=['Pet'], operation_id='getPet')
 def get_pet(pet_id):
     """Get a Pet
 
@@ -143,7 +143,7 @@ def get_pet(pet_id):
 
 @app.get('/pets')
 @app.output(PetOutSchema(many=True), description='A list of pets')
-@app.doc(tag='Pet')
+@app.doc(tags=['Pet'])
 def get_pets():
     """Get All Pet
 
@@ -165,7 +165,7 @@ def get_pets():
         }
     }}
 )
-@app.doc(tag='Pet')
+@app.doc(tags=['Pet'])
 def create_pet(data):
     """Create a Pet
 
@@ -180,7 +180,7 @@ def create_pet(data):
 @app.patch('/pets/<int:pet_id>')
 @app.input(PetInSchema(partial=True))
 @app.output(PetOutSchema, description='The updated pet')
-@app.doc(tag='Pet')
+@app.doc(tags=['Pet'])
 def update_pet(pet_id, data):
     """Update a Pet
 
@@ -195,7 +195,7 @@ def update_pet(pet_id, data):
 
 @app.delete('/pets/<int:pet_id>')
 @app.output({}, 204, description='Empty')
-@app.doc(tag='Pet')
+@app.doc(tags=['Pet'])
 def delete_pet(pet_id):
     """Delete a Pet
 
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index e4f8cfe..6b24e25 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -1,4 +1,5 @@
 import typing as t
+import warnings
 from collections.abc import Mapping as ABCMapping
 from functools import wraps
 
@@ -138,17 +139,21 @@ class APIScaffold:
             auth: The `auth` object, an instance of
                 [`HTTPBasicAuth`][apiflask.security.HTTPBasicAuth]
                 or [`HTTPTokenAuth`][apiflask.security.HTTPTokenAuth].
-            role: The selected role to allow to visit this view, accepts a string.
+            role: Deprecated since 1.0, use `roles` instead.
+            roles: The selected roles to allow to visit this view, accepts a list of role names.
                 See [Flask-HTTPAuth's documentation][_role]{target:_blank} for more details.
                 [_role]: https://flask-httpauth.readthedocs.io/en/latest/#user-roles
-            roles: Similar to `role` but accepts a list of role names.
             optional: Set to `True` to allow the view to execute even the authentication
                 information is not included with the request, in which case the attribute
                 `auth.current_user` will be `None`.
 
+        *Version changed: 1.0.0*
+
+        - The `role` parameter is deprecated.
+
         *Version changed: 0.12.0*
 
-        - Move to APIFlask and APIBlueprint classes.
+        - Move to `APIFlask` and `APIBlueprint` classes.
 
         *Version changed: 0.4.0*
 
@@ -156,6 +161,12 @@ class APIScaffold:
         """
         _roles = None
         if role is not None:
+            warnings.warn(
+                'The `role` parameter is deprecated and will be removed in 1.1, '
+                'use `roles` and always pass a list instead.',
+                DeprecationWarning,
+                stacklevel=3,
+            )
             _roles = [role]
         elif roles is not None:
             _roles = roles
@@ -443,7 +454,7 @@ class APIScaffold:
         app = APIFlask(__name__)
 
         @app.get('/')
-        @app.doc(summary='Say hello', tag='Foo')
+        @app.doc(summary='Say hello', tags=['Foo'])
         def hello():
             return 'Hello'
         ```
@@ -460,10 +471,9 @@ class APIScaffold:
 
             description: The description of this endpoint. If not set, the lines after the empty
                 line of the docstring will be used.
-            tag: The tag name of this endpoint, map the tags you passed in the `app.tags`
-                attribute. If `app.tags` is not set, the blueprint name will be used as
-                tag name.
-            tags: Similar to `tag` but accepts a list of tag names.
+            tag: Deprecated since 1.0, use `tags` instead.
+            tags: A list of tag names of this endpoint, map the tags you passed in the `app.tags`
+                attribute. If `app.tags` is not set, the blueprint name will be used as tag name.
             responses: The other responses for this view function, accepts a dict in a format
                 of `{404: 'Not Found'}` or a list of status code (`[404, 418]`). If pass a dict,
                 and a response with the same status code is already exist, the existing
@@ -481,10 +491,11 @@ class APIScaffold:
         *Version changed: 1.0*
 
         - Add `security` parameter to support customizing security info.
+        - The `role` parameter is deprecated.
 
         *Version changed: 0.12.0*
 
-        - Move to APIFlask and APIBlueprint classes.
+        - Move to `APIFlask` and `APIBlueprint` classes.
 
         *Version changed: 0.10.0*
 
@@ -507,6 +518,12 @@ class APIScaffold:
         """
         _tags = None
         if tag is not None:
+            warnings.warn(
+                'The `tag` parameter is deprecated and will be removed in 1.1, '
+                'use `tags` and always pass a list instead.',
+                DeprecationWarning,
+                stacklevel=2,
+            )
             _tags = [tag]
         elif tags is not None:
             _tags = tags


Test patch:
diff --git a/tests/test_decorator_auth_required.py b/tests/test_decorator_auth_required.py
index be7b915..7ac3fe6 100644
--- a/tests/test_decorator_auth_required.py
+++ b/tests/test_decorator_auth_required.py
@@ -32,7 +32,7 @@ def test_auth_required(app, client):
         return auth.current_user
 
     @app.route('/bar')
-    @app.auth_required(auth, role='admin')
+    @app.auth_required(auth, roles=['admin'])
     def bar():
         return auth.current_user
 
@@ -110,7 +110,7 @@ def test_auth_required_with_methodview(app, client):
         def get(self):
             return auth.current_user
 
-        @app.auth_required(auth, role='admin')
+        @app.auth_required(auth, roles=['admin'])
         def post(self):
             return auth.current_user
 
diff --git a/tests/test_decorator_doc.py b/tests/test_decorator_doc.py
index d131e7f..1417105 100644
--- a/tests/test_decorator_doc.py
+++ b/tests/test_decorator_doc.py
@@ -49,7 +49,7 @@ def test_doc_tags(app, client):
     app.tags = ['foo', 'bar']
 
     @app.route('/foo')
-    @app.doc(tag='foo')
+    @app.doc(tags=['foo'])
     def foo():
         pass
 
@@ -68,7 +68,7 @@ def test_doc_tags(app, client):
 def test_doc_tags_with_methodview(app, client):
     @app.route('/baz')
     class Baz(MethodView):
-        @app.doc(tag='foo')
+        @app.doc(tags=['foo'])
         def get(self):
             pass
 
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
index d1f7a40..205795f 100644
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -1,4 +1,30 @@
 from apiflask import APIBlueprint
+from apiflask import HTTPBasicAuth
+
+
+def test_deprecation_warning_for_role(recwarn, app, client):
+    auth = HTTPBasicAuth()
+
+    @app.get('/foo')
+    @app.auth_required(auth, role='admin')
+    def foo():
+        pass
+
+    client.get('/foo')
+    w = recwarn.pop(DeprecationWarning)
+    assert 'The `role` parameter is deprecated and will be removed in 1.1,' in str(w)
+
+
+def test_deprecation_warning_for_tag(recwarn, app, client):
+
+    @app.get('/foo')
+    @app.doc(tag='foo')
+    def foo():
+        pass
+
+    client.get('/foo')
+    w = recwarn.pop(DeprecationWarning)
+    assert 'The `tag` parameter is deprecated and will be removed in 1.1,' in str(w)
 
 
 def test_app_decorators(app):
diff --git a/tests/test_route.py b/tests/test_route.py
index 2d227c9..6053858 100644
--- a/tests/test_route.py
+++ b/tests/test_route.py
@@ -132,7 +132,7 @@ def test_class_attribute_decorators(app, client):
 def test_overwrite_class_attribute_decorators(app, client):
     @app.route('/')
     class Foo(MethodView):
-        decorators = [app.doc(deprecated=True, tag='foo')]
+        decorators = [app.doc(deprecated=True, tags=['foo'])]
 
         def get(self):
             pass
@@ -141,7 +141,7 @@ def test_overwrite_class_attribute_decorators(app, client):
         def post(self):
             pass
 
-        @app.doc(tag='bar')
+        @app.doc(tags=['bar'])
         def delete(self):
             pass