When building REST APIs in Python, nothing beats FastAPI. It lets you define routes, and their inputs and outputs quickly and easily. Best of all, it automatically generates an OpenAPI 3.0 JSON spec, and includes Swagger UI to let you play with each API in a browser.
There doesn't appear to be something identical in Go. There are efforts, but none on the same level of popularity as FastAPI.
Here we'll use a few Go packages to try to get close to what FastAPI provides.
Specifically we want to have openapi.json
autogen, and a Swagger UI interface.
Here are the API endpoints we will build:
/task/:id
. GET. Get a task by ID. Accepts a path parameterid
. Returns a JSON of the task detail./docs
. GET. Swagger UI interface. Returns the static assets of Swagger UI./openapi.json
. GET. Returns an auto-generated OpenAPI 3.0 JSON spec.
Task API in Python 3.10
With FastAPI, we only need to worry about building the /task
route. FastAPI will create the latter 2 for us. Here's the code in Python:
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel
# Mock task store
task_store = {
0: {"due": "2022-01-01", "remarks": "this is very important!!"},
1: {"due": "2022-01-02", "remarks": "this is not important"},
}
# OpenAPI tags description
tags_metadata = [
{"name": "Task", "description": "manage tasks"},
]
app = FastAPI(title="Task API", version="0.0.1", openapi_tags=tags_metadata)
# Output for GET /task
class GetTaskResponse(BaseModel):
due: str
remarks: str
class Config:
schema_extra = {"example": {"due": "2022-12-31", "remarks": "remarks for this task"}}
@app.get("/task/{id}", response_model=GetTaskResponse, tags=["Task"])
def get_task(id: int):
"""Get task by `id`."""
task: Optional[dict] = task_store.get(id)
if not task:
raise FileNotFoundError(f"task id={id} not found")
return task
When we assign GetTaskResponse
class to the route's response_model
, it shows up in Swagger UI:
Note the example value at the bottom of the screenshot. It matches the example given in GetTaskResponse
.
We want to replicate that in Go.
Task API in Go 1.18
We'll use Gin to help us build the API. Here is a reproduction of the /task
GET route in Go:
package main
import (
"strconv"
"sync"
"github.com/gin-gonic/gin"
)
type Task struct {
Due string `json:"due"`
Remarks string `json:"remarks"`
}
// Mock task store
taskStore := map[int]Task{
0: {Due: "2022-01-01", Remarks: "this is very important!"},
1: {Due: "2022-01-02", Remarks: "this is not important"},
}
// Handler for /task/:id GET
func GetTask(c *gin.Context) {
// Get path param "id", and convert from string to int
idStr := c.Param("id")
idInt, err := strconv.Atoi(idStr)
if err != nil {
c.IndentedJSON(http.StatusBadRequest, gin.H{"error": "bad id"})
return
}
// Return task from the store
for id, task := range taskStore {
if id == idInt {
c.IndentedJSON(http.StatusOK, task)
return
}
}
// Task not found
c.IndentedJSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("task id=%s not found", idStr)})
}
func main() {
router := gin.Default()
router.GET("/task/:id", getTask)
router.Run("localhost:8000")
}
We'll use fizz to auto-generate openapi.json
. fizz is built on top of tonic.
There's a pending pull request in fizz that proposes bundling Swagger UI. It hasn't been accepted as of this article's writing.
First, create a fizz instance, pass in Gin router, and replace Gin.GET
with fizz.GET
:
func main() {
router := gin.Default()
f := fizz.NewFromEngine(router)
f.GET("/task/:id", nil, getTask)
srv := &http.Server{
Addr: "localhost:8000",
Handler: f,
}
err := srv.ListenAndServe()
if err != nil {
log.Fatalf("Failed to run server: %v", err)
}
}
f.GET
accepts at least three parameters: the route ("/task/:id"
), OpenAPI metadata about this route (nil
at this time), and handlers. To be able to generate OpenAPI specs for this route's input and output, its handler must be wrapped in tonic
:
func main() {
router := gin.Default()
f := fizz.NewFromEngine(router)
f.GET("/task/:id", nil, tonic.Handler(getTask, http.StatusOK)) // changed
srv := &http.Server{
Addr: "localhost:8000",
Handler: f,
}
err := srv.ListenAndServe()
if err != nil {
log.Fatalf("Failed to run server: %v", err)
}
}
If you try to run/compile at this point, go will panic. This is because our handler getTask()
needs to be changed to a format tonic accepts:
type getTaskInput struct {
Id int `path:"id"`
}
// Handler for /task/:id GET
func getTask(c *gin.Context, input *getTaskInput) (*Task, error) {
// Return task from the store
for id, task := range taskStore {
if id == input.Id {
return &task, nil
}
}
// Task not found
c.AbortWithStatus(http.StatusNotFound)
return nil, fmt.Errorf("task id=%d not found", input.Id)
}
Here we add a struct to define input data (the path parameter id
), and simplify the handler quite a bit. We no longer need to manually get path parameter from gin.Context
because tonic will do that for us, and bind it to input
. tonic will also marshal the output to JSON for us, we just need to return
the task object. If your handler doesn't need to return any data, you must at least return an error
.
Next, we'll add a GET route to /openapi.json
:
func main() {
router := gin.Default()
f := fizz.NewFromEngine(router)
f.GET("/task/:id", nil, tonic.Handler(getTask, http.StatusOK))
// changed
info := &openapi.Info{
Title: "Task API",
Description: `manage tasks`,
Version: "0.0.1",
}
f.GET("/openapi.json", nil, f.OpenAPI(info, "json"))
// end changed
srv := &http.Server{
Addr: "localhost:8000",
Handler: f,
}
err := srv.ListenAndServe()
if err != nil {
log.Fatalf("Failed to run server: %v", err)
}
}
Run your server with go run .
and navigate to localhost:8000/openapi.json in your browser. You should see a barebone OpenAPI 3.0 spec JSON like this:
{
"openapi": "3.0.1",
"info": {
"title": "Task API",
"description": "manage tasks",
"version": "0.0.1"
},
"paths": {
"/task/{id}": {
"get": {
"operationId": "getTask",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Task"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Task": {
"type": "object",
"properties": {
"due": {
"type": "string"
},
"remarks": {
"type": "string"
}
}
}
}
}
}
We'll be populating it with more information later. Let's get Swagger UI up and running so we can look at a pretty interface.
Add Swagger UI
Download Swagger UI static files, and save them into a folder named swagger-ui
. This folder contains all the static assets needed to run swagger UI. You can open index.html
in a browser to see it.
By default, it shows you example API routes for a pet store. To change the URL, edit the file swagger-initializer.js
. Replace "https://petstore.swagger.io/v2/swagger.json"
with "/openapi.json"
.
While we're here, comment out SwaggerUIStandalonePreset
and everything below it. This removes the ugly explore bar at the top:
window.onload = function() {
//<editor-fold desc="Changeable Configuration Block">
// the following lines will be replaced by docker/configurator, when it runs in a docker-container
window.ui = SwaggerUIBundle({
url: "/openapi.json", // changed
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
// SwaggerUIStandalonePreset
],
// plugins: [
// SwaggerUIBundle.plugins.DownloadUrl
// ],
// layout: "StandaloneLayout"
});
//</editor-fold>
};
Back to our go source file.
We want to embed the swagger-ui directory into our app so that the compiled executable contains the all the files we need to run Swagger UI.
To embed the swagger-ui folder, add these lines outside the main function:
//go:embed swagger-ui
var swaggerUIdir embed.FS
The go:embed swagger-ui
magic comment is required. swagger-ui
must match the name of the folder or file you want to embed.
In your main function, load the embedded folder, and add a route to serve Swagger UI static assets (FastAPI serves them at /docs
, we'll do the same):
//go:embed swagger-ui
var swaggerUIdir embed.FS
func main() {
router := gin.Default()
f := fizz.NewFromEngine(router)
f.GET("/task/:id", nil, tonic.Handler(getTask, http.StatusOK))
info := &openapi.Info{
Title: "Task API",
Description: `manage tasks`,
Version: "0.0.1",
}
f.GET("/openapi.json", nil, f.OpenAPI(info, "json"))
// changed
// Load embedded folder and emulate it as a file system sub dir
swaggerAssets, fsErr := fs.Sub(swaggerUIdir, "swagger-ui")
if fsErr != nil {
log.Fatalf("Failed to load embedded Swagger UI assets: %v", fsErr)
}
// Add Swagger UI to route /docs
router.StaticFS("/docs", http.FS(swaggerAssets))
// end changed
srv := &http.Server{
Addr: "localhost:8000",
Handler: f,
}
err := srv.ListenAndServe()
if err != nil {
log.Fatalf("Failed to run server: %v", err)
}
}
Run your app with go run .
, and navigate to localhost:8000/docs. You should see this:
To test if the compiled executable could load the embedded files, run it from a location outside of your project folder.
At this point, we've accomplished what we set out to do:
- [x]
openapi.json
autogen - [x] Swagger UI
All that's left is to add more information to openapi.json
. They will show up in Swagger UI.
Populate openapi.json
Earlier when we defined the GET route, we filled the infos
parameter with nil
. Now we'll fill it with information about this route:
func main() {
router := gin.Default()
f := fizz.NewFromEngine(router)
// changed
getTaskSpec := []fizz.OperationOption{
fizz.ID("getTask"),
fizz.Summary("Get task"),
fizz.Description("Get a task by its ID."),
fizz.StatusDescription("Successful Response"),
fizz.Response("404", "Task not found.", nil, nil, map[string]string{"error": "task id=1 not found"}),
}
f.GET("/task/:id", getTaskSpec, tonic.Handler(getTask, http.StatusOK))
// end changed
info := &openapi.Info{
Title: "Task API",
Description: `manage tasks`,
Version: "0.0.1",
}
f.GET("/openapi.json", nil, f.OpenAPI(info, "json"))
// snip
// ...
}
To show examples in the Successful Response section, add the example
tag to Task
struct:
type Task struct {
Due string `json:"due" example:"2022-12-31"`
Remarks string `json:"remarks" example:"remarks for this task"`
}
This is the complete main.go
source:
package main
import (
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/loopfz/gadgeto/tonic"
"github.com/wI2L/fizz"
"github.com/wI2L/fizz/openapi"
)
type Task struct {
Due string `json:"due" example:"2022-12-31"`
Remarks string `json:"remarks" example:"remarks for this task"`
}
// Mock task store
var taskStore = map[int]Task{
0: {Due: "2022-01-01", Remarks: "this is very important!"},
1: {Due: "2022-01-02", Remarks: "this is not important"},
}
type getTaskInput struct {
Id int `path:"id"`
}
// Handler for /task/:id GET
func getTask(c *gin.Context, input *getTaskInput) (*Task, error) {
// Return task from the store
for id, task := range taskStore {
if id == input.Id {
return &task, nil
}
}
// Task not found
c.AbortWithStatus(http.StatusNotFound)
return nil, fmt.Errorf("task id=%d not found", input.Id)
}
//go:embed swagger-ui
var swaggerUIdir embed.FS
func main() {
router := gin.Default()
f := fizz.NewFromEngine(router)
getTaskSpec := []fizz.OperationOption{
fizz.ID("getTask"),
fizz.Summary("Get task"),
fizz.Description("Get a task by its ID."),
fizz.StatusDescription("Successful Response"),
fizz.Response("404", "Task not found.", nil, nil, map[string]string{"error": "task id=1 not found"}),
}
f.GET("/task/:id", getTaskSpec, tonic.Handler(getTask, http.StatusOK))
info := &openapi.Info{
Title: "Task API",
Description: `manage tasks`,
Version: "0.0.1",
}
f.GET("/openapi.json", nil, f.OpenAPI(info, "json"))
// Load embedded files and emulate it as a file system sub dir
swaggerAssets, fsErr := fs.Sub(swaggerUIdir, "swagger-ui")
if fsErr != nil {
log.Fatalf("Failed to load embedded Swagger UI assets: %v", fsErr)
}
// Add Swagger UI to route /docs
router.StaticFS("/docs", http.FS(swaggerAssets))
srv := &http.Server{
Addr: "localhost:8000",
Handler: f,
}
err := srv.ListenAndServe()
if err != nil {
log.Fatalf("Failed to run server: %v", err)
}
}
There are more you could do, like validation and authentication.
I'll leave that as an exercise for you :)