Creating REST APIs in Golang: A Practical Guide with Okapi Framework

Jonas KanindaJonas Kaninda
11 min read

REST (Representational State Transfer) APIs have become the backbone of modern web applications, enabling efficient communication and data exchange across systems.

Go (or Golang) is increasingly favored for backend development due to its performance, simplicity, and concurrency features.

In this tutorial, we’ll walk through building a REST API using the Okapi framework—a modern, minimalist web framework for Go that emphasizes developer experience and clean API design.

What is Okapi?

Okapi is a lightweight, performant web framework inspired by FastAPI. It is designed to help developers build scalable, well-documented APIs quickly, with minimal boilerplate.

Key features:

  • Minimalist syntax: Clean and declarative route definitions.

  • OpenAPI integration: Automatic and real-time OpenAPI spec generation.

  • Extensible middleware: Support for custom and built-in middleware like JWTAuth.

Why use Okapi?

  • Easy to Learn – With familiar Go syntax and intuitive APIs, you can be productive in minutes, even on your first project.

  • Lightweight and Unopinionated – Okapi is built from the ground up and doesn’t wrap or build on top of another framework. It gives you full control without unnecessary abstraction or bloat.

  • Highly Flexible – Designed to adapt to your architecture and workflow, not the other way around.

  • Built for Production – Fast, reliable, and efficient under real-world load. Okapi is optimized for performance without sacrificing developer experience.

  • Standard Library Compatibility - Integrates seamlessly with Go’s net/http standard library, making it easy to combine Okapi with existing Go code and tools.

  • Automatic OpenAPI Documentation - Generate comprehensive OpenAPI specs automatically for every route, keeping your API documentation always up to date with your code.

  • Dynamic Route Management - Enable or disable routes and route groups at runtime. No need to comment out code—just toggle behavior cleanly and efficiently.

Ideal for:

  • High-performance REST APIs

  • Composable microservices

  • Rapid prototyping

  • Learning & teaching Go web development

Whether you're building your next startup, internal tools, or side projects, Okapi scales with you.


Prerequisites

Before you begin, ensure you have the following installed:

  • Basic knowledge of Go

  • Go installed (v1.24+ recommended)

  • A code editor (VS Code or similar)


Project Setup

Start by creating your project directory and initializing Go modules:

mkdir okapi-example && cd okapi-example
go mod init okapi-example
go get github.com/jkaninda/okapi@latest

Project folder structure:

okapi-example/
├── controllers
   └── controller.go
├── go.mod
├── go.sum
├── main.go
├── middlewares
   └── middleware.go
├── models
   └── model.go
└── routes
    └── route.go

Basic Routing Example

Here’s a minimal Okapi server that responds with a welcome message:

package main

import (
    "github.com/jkaninda/okapi"
)

func main() {
    o := okapi.Default()

    o.Get("/", func(c okapi.Context) error {
        return c.OK(okapi.M{"message": "Hello from Okapi Web Framework!", "License": "MIT"})
    })

    o.Get("/greet/:name", func(c okapi.Context) error {
        name := c.Param("name")
        return c.OK(okapi.M{"message": "Hello, " + name + "!"})
    })

    if err := o.Start(); err != nil {
        panic(err)
    }
}

Run it with:

go run main.go

Visit http://localhost:8080 to see the response.


Building a Book API

We’ll now create a simple API to manage books, including authentication for admin-level routes.


1. Define Models

models/model.go:

package models

type Response struct {
    Success bool   `json:"success"`
    Message string `json:"message"`
    Data    Book   `json:"data"`
}
type Book struct {
    Id    int    `json:"id"`
    Name  string `json:"name" form:"name"  max:"50" required:"true" description:"Book name"`
    Price int    `json:"price" form:"price" query:"price" yaml:"price" required:"true" description:"Book price"`
}
type ErrorResponse struct {
    Success bool `json:"success"`
    Status  int  `json:"status"`
    Details any  `json:"details"`
}

type AuthRequest struct {
    Username string `json:"username" required:"true" description:"Username for authentication"`
    Password string `json:"password" required:"true" description:"Password for authentication"`
}
type AuthResponse struct {
    Success   bool   `json:"success"`
    Message   string `json:"message"`
    Token     string `json:"token,omitempty"`
    ExpiresAt int64  `json:"expires,omitempty"`
}
type UserInfo struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Role  string `json:"role"`
}

2. Implement Middleware (JWT)

middlewares/middleware.go:

Uses Okapi's built-in JWTAuth with claim validation and token generation.

package middlewares

import (
    "fmt"
    "log/slog"
    "net/http"
    "okapi-example/models"
    "strings"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/jkaninda/okapi"
)

var (
    // signingSecret is used to sign the JWT tokens
    signingSecret = "supersecret"

    JWTAuth = &okapi.JWTAuth{
        SigningSecret:    []byte(signingSecret),
        TokenLookup:      "header:Authorization",
        ClaimsExpression: "Equals(`email_verified`, `true`) && Equals(`user.role`, `admin`) && Contains(`permissions`, `create`, `delete`, `update`)",
        ForwardClaims: map[string]string{
            "email": "user.email",
            "role":  "user.role",
            "name":  "user.name",
        },
        // CustomClaims claims validation function
        ValidateClaims: func(claims jwt.Claims) error {
            slog.Info("Validating JWT claims for role using custom function")
            // Simulate a custom claims validation
            if _, ok := claims.(jwt.Claims); ok {

            }
            return nil
        },
    }
    jwtClaims = jwt.MapClaims{
        "sub": "12345",
        "iss": "okapi.example.com",
        "aud": "okapi.example.com",
        "user": map[string]string{
            "name":  "",
            "role":  "",
            "email": "",
        },
        "email_verified": true,
        "permissions":    []string{"read", "create"},
        "exp":            time.Now().Add(2 * time.Hour).Unix(),
    }
    adminPermissions = []string{"read", "create", "delete", "update"}
)

func Login(authRequest *models.AuthRequest) (models.AuthResponse, error) {
    // This is where you would typically validate the user credentials against a database

    slog.Info("Login attempt", "username", authRequest.Username)
    // Simulate a login function that returns a JWT token
    if authRequest.Username != "admin" && authRequest.Password != "password" ||
        authRequest.Username != "user" && authRequest.Password != "password" {
        return models.AuthResponse{
            Success: false,
            Message: "Invalid username or password",
        }, fmt.Errorf("username or password is wrong")
    }

    if _, ok := jwtClaims["user"].(map[string]string); ok {
        jwtClaims["user"].(map[string]string)["name"] = strings.ToUpper(authRequest.Username)
        jwtClaims["user"].(map[string]string)["role"] = authRequest.Username
        jwtClaims["user"].(map[string]string)["email"] = authRequest.Username + "@example.com"
        jwtClaims["permissions"] = []string{"read"}

        // If the user is an admin, add admin permissions
        if authRequest.Username == "admin" {
            jwtClaims["permissions"] = adminPermissions
        }

    }
    // Set the expiration time for the JWT token
    expireAt := 30 * time.Minute
    jwtClaims["exp"] = time.Now().Add(expireAt).Unix()

    token, err := okapi.GenerateJwtToken(JWTAuth.SigningSecret, jwtClaims, expireAt)
    if err != nil {

        return models.AuthResponse{
            Success: false,
            Message: "Invalid username or password",
        }, fmt.Errorf("failed to generate JWT token: %w", err)
    }
    return models.AuthResponse{
        Success:   true,
        Message:   "Welcome back " + authRequest.Username,
        Token:     token,
        ExpiresAt: time.Now().Add(expireAt).Unix(),
    }, nil

}
func CustomMiddleware(next okapi.HandleFunc) okapi.HandleFunc {
    return func(c okapi.Context) error {
        slog.Info("Custom middleware executed", "path", c.Request().URL.Path, "method", c.Request().Method)
        // You can add any custom logic here, such as logging, authentication, etc.
        // For example, let's log the request method and URL
        slog.Info("Request received", "method", c.Request().Method, "url", c.Request().URL.String())
        // Call the next handler in the chain
        if err := next(c); err != nil {
            // If an error occurs, log it and return a generic error response
            slog.Error("Error in custom middleware", "error", err)
            return c.JSON(http.StatusInternalServerError, okapi.M{"error": "Internal Server Error"})
        }
        return nil
    }
}

3. Create Controllers

controllers/controller.go:

Implements handlers for Home, Book, and Authentication logic.

package controllers

import (
    "fmt"
    "github.com/jkaninda/okapi"
    "net/http"
    "okapi-example/middlewares"
    "okapi-example/models"
    "strconv"
)

type BookController struct{}
type CommonController struct{}
type AuthController struct{}

var (
    books = []*models.Book{
        {Id: 1, Name: "The Go Programming Language ", Price: 100},
        {Id: 2, Name: "Building REST/API With Okapi ", Price: 50},
        {Id: 3, Name: "Learning Go", Price: 200},
        {Id: 4, Name: "Go Web Programming", Price: 300},
        {Id: 5, Name: "Go in Action", Price: 150},
    }
    ApiVersion = "V1"
)

// ****************** Controllers *****************

func (hc *CommonController) Home(c okapi.Context) error {
    return c.OK(okapi.M{"message": "Welcome to the Okapi Web Framework!"})
}
func (hc *CommonController) Version(c okapi.Context) error {
    return c.OK(okapi.M{"version": ApiVersion})
}
func (bc *BookController) GetBooks(c okapi.Context) error {
    // Simulate fetching books from a database
    return c.OK(books)
}

func (bc *BookController) CreateBook(c okapi.Context) error {
    // Simulate creating a book in a database
    book := &models.Book{}
    err := c.Bind(book)
    if err != nil {
        return c.ErrorBadRequest(models.ErrorResponse{Success: false, Status: http.StatusBadRequest, Details: err.Error()})
    }
    book.Id = len(books) + 1
    books = append(books, book)
    response := models.Response{
        Success: true,
        Message: "Book created successfully",
        Data:    *book,
    }
    return c.OK(response)
}
func (bc *BookController) GetBook(c okapi.Context) error {
    id := c.Param("id")
    i, err := strconv.Atoi(id)
    if err != nil {
        return c.ErrorBadRequest(models.ErrorResponse{Success: false, Status: http.StatusBadRequest, Details: err.Error()})
    }
    // Simulate a fetching book from a database

    for _, book := range books {
        if book.Id == i {
            return c.OK(book)
        }
    }
    return c.AbortNotFound("Book not found")
}
func (bc *BookController) DeleteBook(c okapi.Context) error {
    id := c.Param("id")
    i, err := strconv.Atoi(id)
    if err != nil {
        return c.ErrorBadRequest(models.ErrorResponse{Success: false, Status: http.StatusBadRequest, Details: err.Error()})
    }

    // Simulate deleting a book from a database
    for index, book := range books {
        if book.Id == i {
            books = append(books[:index], books[index+1:]...)
            return c.OK(models.Response{
                Success: true,
                Message: "Book deleted successfully",
            })
        }
    }
    return c.AbortNotFound("Book not found")
}

// ******************** AuthController *****************

func (bc *AuthController) Login(c okapi.Context) error {
    authRequest := &models.AuthRequest{}
    err := c.Bind(authRequest)
    if err != nil {
        return c.ErrorBadRequest(models.ErrorResponse{Success: false, Status: http.StatusBadRequest, Details: err.Error()})
    }
    // Validate the authRequest and generate a JWT token
    authResponse, err := middlewares.Login(authRequest)
    if err != nil {
        return c.ErrorUnauthorized(authResponse)
    }
    return c.OK(authResponse)
}
func (bc *AuthController) WhoAmI(c okapi.Context) error {
    //Get User Information from the context, shared by the JWT middleware using forwardClaims
    email := c.GetString("email")
    if email == "" {
        return c.AbortUnauthorized("Unauthorized", fmt.Errorf("user not authenticated"))
    }

    // Respond with the current user information
    return c.OK(models.UserInfo{
        Email: email,
        Role:  c.GetString("role"),
        Name:  c.GetString("name"),
    },
    )
}

4. Define Routes

routes/route.go:

Group routes by feature and use route definitions with OpenAPI documentation metadata.

package routes

import (
    "net/http"
    "okapi-example/controllers"
    "okapi-example/middlewares"
    "okapi-example/models"

    "github.com/jkaninda/okapi"
)

// ****************** Controllers ******************
var (
    bookController   = &controllers.BookController{}
    commonController = &controllers.CommonController{}
    authController   = &controllers.AuthController{}
)

type Route struct {
    // app is the Okapi application
    app *okapi.Okapi
}

// NewRoute creates a new Route instance with the Okapi application
func NewRoute(app *okapi.Okapi) *Route {
    // Update OpenAPI documentation with the application title and version
    app.WithOpenAPIDocs(okapi.OpenAPI{
        Title:   "REST APIs with Okapi Framework",
        Version: controllers.ApiVersion,
        Licence: okapi.License{
            Name: "MIT",
            URL:  "https://opensource.org/license/mit/",
        },
    })
    return &Route{
        app: app,
    }
}

// ****************** Routes Definition ******************

// Home returns the route definition for the Home endpoint
func (r *Route) Home() okapi.RouteDefinition {
    return okapi.RouteDefinition{
        Path:    "/",
        Method:  http.MethodGet,
        Handler: commonController.Home,
        Group:   &okapi.Group{Prefix: "/", Tags: []string{"CommonController"}},
        Options: []okapi.RouteOption{
            okapi.DocSummary("Home"),
            okapi.DocDescription("Welcome to the Okapi Web Framework!"),
        },
    }
}

// Version returns the route definition for the Version endpoint
func (r *Route) Version() okapi.RouteDefinition {
    return okapi.RouteDefinition{
        Path:    "/version",
        Method:  http.MethodGet,
        Handler: commonController.Version,
        Group:   &okapi.Group{Prefix: "/api/v1", Tags: []string{"CommonController"}},
        Options: []okapi.RouteOption{
            okapi.DocSummary("API Version"),
            okapi.DocDescription("Get the API version"),
            okapi.DocResponse(okapi.M{"version": "v1"}),
        },
    }
}

// ************* Book Routes *************
// In this section, we will make BookRoutes deprecated and create BookV1Routes

// BookRoutes returns the route definitions for the BookController
func (r *Route) BookRoutes() []okapi.RouteDefinition {
    apiGroup := &okapi.Group{Prefix: "/api", Tags: []string{"BookController"}}
    // Mark the group as deprecated
    // But, it will still be available for use, it's just marked as deprecated on the OpenAPI documentation
    apiGroup.Deprecated()
    // Apply custom middleware
    apiGroup.Use(middlewares.CustomMiddleware)
    return []okapi.RouteDefinition{
        {
            Method:  http.MethodGet,
            Path:    "/books",
            Handler: bookController.GetBooks,
            Group:   apiGroup,
            Options: []okapi.RouteOption{
                okapi.DocSummary("Get Books"),
                okapi.DocDescription("Retrieve a list of books"),
                okapi.DocResponse([]models.Book{}),
                okapi.DocResponse(http.StatusBadRequest, models.ErrorResponse{}),
                okapi.DocResponse(http.StatusNotFound, models.ErrorResponse{}),
            },
        },
        {
            Method:  http.MethodGet,
            Path:    "/books/:id",
            Handler: bookController.GetBook,
            Group:   apiGroup,
            Options: []okapi.RouteOption{
                okapi.DocSummary("Get Book by ID"),
                okapi.DocDescription("Retrieve a book by its ID"),
                okapi.DocPathParam("id", "int", "The ID of the book"),
                okapi.DocResponse(models.Book{}),
                okapi.DocResponse(http.StatusBadRequest, models.ErrorResponse{}),
                okapi.DocResponse(http.StatusNotFound, models.ErrorResponse{}),
            },
        },
    }
}

// *************** End of Book Routes ***************

// *********************** Book v1 Routes ***********************

func (r *Route) V1BookRoutes() []okapi.RouteDefinition {
    apiGroup := &okapi.Group{Prefix: "/api"}
    apiV1Group := apiGroup.Group("/v1").WithTags([]string{"BookController"})
    // Apply custom middleware
    apiGroup.Use(middlewares.CustomMiddleware)
    return []okapi.RouteDefinition{
        {
            Method:  http.MethodGet,
            Path:    "/books",
            Handler: bookController.GetBooks,
            Group:   apiV1Group,
            Options: []okapi.RouteOption{
                okapi.DocSummary("Get Books"),
                okapi.DocDescription("Retrieve a list of books"),
                okapi.DocResponse([]models.Book{}),
                okapi.DocResponse(http.StatusBadRequest, models.ErrorResponse{}),
            },
        },
        {
            Method:  http.MethodGet,
            Path:    "/books/:id",
            Handler: bookController.GetBook,
            Group:   apiV1Group,
            Options: []okapi.RouteOption{
                okapi.DocSummary("Get Book by ID"),
                okapi.DocDescription("Retrieve a book by its ID"),
                okapi.DocPathParam("id", "int", "The ID of the book"),
                okapi.DocResponse(models.Book{}),
                okapi.DocResponse(http.StatusBadRequest, models.ErrorResponse{}),
                okapi.DocResponse(http.StatusNotFound, models.ErrorResponse{}),
            },
        },
    }
}

// *************** Auth Routes ****************

func (r *Route) AuthRoute() okapi.RouteDefinition {
    apiGroup := &okapi.Group{Prefix: "/api/v1/auth", Tags: []string{"AuthController"}}
    apiGroup.Use(middlewares.CustomMiddleware)
    return okapi.RouteDefinition{

        Method:  http.MethodPost,
        Path:    "/login",
        Handler: authController.Login,
        Group:   apiGroup,
        Options: []okapi.RouteOption{
            okapi.DocSummary("Login"),
            okapi.DocDescription("User login to get a JWT token"),
            okapi.DocRequestBody(models.AuthRequest{}),
            okapi.DocResponse(models.AuthResponse{}),
            okapi.DocResponse(http.StatusUnauthorized, models.AuthResponse{}),
        },
    }
}

// ************** Authenticated Routes **************

func (r *Route) SecurityRoutes() []okapi.RouteDefinition {
    coreGroup := &okapi.Group{Prefix: "/api/v1/security", Tags: []string{"SecurityController"}}
    // Apply JWT authentication middleware to the admin group
    coreGroup.Use(middlewares.JWTAuth.Middleware)
    // Apply custom middleware
    coreGroup.Use(middlewares.CustomMiddleware)
    coreGroup.WithBearerAuth() //Enable Bearer token for OpenAPI documentation
    return []okapi.RouteDefinition{
        {
            Method:  http.MethodPost,
            Path:    "/whoami",
            Handler: authController.WhoAmI,
            Group:   coreGroup,
            Options: []okapi.RouteOption{
                okapi.DocSummary("Whoami"),
                okapi.DocDescription("Get the current user's information"),
                okapi.DocResponse(models.UserInfo{}),
            },
        },
    }
}

// ***************** Admin Routes *****************

func (r *Route) AdminRoutes() []okapi.RouteDefinition {
    apiGroup := &okapi.Group{Prefix: "/api/v1/admin", Tags: []string{"AdminController"}}
    // Apply JWT authentication middleware to the admin group
    apiGroup.Use(middlewares.JWTAuth.Middleware)
    apiGroup.Use(middlewares.CustomMiddleware)
    apiGroup.WithBearerAuth() //Enable Bearer token for OpenAPI documentation

    return []okapi.RouteDefinition{

        {
            Method:  http.MethodPost,
            Path:    "/books",
            Handler: bookController.CreateBook,
            Group:   apiGroup,
            Options: []okapi.RouteOption{
                okapi.DocSummary("Create Book"),
                okapi.DocDescription("Create a new book"),
                okapi.DocRequestBody(models.Book{}),
                okapi.DocResponse(models.Response{}),
            },
        },
        {
            Method:  http.MethodDelete,
            Path:    "/books/:id",
            Handler: bookController.DeleteBook,
            Group:   apiGroup,
            Options: []okapi.RouteOption{
                okapi.DocSummary("Delete Book by ID"),
                okapi.DocDescription("Delete a book by its ID"),
                okapi.DocPathParam("id", "int", "The ID of the book"),
                okapi.DocResponse(models.Response{}),
                okapi.DocResponse(http.StatusNotFound, models.ErrorResponse{}),
                okapi.DocResponse(http.StatusUnauthorized, models.ErrorResponse{}),
            },
        },
    }
}

5. Main Entry Point

main.go:

package main

import (
    "github.com/jkaninda/okapi"
    "okapi-example/routes"
)

func main() {
    app := okapi.Default()

    // Create the route instance
    route := routes.NewRoute(app)
    // Register routes
    app.Register(route.Home())
    app.Register(route.Version())
    app.Register(route.AdminRoutes()...)
    app.Register(route.AuthRoute())
    app.Register(route.SecurityRoutes()...)
    app.Register(route.BookRoutes()...)
    app.Register(route.V1BookRoutes()...)

    // Start the server
    if err := app.Start(); err != nil {
        panic(err)
    }

}

Run the Server

go run main.go

Access the API

Example response:

{
  "message": "Welcome to the Okapi Web Framework!"
}

Swagger UI

Okapi OpenAPI Swagger UI

Summary

With Okapi, you can create well-structured, RESTful APIs in Go with minimal effort. Features like declarative routing, built-in JWT authentication, and automatic OpenAPI documentation make it ideal for modern backend development.

Okapi Github: https://github.com/jkaninda/okapi

Github:

Next Steps

  • Add database integration (e.g., PostgreSQL with GORM)

  • Integrate unit tests

  • Deploy to Docker or cloud platforms

  • Secure your JWT secrets using environment variables


11
Subscribe to my newsletter

Read articles from Jonas Kaninda directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jonas Kaninda
Jonas Kaninda

I’m a Software Engineer with over 5 years of hands-on experience building scalable, reliable systems. My expertise spans Kotlin, Spring Boot, Go (Golang), MySQL, PostgreSQL, and Linux systems, with a strong focus on DevOps, Docker, and Kubernetes. Programming is more than a job, it's my passion. I’m driven by curiosity, constantly exploring new tools, frameworks, and architectural patterns to stay at the forefront of the tech landscape. I'm a strong advocate of Open Source and enjoy building tools that simplify infrastructure and developer workflows. My OSS contributions include solutions for database backup/migration, encryption, API Gateway management, and Kubernetes Operators. My current interests revolve around: Cloud-native architecture Microservices and API frameworks DevOps & GitOps practices SRE and DevSecOps principles I bring experience in designing, deploying, and maintaining modern software systems, with a deep understanding of CI/CD pipelines, infrastructure automation, and container orchestration. I thrive in both collaborative environments and independent work—always aiming for clean, maintainable code and high-impact solutions.