Gigson Expert

/

January 9, 2026

Building Your Personal Comedian API: A Web Service Tutorial Using Go and Chi

A hands-on Go tutorial showing how to build a production-style API with Chi and Google Gemini AI for jokes, puns, and quotes.

Blog Image

Nwaokocha Michael

A Web Developer with experience building scalable web and mobile applications. He works with React, TypeScript, Golang, and Node.js, and enjoys writing about the patterns that make software easier to understand. When not debugging distributed systems or writing articles, you can find him reading sci-fi novels or hiking.

Article by Gigson Expert

The problem with most "getting started" tutorials is that they teach you how to build things that are not interesting. Another TODO list. Another CRUD app you will abandon halfway through.

Let us build something different: a web service in Go that generates puns, jokes, proverbs, and quotes using Google's Gemini AI. It is simple enough to finish in an afternoon but real enough to teach you how actual web services work. We will include practical practices that will serve you well when you build more serious applications.

Think of this as building your personal comedian that lives on the internet and never runs out of material.

What We Are Actually Building

Before we write a single line of code, here is what we are aiming for. By the end of this tutorial, you will have a web service with endpoints like:

  • GET /joke - Returns a random joke
  • GET /pun - Returns a pun (the dad-joke kind)
  • GET /quote - Returns an inspirational quote
  • GET /proverb - Returns a proverb or piece of wisdom

Each endpoint talks to Gemini AI and returns a JSON response.

Here is an example response:

{
  "content": "Why don't scientists trust atoms? Because they make up everything!",
  "type": "joke",
  "timestamp": "2024-03-15T10:30:00Z"
}

Setting Up: The Foundation

First, ensure you have Go installed.

Create a new project:

mkdir comedian-api
cd comedian-api
go mod init comedian-api

A Note on Module Names: The import paths used in your code (e.g., comedian-api/ai) must match the module path declared in go.mod. If you initialise the module with a different name (e.g., github.com/you/comedian-api), then your local imports must use that name instead.

Now we need two dependencies: Chi (our routing library) and the Gemini AI SDK:

go get github.com/go-chi/chi/v5
go get github.com/google/generative-ai-go/genai
go get google.golang.org/api/option

You will also need a Gemini API key. Obtain one from Google AI Studio (ai.google.dev), save it securely, and do not commit it to source control. We discuss safer handling below.

The Simplest Thing That Could Possibly Work

Start with the basics to see Chi in action:

package main

import (
    "net/http"
    "github.com/go-chi/chi/v5"
)

func main() {
    r := chi.NewRouter()

    r.Get("/joke", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("This will be a joke soon, I promise"))
    })

    http.ListenAndServe(":8080", r)
}

Run it with go run main.go, and open your browser to http://localhost:8080/joke. Chi is straightforward: create a router with chi.NewRouter(), register routes with r.Get(), and it matches incoming requests to the handlers. Chi sits on top of Go's standard net/http.

Making It Actually Talk to Gemini

We implement the AI client in a dedicated package to keep generation logic separate and make future replacements or tests easier.

Create ai/client.go:

// ai/client.go
package ai

import (
    "context"
    "fmt"

    "github.com/google/generative-ai-go/genai"
    "google.golang.org/api/option"
)

type Client struct {
    model *genai.GenerativeModel
}

func NewClient(apiKey string) (*Client, error) {
    ctx := context.Background()
    client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey))
    if err != nil {
        return nil, fmt.Errorf("failed to create AI client: %w", err)
    }

    model := client.GenerativeModel("gemini-1.5-flash")
    return &Client{model: model}, nil
}

func (c *Client) GenerateJoke(ctx context.Context) (string, error) {
    prompt := "Generate a short, funny joke. Just the joke, no explanation."

    resp, err := c.model.GenerateContent(ctx, genai.Text(prompt))
    if err != nil {
        return "", fmt.Errorf("failed to generate joke: %w", err)
    }

    if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
        return "", fmt.Errorf("empty response from AI")
    }

    return fmt.Sprintf("%v", resp.Candidates[0].Content.Parts[0]), nil
}

This file encapsulates Gemini calls. Note the error wrapping and checks for empty responses, which make failures explicit and easier to diagnose.

Building the Handler Layer

Handlers respond to HTTP requests. Group handlers together on a struct to pass shared dependencies (AI client, logger) explicitly.

Create handlers/handlers.go:

// handlers/handlers.go
package handlers

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "time"

    "comedian-api/ai"
)

type Response struct {
    Content   string    `json:"content"`
    Type      string    `json:"type"`
    Timestamp time.Time `json:"timestamp"`
}

type ErrorResponse struct {
    Error     string    `json:"error"`
    Timestamp time.Time `json:"timestamp"`
}

type Handler struct {
    aiClient *ai.Client
    logger   *log.Logger
}

func NewHandler(aiClient *ai.Client, logger *log.Logger) *Handler {
    return &Handler{
        aiClient: aiClient,
        logger:   logger,
    }
}

func (h *Handler) HandleJoke(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    joke, err := h.aiClient.GenerateJoke(ctx)
    if err != nil {
        h.logger.Printf("Error generating joke: %v", err)
        h.respondWithError(w, "Failed to generate joke", http.StatusInternalServerError)
        return
    }

    h.respondWithJSON(w, Response{
        Content:   joke,
        Type:      "joke",
        Timestamp: time.Now(),
    })
}

func (h *Handler) respondWithJSON(w http.ResponseWriter, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(data)
}

func (h *Handler) respondWithError(w http.ResponseWriter, message string, code int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(ErrorResponse{
        Error:     message,
        Timestamp: time.Now(),
    })
}

Struct Tags: The backquoted tags, such as `json:"content"`, instruct Go's encoding/json package how to serialise struct fields. json:"content" ensures the JSON key is content (lowercase) rather than the Go field name Content.

This handler pattern keeps your code testable and modular by explicitly passing shared dependencies via the Handler struct.

Putting It All Together

Wire everything in main.go, including environment variable setup, Chi middleware, and graceful shutdown:

// main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "comedian-api/ai"
    "comedian-api/handlers"
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

func main() {
    logger := log.New(os.Stdout, "[COMEDIAN-API] ", log.LstdFlags)

    apiKey := os.Getenv("GEMINI_API_KEY")
    if apiKey == "" {
        logger.Fatal("GEMINI_API_KEY environment variable not set")
    }

    aiClient, err := ai.NewClient(apiKey)
    if err != nil {
        logger.Fatalf("Failed to create AI client: %v", err)
    }

    h := handlers.NewHandler(aiClient, logger)

    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    r.Get("/joke", h.HandleJoke)
    r.Get("/pun", h.HandlePun)       // add when implemented
    r.Get("/quote", h.HandleQuote)   // add when implemented
    r.Get("/proverb", h.HandleProverb) // add when implemented

    srv := &http.Server{
        Addr:    ":8080",
        Handler: r,
    }

    go func() {
        logger.Println("Starting server on :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            logger.Fatalf("Server failed: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    logger.Println("Shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        logger.Fatalf("Server forced to shutdown: %v", err)
    }

    logger.Println("Server stopped")
}

The graceful shutdown logic uses os/signal and srv.Shutdown to ensure in-flight requests are completed before the process exits.

Adding More Endpoints

Add methods to the AI client for puns, quotes, and proverbs, following the same pattern as GenerateJoke:

// ai/client.go (additional methods)
func (c *Client) GeneratePun(ctx context.Context) (string, error) {
    prompt := "Generate a short, clever pun. Just the pun, no explanation."
    // same pattern as GenerateJoke
}

func (c *Client) GenerateQuote(ctx context.Context) (string, error) {
    prompt := "Generate an inspirational or thought-provoking quote. Just the quote."
    // same pattern as GenerateJoke
}

func (c *Client) GenerateProverb(ctx context.Context) (string, error) {
    prompt := "Generate a wise proverb or saying. Just the proverb."
    // same pattern as GenerateJoke
}

Then, add corresponding handler methods in handlers/handlers.go mirroring the HandleJoke pattern and register the routes in main.go:

r.Get("/pun", h.HandlePun)
r.Get("/quote", h.HandleQuote)
r.Get("/proverb", h.HandleProverb)

If you prefer versioned APIs, use r.Route to group endpoints:

r.Route("/api/v1", func(r chi.Router) {
    r.Get("/joke", h.HandleJoke)
    r.Get("/pun", h.HandlePun)
    r.Get("/quote", h.HandleQuote)
    r.Get("/proverb", h.HandleProverb)
})

By now, your project should look like this:

comedian-api/
├── main.go
├── ai/
│   └── client.go
├── handlers/
│   └── handlers.go
└── go.mod

Access a Global pool of Talented and Experienced Developers

Hire skilled professionals to build innovative products, implement agile practices, and use open-source solutions

Start Hiring

Testing Your API

Refactor the handler to depend on an interface rather than a concrete client type to improve testability.

Example interface and updated handler constructor:

// handlers/types.go
package handlers

import "context"

type AIClient interface {
    GenerateJoke(ctx context.Context) (string, error)
    GeneratePun(ctx context.Context) (string, error)
    GenerateQuote(ctx context.Context) (string, error)
    GenerateProverb(ctx context.Context) (string, error)
}

Update the Handler struct to accept AIClient:

type Handler struct {
    aiClient AIClient
    logger   *log.Logger
}

func NewHandler(aiClient AIClient, logger *log.Logger) *Handler {
    return &Handler{aiClient: aiClient, logger: logger}
}

A minimal test for GET /joke using httptest:

// handlers/handlers_test.go
package handlers

import (
    "context"
    "net/http"
    "net/http/httptest"
    "testing"
    "log"
    "time"
)

// fakeAI implements AIClient for testing
type fakeAI struct{}

func (f *fakeAI) GenerateJoke(ctx context.Context) (string, error) {
    return "Test joke", nil
}
func (f *fakeAI) GeneratePun(ctx context.Context) (string, error)    { return "", nil }
func (f *fakeAI) GenerateQuote(ctx context.Context) (string, error)  { return "", nil }
func (f *fakeAI) GenerateProverb(ctx context.Context) (string, error){ return "", nil }

func TestHandleJoke(t *testing.T) {
    logger := log.New(nil, "", log.LstdFlags)
    h := NewHandler(&fakeAI{}, logger)

    req := httptest.NewRequest("GET", "/joke", nil)
    w := httptest.NewRecorder()

    h.HandleJoke(w, req)

    resp := w.Result()
    if resp.StatusCode != http.StatusOK {
        t.Fatalf("expected status 200, got %d", resp.StatusCode)
    }

    // Optional: decode JSON and assert content
    // var body Response
    // json.NewDecoder(resp.Body).Decode(&body)
    // if body.Content != "Test joke" { t.Fatalf("unexpected content: %v", body.Content) }
}

This test demonstrates exercising the handler in isolation with a deterministic fake client, avoiding network calls.

Running It

Set the API key in your environment and run the server:

export GEMINI_API_KEY="your-api-key-here"
go run main.go

In another terminal:

curl http://localhost:8080/joke

Security Note: Do not commit API keys or credentials to source control. Use environment variables for local development and secrets managers for production.

Adding More Real-World Features

Practical extensions that are easy to add:

  • Customise Outputs: Add query parameters, e.g., GET /joke?topic=programming.
  • Caching: Use an in-memory LRU cache to avoid invoking the AI on every request.
  • Rate Limiting: Protect your Gemini quota from abuse.
  • Authentication: Add a simple API key check if the service is public.

Wrapping Up

The project demonstrates how to create a modular, testable web service with Go and Chi that interacts with an external AI service. The separation into packages (ai, handlers, main) keeps responsibilities clear. The Handler pattern and the suggested interface refactor improve testability. The code includes sensible error handling and graceful shutdown.

You can now extend the service, deploy it, and share it. The code is yours to adapt and reuse for other small, pleasant projects that teach practical systems design while remaining fun to use.

Frequently Asked Questions

Q: Why Chi instead of Gin or Echo, or other Go web frameworks?

Chi is lightweight and works directly with Go's standard library. It doesn't try to reinvent HTTP handling with custom types. If you learn Chi, you're also learning Go's standard net/http patterns. That said, Gin and Echo are also solid choices, especially if you need features like input validation built-in. For learning, Chi's simplicity is hard to beat.

Q: Do I need to use Gemini AI specifically, or can I swap it out?

You can definitely swap it out. Because we put all the AI logic in a separate package with clear methods like GenerateJoke(), switching to OpenAI, Claude, or any other LLM is just a matter of rewriting the ai/client.go file. The handlers don't need to change at all. That's the benefit of layered architecture.

Q: How do I deploy this?

The simplest way is to use a platform like Fly.io, Railway, or Google Cloud Run. They all support Go and can deploy directly from a GitHub repo. You'll need to set the GEMINI_API_KEY environment variable in your deployment settings. For something more manual, you can compile your Go binary with go build and run it on any Linux server.

Q: How do I handle API rate limits or costs?

Good question. Gemini's free tier is pretty generous, but for production, you'd want to add caching (store recent jokes in memory), rate limiting (limit requests per IP), or both. Chi has middleware for rate limiting, and you could cache responses for a few minutes using a simple in-memory map or something like Redis.

Q: What about CORS if I want to call this from a web frontend?

Chi has CORS middleware built in. Just add this to your main.go after creating the router:

import "github.com/go-chi/cors"

r.Use(cors.Handler(cors.Options{
    AllowedOrigins: []string{"https://*", "http://*"},
    AllowedMethods: []string{"GET", "OPTIONS"},
}))

For production, you'd want to restrict AllowedOrigins to your specific domain instead of wildcards.

Q: How would I add tests to this?

Great instinct. For the AI client, you could create a mock that returns fixed responses instead of calling Gemini. For handlers, you can use Go's httptest package to make fake requests and check responses without starting a server. The layered structure makes testing much easier because you can test each piece independently.

Q: The graceful shutdown seems complex. Is it necessary?

For development? No. But it's a good habit. In production, a graceful shutdown means users don't get dropped connections when you deploy updates. The code looks complex, but it's mostly boilerplate you copy once and forget about. Go's standard library makes it pretty straightforward.

Q: Can I add authentication?

Absolutely. The easiest approach is API key authentication via a custom middleware. Create a middleware function that checks for an API key in the request header, and add it with r.Use(). Chi's middleware pattern makes this clean. For something more robust, look into JWT tokens or OAuth, but API keys are fine for most personal projects.

Subscribe to our newsletter

The latest in talent hiring. In Your Inbox.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Hiring Insights. Delivered.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Request a call back

Lets connect you to qualified tech talents that deliver on your business objectives.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.