OpenAPI Workflow with Flask and TypeScript

OpenAPI has become the de facto standard of designing web APIs, and there are numerous tools developed around its ecosystem. In this article, I will demonstrate the workflow of using OpenAPI in both backend and frontend projects.

OpenAPI 3.0

API Server

There are code first and design first approaches when using OpenAPI, and here we go with code first approach, i.e. writing the API server first, add specification to the method docs, then generate the final OpenAPI specification. The API server will be developed with Python Flask framework and apispec library with marshmallow extension. Let’s first install the dependencies:

1
2
3
4
5
6
7
Flask==2.1.2
Flask-Cors==3.0.10
Flask-SQLAlchemy==2.5.1
SQLAlchemy==1.4.36
python-dotenv==0.20.0
apispec[marshmallow]==5.2.2
apispec-webframeworks==0.5.2

Get post list

We will develop a simple blog post list page like this:

Blog post list

The first API we implement is get_post_list:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.get('/api/post/list')
def get_post_list() -> Response:
page = int(request.args.get('page', '1'))
sort = request.args.get('sort', 'desc')

query = db.session.query(Post)
if sort == 'asc':
query = query.order_by(Post.updated_at.asc())
else:
query = query.order_by(Post.updated_at.desc())

query = query.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)
posts = query.all()
return jsonify(posts=post_schema.dump(posts, many=True))

It is a regular web API, that parses the GET parameters, construct a SQLAlchemy query with pagination, and return the post list. The only thing special here is post_schema, which uses the marshmallow library to serialize post items. The schema is defined as:

1
2
3
4
5
6
7
8
class PostSchema(Schema):
id = fields.Integer()
title = fields.String(required=True, validate=validate.Length(min=1))
content = fields.String(required=True, validate=validate.Length(min=1))
updated_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)


post_schema = PostSchema()

The schema is not that different from the SQLAlchemy model, but with extra information about validation, format, and whether some field should be dumped to the output. We will see how to use the schema to deserialize and validate the form data, later in save_post API.

1
2
3
4
5
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String)
content = db.Column(db.String)
updated_at = db.Column(db.DateTime)

Add spec to docstring

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@app.get('/api/post/list')
def get_post_list() -> Response:
"""
---
get:
summary: Get post list.
tags: [post]
x-swagger-router-controller: oasis.views
operationId: get_post_list
parameters:
- in: query
name: page
schema:
type: integer
minimum: 1
- in: query
name: sort
schema:
type: string
enum: [asc, desc]
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
posts:
type: array
items:
$ref: '#/components/schemas/Post'
"""
...

The YAML is exactly the same as OpenAPI specification states, except the apispec library will use the route and schema to generate the complete spec file.

Generate specification file

To wire these routes and schemas, we create an openapi.py file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from apispec_webframeworks.flask import FlaskPlugin

from oasis import app, views, schemas

spec = APISpec(
title='Oasis',
version='0.1.0',
openapi_version='3.0.2',
info={'description': 'Demo project for OpenAPI workflow.'},
plugins=[FlaskPlugin(), MarshmallowPlugin()],
servers=[{'url': 'http://127.0.0.1:5000'}]
)

spec.components.schema('Post', schema=schemas.PostSchema)

with app.test_request_context():
spec.path(view=views.get_post_list)

And then a CLI command tool to generate the specification:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path

from oasis import app
from oasis.openapi import spec


@app.cli.command()
def gen():
"""Generate OpenAPI specification."""
spec_path = Path(__file__).parent.joinpath('../openapi.yaml').resolve()
with open(spec_path, 'w') as f:
f.write('# DO NOT EDIT\n')
f.write('# Auto generated by "flask gen"\n')
f.write('\n')
f.write(spec.to_yaml())

app.logger.info(f'Generated {spec_path}')

The output file openapi.yaml looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
info:
description: Demo project for OpenAPI workflow.
title: Oasis
version: 0.1.0
servers:
- url: http://127.0.0.1:5000
paths:
/api/post/list:
get:
summary: Get post list.
x-swagger-router-controller: oasis.views
operationId: get_post_list
parameters: [...]
responses: {...}
openapi: 3.0.2
components:
schemas:
Post:
type: object
properties:
id:
type: integer
title:
type: string
minLength: 1
content:
type: string
minLength: 1
updated_at:
type: string
format: date-time
readOnly: true
required:
- content
- title

More on post API

Let’s create another API that accepts form data, validate them, and save to database.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@app.post('/api/post/save')
def save_post() -> Response:
"""
---
post:
summary: Save post.
tags: [post]
x-swagger-router-controller: oasis.views
operationId: save_post
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Post'
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
id:
type: integer
"""
try:
post_form = post_schema.load(request.form)
except ValidationError as e:
raise AppError(e.messages)

post = Post(**post_form)
post = db.session.merge(post)
db.session.commit()
return jsonify({'id': post.id})

We can write custom validation in the schema file. It is also a good way to separate concerns.

1
2
3
4
5
6
7
8
9
10
11
class PostSchema(Schema):
...

@validates('id')
def validate_id(self, value: Optional[int]):
if not value:
return

post = db.session.query(Post).get(value)
if post is None:
raise ValidationError('Post ID not found.')

Preview API docs

OpenAPI enjoys a nice documentation UI that you can preview in VS Code. Install the OpenAPI (Swagger) Editor extension, and turn on the preview alongside openapi.yaml:

VS Code OpenAPI

If you want to run Try it out, make sure you enable the CORS of the API server. For Flask, there is an extension for this task:

1
2
3
4
5
6
from flask_cors import CORS

app = Flask(__name__)

if app.debug:
CORS(app)

API Client

Now we have a complete OpenAPI specification file, we can generate all forms of clients and stubs from it. Here we use the OpenAPI Generator to create a TypeScript-Fetch client, and use it in a Vue project.

Generate client codes

First add the development tool via package manager like yarn:

1
yarn add -D @openapitools/openapi-generator-cli

And create a configuration file openapitools.json for OpenAPI generator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "5.4.0",
"repository": {
"downloadUrl": "http://maven.aliyun.com/nexus/content/groups/public/${groupId}/${artifactId}/${versionName}/${artifactId}-${versionName}.jar"
},
"generators": {
"v1": {
"inputSpec": "../openapi-server/openapi.yaml",
"generatorName": "typescript-fetch",
"output": "src/openapi"
}
}
}
}

downloadUrl can be used when you have trouble reaching the Maven central. generators section indicates the path of openapi.yaml, which generator we use, and the output path. For convenience, we create a script in package.json:

1
2
3
4
5
{
"scripts": {
"gen": "openapi-generator-cli generate"
},
}

After running yarn gen, we get a fully functional client in src/openapi folder.

Use the generated client

1
2
3
4
import { PostApi } from './openapi'

const postApi = new PostApi()
postApi.getPostList().then((response) => { console.log(response.posts) })

Note the base path of the API request is http://127.0.0.1:5000, as we defined in openapi.py, which should be overridden in production. Since typically the frontend code and API server are deployed under the same domain, we can set the base path to an empty string. Create an api.ts file:

1
2
3
4
5
6
7
import { Configuration, PostApi } from './openapi'

const conf = new Configuration({
basePath: '',
})

export const postApi = new PostApi(conf)

And use it in your web application:

1
2
3
import { postApi } from './api'

postApi.getPostList()

Display post list

Let’s request for the posts when page is loaded, and display them via Vue and Bootstrap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { postApi } from './api'
import type { Post } from './openapi'

const posts = ref<Post[]>([])

onMounted(() => {
postApi.getPostList().then((response) => {
if (response.posts) {
posts.value = response.posts
}
})
})
</script>

<template>
<div class="container">
<div class="d-grid gap-3 my-3">
<div class="card" v-for="post in posts" :key="post.id">
<div class="card-body">
<h5 class="card-title">{{post.title}}</h5>
<p class="card-text">{{post.content}}</p>
<p class="card-text"><small class="text-muted">Updated at {{post.updatedAt}}</small></p>
</div>
</div>
</div>
</div>
</template>

The full source code can be found on GitHub: openapi-server, openapi-fe.