Repository: Neoteroi/BlackSheep (python)

Issue:
Verify requirements for schema names for generics
In the following example, BlackSheep generates an OpenAPI Schema containing (_[_ and _]_), when using [Generic models](https://docs.pydantic.dev/latest/concepts/models/#generic-models).

The following is still true:
_$ref values must be RFC3986-compliant percent-encoded URIs_

```python
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID

from openapidocs.v3 import Info
from pydantic import BaseModel

from blacksheep import Application, get
from blacksheep.server.openapi.v3 import OpenAPIHandler


class DataModel(BaseModel):
    number: int


class Response[DataT](BaseModel):
    data: DataT


app = Application()


docs = OpenAPIHandler(info=Info(title="Example API", version="0.0.1"))
docs.bind_app(app)


@dataclass
class Cat:
    id: UUID

    name: str
    creation_time: datetime


@get("/example")
def generic_example() -> Response[Cat]: ...
```

```yaml
openapi: 3.1.0
info:
    title: Example API
    version: 0.0.1
paths:
    /example:
        get:
            responses:
                '200':
                    description: Success response
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Response[Cat]'
            operationId: generic_example
servers: []
components:
    schemas:
        Cat:
            properties:
                id:
                    format: uuid
                    title: Id
                    type: string
                name:
                    title: Name
                    type: string
                creation_time:
                    format: date-time
                    title: Creation Time
                    type: string
            required:
            - id
            - name
            - creation_time
            title: Cat
            type: object
        Response[Cat]:
            properties:
                data:
                    $ref: '#/components/schemas/Cat'
            required:
            - data
            title: Response[Cat]
            type: object
tags: []
```


Pull request:
Fix #550
Ensure that all generated `$ref` values for the OpenAPI documentation contain only [allowed characters](https://swagger.io/docs/specification/v3_0/using-ref/).

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cada4a9..5aec043 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   Contribution by @aldem, who reported the issue and provided the solution.
 - To better support `@validate_call`, automatically configures a default
   exception handler for `pydantic.ValidationError` when Pydantic is installed.
+- Fix [#550](https://github.com/Neoteroi/BlackSheep/issues/550). Ensure that
+  all generated `$ref` values contain only [allowed characters](https://swagger.io/docs/specification/v3_0/using-ref/).
 
 ## [2.2.0] - 2025-04-28 🎉
 
diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py
index 3ba26a4..5a46bc6 100644
--- a/blacksheep/server/openapi/common.py
+++ b/blacksheep/server/openapi/common.py
@@ -8,6 +8,7 @@ potentially in the future v4, if it will be so different from v3.
 """
 
 import json
+import re
 from abc import ABC, abstractmethod
 from dataclasses import dataclass
 from enum import Enum
@@ -174,6 +175,81 @@ class OpenAPIEvents:
         self.on_paths_created = OpenAPIEvent(context)
 
 
+class DefaultSerializer(Serializer):
+    """
+    Default OAD serializer used by BlackSheep.
+    BlackSheep generates OpenAPI Specification files containing
+    reusable schemas for handled types and references to them.
+    It only supports local references (references to schemas
+    defined in the same specification document).
+    This serializer ensures that $refs contain only allowed characters.
+    """
+
+    def to_obj(self, item: Any) -> Any:
+        data = super().to_obj(item)
+        self.sanitize_refs(data)
+        self.sanitize_components_names(data)
+        return data
+
+    def sanitize_name(self, value: str) -> str:
+        """
+        Returns a sanitized name for a schema, replacing
+        characters that are not allowed for $ref.
+
+        https://swagger.io/docs/specification/v3_0/using-ref/
+        """
+        if "[" in value:
+            value = self.get_type_name_for_generic(value)
+        beginning, sep, name = value.rpartition("/")
+        name = name.replace("~", "~0").replace("/", "~1")
+        name = re.sub("[^a-zA-Z0-9-_]", "_", name)
+        return beginning + sep + name
+
+    def sanitize_components_names(self, data):
+        try:
+            schemas = data["components"]["schemas"]
+        except KeyError:
+            pass
+        else:
+            to_correct: list[str] = [
+                key for key in schemas.keys() if not re.match("^[a-zA-Z0-9-_.]+$", key)
+            ]
+            for key in to_correct:
+                schemas[self.sanitize_name(key)] = schemas[key]
+                del schemas[key]
+
+    def sanitize_refs(self, data):
+        for key, value in data.items():
+            if isinstance(value, dict):
+                self.sanitize_refs(value)
+            elif isinstance(value, list):
+                for item in value:
+                    if isinstance(item, dict):
+                        self.sanitize_refs(item)
+            elif isinstance(key, str) and key == "$ref":
+                assert isinstance(value, str)
+                to_replace = None
+                if not re.match("^[a-zA-Z0-9-_.]+$", value):
+                    to_replace = self.sanitize_name(value)
+                if to_replace:
+                    data["$ref"] = to_replace
+
+    def get_type_name_for_generic(self, name: str) -> str:
+        """
+        This method returns a type name for a generic type.
+        The following is more for backward compatibility in BlackSheep.
+        """
+        # Note: by default returns a string respectful of this requirement:
+        # $ref values must be RFC3986-compliant percent-encoded URIs
+        # Therefore, a generic that would be expressed in Python: Example[Foo, Bar]
+        # and C# or TypeScript Example<Foo, Bar>
+        # Becomes here represented as: ExampleOfFooAndBar
+        if "[" not in name:
+            return name
+        name = name.replace("[", "Of").replace("]", "")
+        return re.sub(r",\s?", "And", name)
+
+
 class APIDocsHandler(Generic[OpenAPIRootType], ABC):
     """
     Provides methods to handle the documentation for an API.
@@ -437,7 +513,7 @@ class APIDocsHandler(Generic[OpenAPIRootType], ABC):
     async def build_docs(self, app: Application) -> None:
         docs = self.generate_documentation(app)
         self.on_docs_generated(docs)
-        serializer = self._serializer or Serializer()
+        serializer = self._serializer or DefaultSerializer()
 
         ui_options = UIOptions(
             spec_url=self.get_spec_path(), page_title=self.get_ui_page_title()
diff --git a/blacksheep/server/openapi/v3.py b/blacksheep/server/openapi/v3.py
index 1b156f9..e2d2bee 100644
--- a/blacksheep/server/openapi/v3.py
+++ b/blacksheep/server/openapi/v3.py
@@ -369,7 +369,7 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
             yaml_spec_path=yaml_spec_path,
             preferred_format=preferred_format,
             anonymous_access=anonymous_access,
-            serializer=serializer or Serializer(),
+            serializer=serializer,
         )
         self.info = info
         self._tags = tags


Test patch:
diff --git a/tests/test_application.py b/tests/test_application.py
index 33441a1..dbf7e85 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -1,16 +1,18 @@
 import json
 import os
+import re
 import sys
 from base64 import urlsafe_b64decode, urlsafe_b64encode
 from dataclasses import dataclass
 from datetime import date, datetime
 from functools import wraps
-from typing import Annotated, Any, Dict, List, Optional, TypeVar
+from typing import Annotated, Any, Dict, Generic, List, Optional, TypeVar
 from uuid import UUID, uuid4
 
 import pytest
 from guardpost import AuthenticationHandler, Identity, User
 from openapidocs.v3 import Info
+from pydantic import VERSION as PYDANTIC_LIB_VERSION
 from pydantic import BaseModel, Field, ValidationError
 from rodi import Container, inject
 
@@ -4051,8 +4053,6 @@ async def test_application_sub_router_normalization():
 
 
 async def test_pydantic_validate_call_scenario():
-    from pydantic import VERSION as PYDANTIC_LIB_VERSION
-
     app = FakeApplication(show_error_details=True, router=Router())
     get = app.router.get
 
@@ -4090,3 +4090,50 @@ async def test_pydantic_validate_call_scenario():
 
             if int(PYDANTIC_LIB_VERSION[0]) > 1:
                 assert response_text in (await response.text())
+
+
+@pytest.mark.skipif(
+    int(PYDANTIC_LIB_VERSION[0]) < 2, reason="Run this test only with Pydantic v2"
+)
+async def test_refs_characters_handling():
+    app = FakeApplication(show_error_details=True, router=Router())
+    get = app.router.get
+
+    # TODO: when support for Python < 3.12 is dropped,
+    # the following can be rewritten without TypeVar, like:
+    #
+    # class Response[DataT](BaseModel):
+    #
+
+    DataT = TypeVar("DataT")
+
+    class Response(BaseModel, Generic[DataT]):
+        data: DataT
+
+    class Cat(BaseModel):
+        id: int
+        name: str
+        creation_time: datetime
+
+    docs = OpenAPIHandler(info=Info(title="Example API", version="0.0.1"))
+    docs.bind_app(app)
+
+    @get("/cat")
+    def generic_example() -> Response[Cat]: ...
+
+    @get("/cats")
+    def generic_list_example() -> list[Response[Cat]]: ...
+
+    await app.start()
+
+    json_docs = docs._json_docs.decode("utf8")
+    yaml_docs = docs._yaml_docs.decode("utf8")
+    spec = json.loads(json_docs)
+
+    # "$ref": "#/components/schemas/Cat"
+    for key in spec["components"]["schemas"].keys():
+        assert re.match(
+            "^[a-zA-Z0-9-_.]+$", key
+        ), "$ref values must match /^[a-zA-Z0-9-_.]+$/"
+        assert f'"$ref": "#/components/schemas/{key}"' in json_docs
+        assert f"$ref: '#/components/schemas/{key}'" in yaml_docs
diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py
index a0a7c00..01d9983 100644
--- a/tests/test_openapi_v3.py
+++ b/tests/test_openapi_v3.py
@@ -35,6 +35,7 @@ from blacksheep.server.bindings import FromForm
 from blacksheep.server.controllers import APIController
 from blacksheep.server.openapi.common import (
     ContentInfo,
+    DefaultSerializer,
     EndpointDocs,
     OpenAPIEndpointException,
     ResponseInfo,
@@ -3744,3 +3745,17 @@ components:
 
     for fragment in expected_fragments:
         assert fragment.strip() in yaml
+
+
+@pytest.mark.parametrize(
+    "name,result",
+    [
+        ("Example[Cat]", "ExampleOfCat"),
+        ("Union[A, B, C]", "UnionOfAAndBAndC"),
+        ("List[Union[A, B, C]]", "ListOfUnionOfAAndBAndC"),
+        ("Dict[str, int]", "DictOfstrAndint"),
+    ],
+)
+def test_default_serializer_sanitize_name(name, result):
+    serializer = DefaultSerializer()
+    assert serializer.get_type_name_for_generic(name) == result