Make Your Own FastAPI in Go

Make Your Own FastAPI in Go

·

9 min read

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 parameter id. 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:

get-task-response-model.png

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:

swagger-ui.png

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
    // ...
}

route-info.png

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"`
}

successful-response-example.png

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 :)