litestar-org / litestar

Production-ready, Light, Flexible and Extensible ASGI API framework | Effortlessly Build Performant APIs
https://litestar.dev/
MIT License
5.38k stars 364 forks source link

Enhancement: Add OpenAPI 3.0.3 support #300

Closed LonelyVikingMichael closed 2 years ago

LonelyVikingMichael commented 2 years ago

This would enable support for interactive Swagger documentation.

The downside is that this will become redundant once Swagger UI adopts OpenAPI 3.1.0.

timwedde commented 2 years ago

As far as I have experienced, the only thing standing between us supporting Swagger in Starlite is that Starlite (correctly) marks the schema as 3.1. SwaggerUI explicitly blocks this because they want to fully support the JSON schema in 3.1 first before allowing this, but if you have a 3.1 YAML schema, it already works perfectly fine with the JS bundle you can get right now.

I've implemented a controller to emulate the FastAPI behavior like this:

# -*- coding: utf-8 -*-
import orjson as json
from typing import Optional, Any
from starlite.datastructures import State
from starlite import Controller, Request, MediaType, get

class SwaggerController(Controller):
    """
    This is an internal controller that exists to provide the /docs route
    that is standard in FastAPI. Starlite only provides ReDoc by default, so
    we patch it in like this as a convenience feature.
    """

    path = "/docs"
    tags = ["docs"]

    def get_swagger_ui_html(
        self,
        openapi_url: str,
        title: str,
        swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui-bundle.js",
        swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui.css",
        swagger_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png",
        oauth2_redirect_url: Optional[str] = None,
        init_oauth: Optional[dict[str, Any]] = None,
        swagger_ui_parameters: Optional[dict[str, Any]] = None,
    ) -> str:
        current_swagger_ui_parameters = {
            "dom_id": "'#swagger-ui'",
            "layout": "'BaseLayout'",
            "deepLinking": "true",
            "showExtensions": "true",
            "showCommonExtensions": "true",
        }
        if swagger_ui_parameters:
            current_swagger_ui_parameters.update(swagger_ui_parameters)

        html = f"""
        <!DOCTYPE html>
        <html>
        <head>
        <link type="text/css" rel="stylesheet" href="{swagger_css_url}">
        <link rel="shortcut icon" href="{swagger_favicon_url}">
        <title>{title}</title>
        </head>
        <body>
        <div id="swagger-ui"></div>
        <script src="{swagger_js_url}"></script>
        <script>
        const ui = SwaggerUIBundle({{
            url: '{openapi_url}',
        """

        for key, value in current_swagger_ui_parameters.items():
            html += f"{key}: {value},\n"

        if oauth2_redirect_url:
            html += (
                f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
            )

        html += """
        presets: [
            SwaggerUIBundle.presets.apis,
            SwaggerUIBundle.SwaggerUIStandalonePreset
            ],
        })"""

        if init_oauth:
            html += f"""
            ui.initOAuth({json.dumps(init_oauth)})
            """

        html += """
        </script>
        </body>
        </html>
        """
        return html

    @get(media_type=MediaType.HTML, include_in_schema=False)
    def docs(self, request: Request, state: State) -> str:
        scheme = request.url.scheme if state.config.environment == "local" else "https" # app-specific, you would have to change this to a different detection mechanism
        if request.url.port:
            url = f"{scheme}://{request.url.hostname}:{request.url.port}/schema/openapi.yaml"
        else:
            url = f"{scheme}://{request.url.hostname}/schema/openapi.yaml"
        return self.get_swagger_ui_html(
            url,
            "API Docs",
            swagger_js_url="/static/swagger-ui-bundle.js",
        )

The above controller vendors a patched SwaggerUI bundle at /static that allows 3.1 to pass without an exception being thrown, and the rest is basically straight-up FastAPI Swagger handling. swagger-ui-bundle.js.zip

This could be made to work right now by implementing the controller like above and monkey-patching the openapi version in the schema that is served to the bundle on the frontend to be 3.0 instead of 3.1 specifically for the Swagger route. This would allow us to not have to vendor a patched JS bundle and we could freely use the CDN just like FastAPI does.

Goldziher commented 2 years ago

@LonelyVikingMichael can you test what @timwedde posted above? It would certainly save a huge amount of work if its possible to patch like this in a satisfactory way.

timwedde commented 2 years ago

Just looked into this a bit more so here's a working version that does not require vendoring a patched SwaggerUI bundle:

# -*- coding: utf-8 -*-
import copy
import orjson as json
from typing import Optional, Any
from starlite.datastructures import State
from starlite.enums import OpenAPIMediaType
from starlite import Controller, Request, MediaType, get
from openapi_schema_pydantic.v3.v3_1_0.open_api import OpenAPI

class SwaggerController(Controller):
    path = "/docs"
    tags = ["docs"]

    def get_swagger_ui_html(
        self,
        openapi_url: str,
        title: str,
        swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui-bundle.js",
        swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui.css",
        swagger_favicon_url: str = "https://starlite-api.github.io/starlite/images/starlite-favicon.ico",
        oauth2_redirect_url: Optional[str] = None,
        init_oauth: Optional[dict[str, Any]] = None,
        swagger_ui_parameters: Optional[dict[str, Any]] = None,
    ) -> str:
        current_swagger_ui_parameters = {
            "dom_id": "'#swagger-ui'",
            "layout": "'BaseLayout'",
            "deepLinking": "true",
            "showExtensions": "true",
            "showCommonExtensions": "true",
        }
        if swagger_ui_parameters:
            current_swagger_ui_parameters.update(swagger_ui_parameters)

        html = f"""
        <!DOCTYPE html>
        <html>
        <head>
        <link type="text/css" rel="stylesheet" href="{swagger_css_url}">
        <link rel="shortcut icon" href="{swagger_favicon_url}">
        <title>{title}</title>
        </head>
        <body>
        <div id="swagger-ui"></div>
        <script src="{swagger_js_url}"></script>
        <script>
        const ui = SwaggerUIBundle({{
            url: '{openapi_url}',
        """

        for key, value in current_swagger_ui_parameters.items():
            html += f"{key}: {value},\n"

        if oauth2_redirect_url:
            html += (
                f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
            )

        html += """
        presets: [
            SwaggerUIBundle.presets.apis,
            SwaggerUIBundle.SwaggerUIStandalonePreset
            ],
        })"""

        if init_oauth:
            html += f"""
            ui.initOAuth({json.dumps(init_oauth)})
            """

        html += """
        </script>
        </body>
        </html>
        """
        return html

    @get(
        path="/openapi.yaml",
        media_type=OpenAPIMediaType.OPENAPI_YAML,
        include_in_schema=False,
    )
    def retrieve_schema_yaml(self, request: Request) -> OpenAPI:
        schema = copy.deepcopy(request.app.openapi_schema)
        schema.openapi = "3.0.3"
        return schema

    @get(media_type=MediaType.HTML, include_in_schema=False)
    def docs(self, request: Request, state: State) -> str:
        if request.url.port:
            url = f"{request.url.scheme}://{request.url.hostname}:{request.url.port}/docs/openapi.yaml"
        else:
            url = f"{request.url.scheme}://{request.url.hostname}/docs/openapi.yaml"
        return self.get_swagger_ui_html(url, request.app.openapi_schema.info.title)

You can see that I simply "trick" Swagger into believing this is an OpenAPI 3.0 spec by overriding the version specifier. With this trick, SwaggerUI works as expected, as far as I have been able to validate, and you can use the regular CDN bundles without any tweaks.

Goldziher commented 2 years ago

Just looked into this a bit more so here's a working version that does not require vendoring a patched SwaggerUI bundle:

# -*- coding: utf-8 -*-
import copy
import orjson as json
from typing import Optional, Any
from starlite.datastructures import State
from starlite.enums import OpenAPIMediaType
from starlite import Controller, Request, MediaType, get
from openapi_schema_pydantic.v3.v3_1_0.open_api import OpenAPI

class SwaggerController(Controller):
    path = "/docs"
    tags = ["docs"]

    def get_swagger_ui_html(
        self,
        openapi_url: str,
        title: str,
        swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui-bundle.js",
        swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui.css",
        swagger_favicon_url: str = "https://starlite-api.github.io/starlite/images/starlite-favicon.ico",
        oauth2_redirect_url: Optional[str] = None,
        init_oauth: Optional[dict[str, Any]] = None,
        swagger_ui_parameters: Optional[dict[str, Any]] = None,
    ) -> str:
        current_swagger_ui_parameters = {
            "dom_id": "'#swagger-ui'",
            "layout": "'BaseLayout'",
            "deepLinking": "true",
            "showExtensions": "true",
            "showCommonExtensions": "true",
        }
        if swagger_ui_parameters:
            current_swagger_ui_parameters.update(swagger_ui_parameters)

        html = f"""
        <!DOCTYPE html>
        <html>
        <head>
        <link type="text/css" rel="stylesheet" href="{swagger_css_url}">
        <link rel="shortcut icon" href="{swagger_favicon_url}">
        <title>{title}</title>
        </head>
        <body>
        <div id="swagger-ui"></div>
        <script src="{swagger_js_url}"></script>
        <script>
        const ui = SwaggerUIBundle({{
            url: '{openapi_url}',
        """

        for key, value in current_swagger_ui_parameters.items():
            html += f"{key}: {value},\n"

        if oauth2_redirect_url:
            html += (
                f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
            )

        html += """
        presets: [
            SwaggerUIBundle.presets.apis,
            SwaggerUIBundle.SwaggerUIStandalonePreset
            ],
        })"""

        if init_oauth:
            html += f"""
            ui.initOAuth({json.dumps(init_oauth)})
            """

        html += """
        </script>
        </body>
        </html>
        """
        return html

    @get(
        path="/openapi.yaml",
        media_type=OpenAPIMediaType.OPENAPI_YAML,
        include_in_schema=False,
    )
    def retrieve_schema_yaml(self, request: Request) -> OpenAPI:
        schema = copy.deepcopy(request.app.openapi_schema)
        schema.openapi = "3.0.3"
        return schema

    @get(media_type=MediaType.HTML, include_in_schema=False)
    def docs(self, request: Request, state: State) -> str:
        if request.url.port:
            url = f"{request.url.scheme}://{request.url.hostname}:{request.url.port}/docs/openapi.yaml"
        else:
            url = f"{request.url.scheme}://{request.url.hostname}/docs/openapi.yaml"
        return self.get_swagger_ui_html(url, request.app.openapi_schema.info.title)

You can see that I simply "trick" Swagger into believing this is an OpenAPI 3.0 spec by overriding the version specifier. With this trick, SwaggerUI works as expected, as far as I have been able to validate, and you can use the regular CDN bundles without any tweaks.

Do you want to add a PR extending starlite.openapi.OpenAPIControllers? I'd suggest to simply add a swagger_ui method to it, keeping it as simple as possible (no extra code we don't support for now). We can the. Test this branch using the example project.

timwedde commented 2 years ago

Can do, I might get to it this evening already.

Goldziher commented 2 years ago

Cheers @timwedde - im testing your PR

Goldziher commented 2 years ago

I verified this works @timwedde -- please see https://github.com/starlite-api/starlite/pull/303

Goldziher commented 2 years ago

Swagger-UI support has been released in v1.7.1, and the docs have been accordingly updated.