Repository: iterait/apistrap

Issue:
Support adding metadata to tags
see https://swagger.io/specification/#tagObject - this could play nicely with Flask blueprints

Discussion:

Patch:
diff --git a/apistrap/decorators.py b/apistrap/decorators.py
index 5617f2b..4de1007 100644
--- a/apistrap/decorators.py
+++ b/apistrap/decorators.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 import abc
 import inspect
 from functools import wraps
-from typing import TYPE_CHECKING, Callable, Optional, Sequence, Type
+from typing import TYPE_CHECKING, Callable, Optional, Sequence, Type, Union
 
 from schematics import Model
 from schematics.exceptions import DataError
@@ -11,6 +11,7 @@ from schematics.exceptions import DataError
 from apistrap.errors import ApiClientError, InvalidFieldsError
 from apistrap.examples import ExamplesMixin, model_examples_to_openapi_dict
 from apistrap.schematics_converters import schematics_model_to_schema_object
+from apistrap.tags import TagData
 from apistrap.types import FileResponse
 
 if TYPE_CHECKING:
@@ -263,13 +264,20 @@ class TagsDecorator:
     A decorator that adds tags to the OpenAPI specification of the decorated view function.
     """
 
-    def __init__(self, tags: Sequence[str]):
+    def __init__(self, extension: Apistrap, tags: Sequence[Union[str, TagData]]):
         self._tags = tags
+        self._extension = extension
 
     def __call__(self, wrapped_func: Callable):
         _ensure_specs_dict(wrapped_func)
         wrapped_func.specs_dict.setdefault("tags", [])
-        wrapped_func.specs_dict["tags"].extend(self._tags)
+
+        for tag in self._tags:
+            wrapped_func.specs_dict["tags"].append(tag.name if isinstance(tag, TagData) else tag)
+
+            if isinstance(tag, TagData):
+                self._extension.add_tag_data(tag)
+
         return wrapped_func
 
 
diff --git a/apistrap/extension.py b/apistrap/extension.py
index 9d931d8..285ae00 100644
--- a/apistrap/extension.py
+++ b/apistrap/extension.py
@@ -1,6 +1,6 @@
 import abc
 from abc import ABCMeta
-from typing import Callable, Dict, List, Optional, Type
+from typing import Callable, Dict, List, Optional, Type, Union
 
 from apispec import APISpec
 from apispec.utils import OpenAPIVersion
@@ -8,6 +8,7 @@ from schematics import Model
 
 from apistrap.decorators import IgnoreDecorator, IgnoreParamsDecorator, SecurityDecorator, TagsDecorator
 from apistrap.errors import ApistrapExtensionError
+from apistrap.tags import TagData
 
 
 class SecurityScheme(metaclass=ABCMeta):
@@ -238,11 +239,21 @@ class Apistrap(metaclass=ABCMeta):
         self.security_schemes.append(scheme)
         self.spec.components.security_scheme(scheme.name, scheme.to_openapi_dict())
 
-    def tags(self, *tags: str):
+    def add_tag_data(self, tag: TagData) -> None:
+        """
+        Add information about a tag to the specification.
+        :param tag: data about the tag
+        """
+
+        spec = self.spec.to_dict()
+        if "tags" not in spec or tag.name not in map(lambda t: t["name"], spec["tags"]):
+            self.spec.tag(tag.to_dict())
+
+    def tags(self, *tags: Union[str, TagData]) -> TagsDecorator:
         """
         A decorator that adds tags to the OpenAPI specification of the decorated view function.
         """
-        return TagsDecorator(tags)
+        return TagsDecorator(self, tags)
 
     def ignore(self):
         """
diff --git a/apistrap/tags.py b/apistrap/tags.py
new file mode 100644
index 0000000..95bcd4e
--- /dev/null
+++ b/apistrap/tags.py
@@ -0,0 +1,16 @@
+from dataclasses import dataclass
+from typing import Optional
+
+
+@dataclass
+class TagData:
+    name: str
+    description: Optional[str] = None
+
+    def to_dict(self) -> dict:
+        result = {"name": self.name}
+
+        if self.description is not None:
+            result["description"] = self.description
+
+        return result


Test patch:
diff --git a/tests/test_tag_data.py b/tests/test_tag_data.py
new file mode 100644
index 0000000..a233fa7
--- /dev/null
+++ b/tests/test_tag_data.py
@@ -0,0 +1,22 @@
+from apistrap.flask import FlaskApistrap
+from apistrap.tags import TagData
+
+
+def test_tag_data(client, app):
+    oapi = FlaskApistrap()
+    oapi.add_tag_data(TagData("Tag"))
+    oapi.add_tag_data(TagData("Tag with description", "Description"))
+    oapi.init_app(app)
+
+    response = client.get("/spec.json")
+
+    assert len(response.json["tags"]) == 2
+
+    assert response.json["tags"][0] == {
+        "name": "Tag"
+    }
+
+    assert response.json["tags"][1] == {
+        "name": "Tag with description",
+        "description": "Description"
+    }
diff --git a/tests/test_tags.py b/tests/test_tags.py
index c634fb0..ddf92f7 100644
--- a/tests/test_tags.py
+++ b/tests/test_tags.py
@@ -1,6 +1,8 @@
 import pytest
 from flask import jsonify
 
+from apistrap.tags import TagData
+
 
 @pytest.fixture()
 def app_with_tags(app, flask_apistrap):
@@ -33,3 +35,29 @@ def test_repeated_tags(app_with_repeated_tags, client):
 
     assert "tags" in path
     assert sorted(path["tags"]) == ["Tag 1", "Tag 2", "Tag 3", "Tag 4"]
+
+
+@pytest.fixture(params=(True, False))
+def app_with_tag_data(app, flask_apistrap, request):
+    tag = TagData("Tag", "Description")
+
+    if request.param:
+        flask_apistrap.add_tag_data(tag)
+
+    @app.route("/", methods=["GET"])
+    @flask_apistrap.tags(tag)
+    def view():
+        return jsonify()
+
+
+def test_tag_data(app_with_tag_data, client):
+    response = client.get("/spec.json")
+    path = response.json["paths"]["/"]["get"]
+
+    assert "tags" in path
+    assert path["tags"] == ["Tag"]
+
+    assert response.json["tags"] == [{
+        "name": "Tag",
+        "description": "Description"
+    }]