Fastapi: [FEATURE] Optionally provide openapi spec as YAML

Created on 19 Mar 2020  路  9Comments  路  Source: tiangolo/fastapi

Is your feature request related to a problem

FastAPI can generate the OpenAPI spec as JSON. It would be nice if that could (optionally) be converted to YAML by FastAPI, because the spec is usually read by humans and sometimes, dev policies might require that the spec is made available in YAML format.

The solution you would like

You can already specifiy the openapi_url when creating a FastAPI instance. When the provided url ens with .yaml, a yaml should be generated instead of a JSON:

app = FastAPI(openapi_url='/openapi.yaml')

The method FastAPI.setup() would need to be patched to something like this:

    def setup(self) -> None:
        if self.openapi_url:
            if self.openapi_url.endswith('.yaml'):
                async def openapi(req: Request) -> Response:
                    yaml_str = StringIO()
                    yaml = YAML()
                    yaml.indent(mapping=2, sequence=4, offset=2)
                    yaml.dump(self.openapi(), yaml_str)
                    return Response(yaml_str.getvalue(), media_type='text/yaml')
            else:
                async def openapi(req: Request) -> JSONResponse:
                    return JSONResponse(self.openapi())

            self.add_route(self.openapi_url, openapi, include_in_schema=False)
            ...

This would add the dependency ruamel.yaml, but that could be made optional.

Describe alternatives you've considered

Manually converting the JSON file to YAML evertime the API changes.

Additional context

-

enhancement

Most helpful comment

Thanks @hjoukl for sharing your code, it definitely saved me time!

I would recommend using yaml.dump with the additional options sort_keys=False so that things are arranged in a coherent order and allow_unicode=True so that special characters don't get mangled.

I wrote the following additional code which displays a pretty-formatted YAML specification in the browser from /openapi.html:

from fastapi.responses import HTMLResponse

@app.get('/openapi.html', include_in_schema=False)
def pretty_yaml(request: Request) -> Response:
    return HTMLResponse(r"""

<!DOCTYPE html>
<!-- CDN links were obtained from https://cdnjs.com/libraries/prism/1.21.0 -->
<html>
<head>
  <meta charset="utf-8" />
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.21.0/themes/prism.css" integrity="sha512-jtWR3pdYjGwfw9df601YF6uGrKdhXV37c+/6VNzNctmrXoO0nkgHcS03BFxfkWycOa2P2Nw9Y9PCT9vjG9jkVg==" crossorigin="anonymous" />
</head>
<body>
<header data-plugin-header="file-highlight"></header>
  <pre data-src="openapi.yaml"></pre>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.21.0/components/prism-core.min.js" integrity="sha512-hqRrGU7ys5tkcqxx5FIZTBb7PkO2o3mU6U5+qB9b55kgMlBUT4J2wPwQfMCxeJW1fC8pBxuatxoH//z0FInhrA==" crossorigin="anonymous"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.21.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha512-ROhjG07IRaPZsryG77+MVyx3ZT5q3sGEGENoGItwc9xgvx+dl+s3D8Ob1zPdbl/iKklMKp7uFemLJFDRw0bvig==" crossorigin="anonymous"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.21.0/plugins/file-highlight/prism-file-highlight.min.js" integrity="sha512-DP0E4atVbD/CgElRtPoPSTEVDN7W5Xuy/JkOOwxAVhUePY30VMGNt63HLEQolVUc0XpIft8s+xEPVpWB+KX0VA==" crossorigin="anonymous"></script>
</body>
</html>

""")

All 9 comments

I鈥檇 glady provide a PR for this if you are interested in this feature.

Is this the wrong time to point out that json is valid yaml? 馃槃

Just kidding, I think this would be a nice feature too!

Thanks for the conversation here! :cake:

But adding YAML support by default would mean requiring another dependency just to change the format :slightly_frowning_face:

The good news is that you can do it in your app, for your use case.

You could remove the default OpenAPI URL: https://fastapi.tiangolo.com/tutorial/metadata/#openapi-url

And then you could use the tools described at https://fastapi.tiangolo.com/advanced/extending-openapi/ but instead of changing the default OpenAPI, using those same utils to generate the OpenAPI dict.

And then use those utility functions to generate the OpenAPI dict that would be returned as JSON, and instead create a new path operation with your custom YAML response returning your serialized OpenAPI.

Thanks for your reaply. I can understand that you don鈥檛 want to add another dependency. I will see if your idea works for us. If so, I could integrated the necessary changes into a cookiecutter template.

@tiangolo Thanks again for your suggestions. Since we needed to some other modifications to FastAPI, too, I created a subclass that implements the desired behavior:

from io import StringIO

from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.routing import APIRoute
from ruamel.yaml import YAML
from starlette.responses import Response


class UnicornAPI(FastAPI):
    def __init__(
        self,
        *,
        contact_name: str = "",
        contact_email: str = "",
        audience: str = "",
        **kwargs,
    ) -> None:
        super().__init__(**kwargs)
        self.contact = {
            'name': contact_name,
            'email': contact_email,
        }
        self.audience = audience
        self.openapi_yaml_str = ''

    def use_route_names_as_op_ids(self):  # pylint: disable=redefined-outer-name
        """Simplify operation IDs so that generated API clients have simpler function
        names.

        """
        for route in self.routes:
            if isinstance(route, APIRoute):
                route.operation_id = route.name  # in this case, 'read_items'

    def openapi(self) -> dict:
        # Change original method to include extra info fields and to generate YAML
        # from the openapi schema.
        if not self.openapi_schema:
            self.use_route_names_as_op_ids()
            self.openapi_schema = get_openapi(
                title=self.title,
                version=self.version,
                openapi_version=self.openapi_version,
                description=self.description,
                routes=self.routes,
                openapi_prefix=self.openapi_prefix,
            )
            self.openapi_schema['info']['contact'] = self.contact
            self.openapi_schema['info']['x-audience'] = self.audience

            yaml_str = StringIO()
            yaml = YAML()
            yaml.indent(mapping=2, sequence=4, offset=2)
            yaml.dump(self.openapi(), yaml_str)
            self.openapi_yaml_str = yaml_str.getvalue()

        return self.openapi_schema

    def openapi_yaml(self) -> str:
        # This is just a wrapper for the opanapi path operation
        self.openapi()
        return self.openapi_yaml_str

    def setup(self) -> None:
        # Override the openapi path operation to return YAML
        super().setup()
        if self.openapi_url:
            async def openapi() -> Response:
                return Response(self.openapi_yaml(), media_type='text/yaml')

            self.add_route(self.openapi_url, openapi, include_in_schema=False)

All API apps then use UnicornAPI instead of FastAPI :smile_cat: :unicorn:

Ah, nice! Thanks for sharing your solution and closing the issue @sscherfke ! :rocket:

Thanks for making this nice piece of software <3

Thanks a lot for sharing @sscherfke.

@tiangolo: Does it still hold true that an unwanted additional dependency would be needed for this? As per https://fastapi.tiangolo.com/#optional-dependencies fastapi has an (optional) dependency on pyyaml anyway, so this could also be done with pyaml, not needing ruamel.
So maybe this could indeed be provided out-of-the-box conditionally, if pyyaml is installed, and automagically get linked in the /doc and /redoc pages...

In case anyone stumbles upon this (like myself) here's a simple variant without inheritance:

from fastapi import FastAPI
from fastapi.responses import Response
import functools
import io
from pydantic import BaseModel
import yaml


# data model
class HelloResponse(BaseModel):
    message: str


app = FastAPI()

# add endpoints
# additional yaml version of openapi.json
@app.get('/openapi.yaml', include_in_schema=False)
@functools.lru_cache()
def read_openapi_yaml() -> Response:
    openapi_json= app.openapi()
    yaml_s = io.StringIO()
    yaml.dump(openapi_json, yaml_s)
    return Response(yaml_s.getvalue(), media_type='text/yaml')


@app.get("/hello", response_model=HelloResponse)
def read_hello():
    return {"message": "Hello, world!"}


@app.get("/hello/{name}", response_model=HelloResponse)
def read_hello_name(name: str):
    return {"message": f"Hello, {name}!"}

The lru_cache mimicks app.openapi() once-only schema generation for the json-to-yaml conversion. I don't know if this would work if there's any real async going on here (doesn't look like it from what I can see in app.openapi(), but I'm basically an async noob :-)).

Here's a gist that additionally hooks the .yaml OAS schema into the fastapi redoc page, instead of the normal .json: https://gist.github.com/hjoukl/790f95128e431396bcabf5ed39a5610b

Thanks @hjoukl for sharing your code, it definitely saved me time!

I would recommend using yaml.dump with the additional options sort_keys=False so that things are arranged in a coherent order and allow_unicode=True so that special characters don't get mangled.

I wrote the following additional code which displays a pretty-formatted YAML specification in the browser from /openapi.html:

from fastapi.responses import HTMLResponse

@app.get('/openapi.html', include_in_schema=False)
def pretty_yaml(request: Request) -> Response:
    return HTMLResponse(r"""

<!DOCTYPE html>
<!-- CDN links were obtained from https://cdnjs.com/libraries/prism/1.21.0 -->
<html>
<head>
  <meta charset="utf-8" />
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.21.0/themes/prism.css" integrity="sha512-jtWR3pdYjGwfw9df601YF6uGrKdhXV37c+/6VNzNctmrXoO0nkgHcS03BFxfkWycOa2P2Nw9Y9PCT9vjG9jkVg==" crossorigin="anonymous" />
</head>
<body>
<header data-plugin-header="file-highlight"></header>
  <pre data-src="openapi.yaml"></pre>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.21.0/components/prism-core.min.js" integrity="sha512-hqRrGU7ys5tkcqxx5FIZTBb7PkO2o3mU6U5+qB9b55kgMlBUT4J2wPwQfMCxeJW1fC8pBxuatxoH//z0FInhrA==" crossorigin="anonymous"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.21.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha512-ROhjG07IRaPZsryG77+MVyx3ZT5q3sGEGENoGItwc9xgvx+dl+s3D8Ob1zPdbl/iKklMKp7uFemLJFDRw0bvig==" crossorigin="anonymous"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.21.0/plugins/file-highlight/prism-file-highlight.min.js" integrity="sha512-DP0E4atVbD/CgElRtPoPSTEVDN7W5Xuy/JkOOwxAVhUePY30VMGNt63HLEQolVUc0XpIft8s+xEPVpWB+KX0VA==" crossorigin="anonymous"></script>
</body>
</html>

""")
Was this page helpful?
0 / 5 - 0 ratings