Repository: Neoteroi/BlackSheep
Pull request #349 (2023-05-02 20:26:08)

Support security defenition in OpenAPI docs
Add to APIDocsHandler __call__ security parameter

```python
    @docs(
        security=[
            SecurityInfo("basicAuth", []),
            SecurityInfo("bearerAuth", ["read:home", "write:home"]),
        ]
    )
```

Add to OpenAPIHandler constructor security_schemes parameter

```python
    docs = OpenAPIHandler(
        info=Info(
            title="Example API",
            version="0.0.1",
        ),
        security_schemes={
            "oauth2": OAuth2Security(
                flows=OAuthFlows(
                    implicit=OAuthFlow(
                        authorization_url="https://example.com/oauth2/authorize",
                        token_url="https://example.com/oauth2/token",
                        refresh_url="https://example.com/oauth2/refresh",
                        scopes={
                            "read:cats": "Read your cats",
                            "write:cats": "Write your cats",
                        },
                    )
                ),
                description="OAuth2 Auth",
            ),
        },
    )
```

OpenAPI documentation - https://swagger.io/docs/specification/authentication/

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/349?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`e152571`)](https://app.codecov.io/gh/Neoteroi/BlackSheep/commit/e152571d2d5a9281120eae765e6a51adaf2b105b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.70% compared to head [(`1d98b7b`)](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/349?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.71%.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main     #349   +/-   ##
=======================================
  Coverage   97.70%   97.71%           
=======================================
  Files          66       66           
  Lines        6181     6201   +20     
=======================================
+ Hits         6039     6059   +20     
  Misses        142      142           
```


| [Impacted Files](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/349?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) | Coverage Δ | |
|---|---|---|
| [blacksheep/server/openapi/common.py](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/349?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvb3BlbmFwaS9jb21tb24ucHk=) | `97.46% <100.00%> (+0.05%)` | :arrow_up: |
| [blacksheep/server/openapi/v3.py](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/349?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvb3BlbmFwaS92My5weQ==) | `86.93% <100.00%> (+0.41%)` | :arrow_up: |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/349?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).

tyzhnenko: @RobertoPrevato I've faced that BlackSheep can't produce OpenAPI documentation with a security description. As a result Swagger UI can't make requests for endpoints that require auth.

The PR contains improvements for the OpenAPI documentation generator to handle this problem. 


RobertoPrevato: Hi @tyzhnenko 
Thank You for helping with this. This is something I should have documented better, having time. I didn´t document it more just because time is limited and I need to prioritize.

This is mentioned too briefly in the documentation, here https://www.neoteroi.dev/blacksheep/openapi/#altering-the-specification-upon-creation but it is possible to edit the OpenAPI Specification upon creation, to do pretty much anything with it.

For example, to configure security:

```python
from blacksheep import Application
from blacksheep.server.openapi.v3 import OpenAPIHandler
from openapidocs.v3 import Info, OpenAPI, Security, SecurityRequirement

app = Application()


class MyOpenAPIHandler(OpenAPIHandler):
    def on_docs_generated(self, docs: OpenAPI) -> None:
        docs.security = Security([SecurityRequirement("basic", [])])


docs = MyOpenAPIHandler(info=Info(title="Example API", version="0.0.1"))
docs.bind_app(app)


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, port=44555)

if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, port=44555)
```

OpenAPI Specification is vast and there are so many options that not everything should be covered within the `OpenAPIHandler` constructor. The main responsibility of this class is to help with whatever can be generated automatically using the information that is available to the web framework. Then the classes in `essentials-openapi` can be used to describe a specification and generate YAML / JSON.

I try to review your PR tomorrow - I didn´t have time today.
tyzhnenko: Hi, @RobertoPrevato

I see your points. 

I wanted to make this automatically just using _auth_ attribute of handlers but I didn't find a way unfortunately. Due OpenAPI schema needs a name and scopes for an operator security field we can't use just _handler.auth_ to fill everything automatically.

It makes sense when some endpoints require auth and some can be called anonymously.

RobertoPrevato: > I wanted to make this automatically just using _auth_ attribute of handlers but I didn't find a way unfortunately. Due OpenAPI schema needs a name and scopes for an operator security field we can't use just _handler.auth_ to fill everything automatically.

Absolutely, I wanted to do the same, but it's a complex topic and it would require a big amount of time to generate auth specs automatically. Not even web frameworks baked by big team working for big corporations offer this feature. Scopes are not always needed because they are only for oauth2 security, but it's anyway challenging to cover all scenarios (basic and API Key would be relatively easy). Another challenge when you use OAuth2, is that the Swagger UI has a dedicated client app registration which is not the same as the app registration used by the web API being documented. There is also the need to keep the web framework itself abstracted away and agnostic of a specific _way of documenting_ APIs, consider:

- OpenAPI Specification v2
- OpenAPI Specification v3
- [AsyncAPI Speficiation v2](https://www.asyncapi.com/docs/reference/specification/v2.0.0)

Both OpenAPI and AsyncAPI are part of the Linux Foundation so they are also both equally legitimate citizens.

> 
> It makes sense when some endpoints require auth and some can be called anonymously.

True.
techntools: I am gunning for api first approach. Please check this [discussion](https://github.com/Neoteroi/BlackSheep/discussions/344).
RobertoPrevato: > I am gunning for api first approach. Please check this [discussion](https://github.com/Neoteroi/BlackSheep/discussions/344).

I already apply API First approach to my web apps and dedicated a good deal of time supporting automatic generation of OpenAPI Documentation in blacksheep. Time is limited and it's not possible to cover all possible scenarios, though. In the last days for example I started working on a blacksheep-cli and project templates to support bootstrapping new projects faster + configuration strategy.
techntools: You are right that time is limited.

I think spec first instead of api first is the right choice of words for what i was trying to say.

But can not cover all scenarios and framework does offer nice ways to cover scenarios not built into it.

techntools: Would the cli support management command like django does ? I find them useful.

Patch:
diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py
index 3429eb0..08c2fe7 100644
--- a/blacksheep/server/openapi/common.py
+++ b/blacksheep/server/openapi/common.py
@@ -101,6 +101,12 @@ class ResponseInfo:
     content: Optional[List[ContentInfo]] = None
 
 
+@dataclass
+class SecurityInfo:
+    name: str
+    value: List[str]
+
+
 ResponseStatusType = Union[int, str, HTTPStatus]
 
 
@@ -123,6 +129,7 @@ class EndpointDocs:
     ignored: Optional[bool] = None
     deprecated: Optional[bool] = None
     on_created: Optional[Callable[[Any, Any], None]] = None
+    security: Optional[List[SecurityInfo]] = None
 
 
 OpenAPIRootType = TypeVar("OpenAPIRootType", bound=OpenAPIRoot)
@@ -188,6 +195,7 @@ class APIDocsHandler(Generic[OpenAPIRootType], ABC):
         ignored: Optional[bool] = None,
         deprecated: Optional[bool] = None,
         on_created: Optional[Callable[[Any, Any], None]] = None,
+        security: Optional[List[SecurityInfo]] = None,
     ) -> Any:
         def decorator(fn):
             if doc:
@@ -204,6 +212,7 @@ class APIDocsHandler(Generic[OpenAPIRootType], ABC):
                 ignored=ignored,
                 deprecated=deprecated,
                 on_created=on_created,
+                security=security,
             )
             return fn
 
diff --git a/blacksheep/server/openapi/v3.py b/blacksheep/server/openapi/v3.py
index 326dfcf..93b82ba 100644
--- a/blacksheep/server/openapi/v3.py
+++ b/blacksheep/server/openapi/v3.py
@@ -12,12 +12,16 @@ from uuid import UUID
 
 from openapidocs.common import Format
 from openapidocs.v3 import (
+    APIKeySecurity,
     Components,
     Example,
     Header,
+    HTTPSecurity,
     Info,
     MediaType,
+    OAuth2Security,
     OpenAPI,
+    OpenIdConnectSecurity,
     Operation,
     Parameter,
     ParameterLocation,
@@ -26,7 +30,14 @@ from openapidocs.v3 import (
     RequestBody,
 )
 from openapidocs.v3 import Response as ResponseDoc
-from openapidocs.v3 import Schema, Server, Tag, ValueFormat, ValueType
+from openapidocs.v3 import (
+    Schema,
+    SecurityRequirement,
+    Server,
+    Tag,
+    ValueFormat,
+    ValueType,
+)
 
 from blacksheep.server.bindings import (
     Binder,
@@ -262,6 +273,14 @@ class PydanticModelTypeHandler(ObjectTypeHandler):
         ]
 
 
+SecuritySchemes = Union[
+    OAuth2Security,
+    OpenIdConnectSecurity,
+    HTTPSecurity,
+    APIKeySecurity,
+]
+
+
 class OpenAPIHandler(APIDocsHandler[OpenAPI]):
     """
     Handles the automatic generation of OpenAPI Documentation, specification v3
@@ -279,6 +298,12 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
         preferred_format: Format = Format.JSON,
         anonymous_access: bool = True,
         tags: Optional[List[Tag]] = None,
+        security_schemes: Optional[
+            Dict[
+                str,
+                SecuritySchemes,
+            ]
+        ] = None,
     ) -> None:
         super().__init__(
             ui_path=ui_path,
@@ -300,6 +325,7 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
         self._binder_docs: Dict[
             Type[Binder], Iterable[Union[Parameter, Reference]]
         ] = {}
+        self.security_schemes = security_schemes or {}
 
     @property
     def object_types_handlers(self) -> List[ObjectTypeHandler]:
@@ -333,6 +359,7 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
 
     def generate_documentation(self, app: Application) -> OpenAPI:
         self._optimize_binders_docs()
+        self._generate_security_schemes()
         paths = self.get_paths(app)
         return OpenAPI(
             info=self.info,
@@ -393,6 +420,16 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
             f"Cannot obtain a name for object_type parameter: {object_type!r}"
         )
 
+    def _generate_security_schemes(self):
+        for name, scheme in self.security_schemes.items():
+            self.register_security_scheme(name, scheme)
+
+    def register_security_scheme(self, name: str, scheme: SecuritySchemes):
+        if self.components.security_schemes is None:
+            self.components.security_schemes = {}
+
+        self.components.security_schemes[name] = scheme
+
     def register_schema_for_type(self, object_type: Type) -> Reference:
         stored_ref = self._get_stored_reference(object_type, None)
         if stored_ref:
@@ -781,6 +818,13 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
 
         return list(parameters.values())
 
+    def get_handler_security(self, handler: Any) -> Optional[List[SecurityRequirement]]:
+        docs = self.get_handler_docs(handler)
+        if docs and docs.security:
+            return [SecurityRequirement(s.name, s.value) for s in docs.security]
+
+        return None
+
     def _get_media_type_from_content_doc(self, content_doc: ContentInfo) -> MediaType:
         media_type = MediaType()
 
@@ -994,6 +1038,7 @@ class OpenAPIHandler(APIDocsHandler[OpenAPI]):
                     request_body=self.get_request_body(handler),
                     deprecated=self.is_deprecated(handler),
                     tags=self.get_handler_tags(handler),
+                    security=self.get_handler_security(handler),
                 )
                 if docs and docs.on_created:
                     docs.on_created(self, operation)


Test patch:
diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py
index acc5642..371dd99 100644
--- a/tests/test_openapi_v3.py
+++ b/tests/test_openapi_v3.py
@@ -6,7 +6,20 @@ from uuid import UUID
 
 import pytest
 from openapidocs.common import Format, Serializer
-from openapidocs.v3 import Info, Reference, Schema, ValueFormat, ValueType
+from openapidocs.v3 import (
+    APIKeySecurity,
+    HTTPSecurity,
+    Info,
+    OAuth2Security,
+    OAuthFlow,
+    OAuthFlows,
+    OpenIdConnectSecurity,
+    ParameterLocation,
+    Reference,
+    Schema,
+    ValueFormat,
+    ValueType,
+)
 from pydantic import BaseModel, HttpUrl, validator
 from pydantic.generics import GenericModel
 from pydantic.types import NegativeFloat, PositiveInt, condecimal, confloat, conint
@@ -19,6 +32,7 @@ from blacksheep.server.openapi.common import (
     EndpointDocs,
     OpenAPIEndpointException,
     ResponseInfo,
+    SecurityInfo,
 )
 from blacksheep.server.openapi.exceptions import (
     DuplicatedContentTypeDocsException,
@@ -1881,6 +1895,16 @@ async def test_schema_registration(docs: OpenAPIHandler, serializer: Serializer)
     def home() -> A:
         ...
 
+    @docs(
+        security=[
+            SecurityInfo("basicAuth", []),
+            SecurityInfo("bearerAuth", ["read:home", "write:home"]),
+        ]
+    )
+    @app.route("/", methods=["POST"])
+    def auth_home() -> A:
+        ...
+
     docs.bind_app(app)
     await app.start()
 
@@ -1904,6 +1928,20 @@ paths:
                             schema:
                                 $ref: '#/components/schemas/A'
             operationId: home
+        post:
+            responses:
+                '200':
+                    description: Success response
+                    content:
+                        application/json:
+                            schema:
+                                $ref: '#/components/schemas/A'
+            operationId: auth_home
+            security:
+            -   basicAuth: []
+            -   bearerAuth:
+                - read:home
+                - write:home
 components:
     schemas:
         A:
@@ -2610,7 +2648,45 @@ async def test_sorting_api_controllers_tags(serializer: Serializer):
     get = app.controllers_router.get
     post = app.controllers_router.post
 
-    docs = OpenAPIHandler(info=Info(title="Example API", version="0.0.1"))
+    docs = OpenAPIHandler(
+        info=Info(
+            title="Example API",
+            version="0.0.1",
+        ),
+        security_schemes={
+            "basicAuth": HTTPSecurity(
+                scheme="basic",
+                description="Basic Auth",
+            ),
+            "bearerAuth": HTTPSecurity(
+                scheme="bearer",
+                description="Bearer Auth",
+            ),
+            "apiKeyAuth": APIKeySecurity(
+                in_=ParameterLocation.HEADER,
+                name="X-API-Key",
+                description="API Key Auth",
+            ),
+            "openID": OpenIdConnectSecurity(
+                open_id_connect_url="https://example.com",
+                description="OIDC Auth",
+            ),
+            "oauth2": OAuth2Security(
+                flows=OAuthFlows(
+                    implicit=OAuthFlow(
+                        authorization_url="https://example.com/oauth2/authorize",
+                        token_url="https://example.com/oauth2/token",
+                        refresh_url="https://example.com/oauth2/refresh",
+                        scopes={
+                            "read:cats": "Read your cats",
+                            "write:cats": "Write your cats",
+                        },
+                    )
+                ),
+                description="OAuth2 Auth",
+            ),
+        },
+    )
     docs.bind_app(app)
 
     @dataclass
@@ -2777,6 +2853,35 @@ components:
         Cat:
             type: object
             properties: {}
+    securitySchemes:
+        basicAuth:
+            scheme: basic
+            type: http
+            description: Basic Auth
+        bearerAuth:
+            scheme: bearer
+            type: http
+            description: Bearer Auth
+        apiKeyAuth:
+            name: X-API-Key
+            in: header
+            type: apiKey
+            description: API Key Auth
+        openID:
+            openIdConnectUrl: https://example.com
+            type: openIdConnect
+            description: OIDC Auth
+        oauth2:
+            flows:
+                implicit:
+                    scopes:
+                        read:cats: Read your cats
+                        write:cats: Write your cats
+                    authorizationUrl: https://example.com/oauth2/authorize
+                    tokenUrl: https://example.com/oauth2/token
+                    refreshUrl: https://example.com/oauth2/refresh
+            type: oauth2
+            description: OAuth2 Auth
 tags:
 -   name: Cats
 -   name: Dogs