Export an OpenAPI specification from your FastAPI app
Tags: python, fastapi, webdev, api
Published: 2023-05-19 08:14:50

[FastAPI](https://fastapi.tiangolo.com/) is a modern Python web framework for building APIs. FastAPI is a great choice for building simple APIs, and it comes with built-in support for generating OpenAPI documentation.

In this post we will look at how to generate and extract the [OpenAPI specification](https://www.doctave.com/blog/2023/02/21/what-is-openapi.html) from a FastAPI project.

A simple single-file FastAPI example taken from [the official docs](https://fastapi.tiangolo.com/tutorial/response-model/) will suffice as our testbed. The same workflow will also work for larger projects with routers.

## Step 1: Add Tags and Metadata

Chances are that you'll use the OpenAPI spec to generate documentation or code. It is important to add metadata to your FastAPI app so that the generated OpenAPI spec is complete.

Firstly, you should tag our endpoints with `tags` to make sure they are grouped in logical operations. This example does not use [routers](https://fastapi.tiangolo.com/tutorial/bigger-applications/#apirouter), but if you do, you need to tag the router instead of the endpoint.

Tags are used by documentation and code generators to group endpoints together. Tags may include spaces and special characters, but we recommend to keep the tags simple. It is common to use either lowercase or Capital Case for tags, like `Items` in our example.

In addition to tags, we'll add a `description` and `version` metadata to our FastAPI app instance. The `description` and `version` will be used in the generated OpenAPI docs on the overview page. You can find the full list of [metadata parameters](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-api) in the FastAPI docs if you need to include additional details in your specification.

The full example `main.py` now looks like this:

```py
# main.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(description="Example app", version="0.1.0")


class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None
    tags: list[str] = []


@app.post("/items/", tags=["Items"])
async def create_item(item: Item) -> Item:
    return item


@app.get("/items/", tags=["Items"])
async def read_items() -> list[Item]:
    return [
        Item(name="Portal Gun", price=42.0),
        Item(name="Plumbus", price=32.0),
    ]
```

This example requires `pip install fastapi[all]` and `pip install pydantic` to run.

## Step 2: Create the Export Script

By default FastAPI will generate OpenAPI docs under `/docs`. You can try this out by running the app and navigating to `http://localhost:8000/docs`.

It is possible to get the OpenAPI JSON directly by navigating to `/openapi.json`, but we'll want to extract the document programmatically in order to be able to automate the process. FastAPI does not support exporting the OpenAPI specification directly, but we'll use a small script to extract it.

Create a file `extract-openapi.py` with the following contents:

```py
# extract-openapi.py
import argparse
import json
import sys
import yaml
from uvicorn.importer import import_from_string

parser = argparse.ArgumentParser(prog="extract-openapi.py")
parser.add_argument("app",       help='App import string. Eg. "main:app"', default="main:app")
parser.add_argument("--app-dir", help="Directory containing the app", default=None)
parser.add_argument("--out",     help="Output file ending in .json or .yaml", default="openapi.yaml")

if __name__ == "__main__":
    args = parser.parse_args()

    if args.app_dir is not None:
        print(f"adding {args.app_dir} to sys.path")
        sys.path.insert(0, args.app_dir)

    print(f"importing app from {args.app}")
    app = import_from_string(args.app)
    openapi = app.openapi()
    version = openapi.get("openapi", "unknown version")

    print(f"writing openapi spec v{version}")
    with open(args.out, "w") as f:
        if args.out.endswith(".json"):
            json.dump(openapi, f, indent=2)
        else:
            yaml.dump(openapi, f, sort_keys=False)

    print(f"spec written to {args.out}")
```

What this script does is import the app from the given import string, and then call `app.openapi()` to get the OpenAPI spec. The spec is then written to the given output file.

You can invoke help to see the available options:

```shell
$ python3 export-openapi.py --help
usage: extract-openapi.py [-h] [--app-dir APP_DIR] [--out OUT] app

positional arguments:
app App import string. Eg. "main:app"

options:
-h, --help show this help message and exit
--app-dir APP_DIR Directory containing the app
--out OUT Output file ending in .json or .yaml
```

If you have trouble with the `uvicorn` import, make sure you have installed the `fastapi[all]` package, which includes uvicorn, or that you have `uvicorn` installed.

## Step 3: Export OpenAPI spec from FastAPI

### Running the script

To run the [export script](#step-2-create-an-export-script), you need to know the import string of your FastAPI app. *The import string is is what you pass to uvicorn when running your app, like `uvicorn main:app`*.

This depends on where your code is located, and what the FastAPI instance is called. See below for tips on how to determine the import string.

```shell
# Run the script by passing the import string of your FastAPI app
# If you don't know the import string, see below for examples
$ python extract-openapi.py main:app
```

This should create an `openapi.json` or `openapi.yaml` file in your current directory.

### Example case: simple project structure

In our example, we have a project structure like this:

```shell
/my_project
├── extract-openapi.py
└── main.py
```

Our FastAPI instance is `app` since we wrote `app = FastAPI()` in `main.py`. We also have a single file `main.py` in the current directory, so the import string is `main:app`.

To extract the OpenAPI spec we do

```shell
$ python extract-openapi.py main:app
```

### Example case: nested project structure

For larger applications, the project structure is often more nested, like this:
```shell
/my_project
├── extract-openapi.py
└── myapp
    └── main.py
```

In this case we'll have to add the module name, `myapp`, to the import string. The import string is now `myapp.main:app`. Alternatively, if you are having trouble with this, you can use the `--app-dir` argument to specify the directory containing the entrypoint of your application.

In this case, to extract the OpenAPI spec we do

```shell
$ python extract-openapi.py myapp.main:app

# or alternatively

$ python extract-openapi.py --app-dir myapp main:app
```

You should now have the `openapi.json` or `openapi.yaml` file in your current directory.

## Step 4: Automate in your CI/CD pipeline (optional)

How you'll integrate the extraction to your CI/CD depends on what you are trying to accomplish. The three most common ways to approach this are:
* Extract the spec locally and commit it to your repository. Let CI/CD verify the committed spec is up-to-date.
* Extract the spec as part of your CI/CD pipeline, and use the spec as a temporary file to accomplish something (eg. generate a client).
* Extract the spec as part of your CI/CD pipeline, and commit the generated spec to your repository, when merging to main.

The benefit with using a script is that it can also be run locally. Locally committing is often a safe and straight-forward approach, but may occasionally make merging more difficult. If, however, you only need to generate the OpenAPI spec as part of your CI/CD pipeline, you should also consider dedicated GitHub Actions.

As an example, we'll demonstrate a GitHub Actions job, which verifies that the committed spec matches a generated one:

```yaml
# .github/workflows/main.yml
name: CI

on: [push]

jobs:
   extract-openapi:
      runs-on: ubuntu-latest
      steps:
         - uses: actions/checkout@v2

         - name: Setup Python
         uses: actions/setup-python@v2
         with:
            python-version: 3.11

         - name: Install dependencies
         run: |
            python -m pip install --upgrade pip
            pip install -r requirements.txt

         - name: Extract OpenAPI spec
         run: python extract-openapi.py main:app --out openapi_generated.yaml

         # Do something with the generated spec here.
         # For example, validate that the committed spec matches the generated one.
         - name: Verify OpenAPI spec has been updated
         run: git diff --exit-code openapi.yaml openapi_generated.yaml

```

## Summary

In summary, we have

- Added `tags` to each endpoint or router
- Added `description`, `version` and other metadata to our FastAPI app instance
- Created a script `extract-openapi.py` to extract the OpenAPI spec from FastAPI
- Automated the extraction in CI/CD pipeline