Episode 2 of 8

How to Create and Test Endpoints

Learn how to build a complete CRUD API with Go Fiber. This tutorial covers creating RESTful endpoints, handling request bodies, validating input, structuring responses, and writing tests for every endpoint using Go's testing package and httptest.

In the previous tutorial, we set up a Fiber server with basic routes. Now we'll build a complete CRUD API for managing books — with proper request validation, structured JSON responses, and tests for every endpoint. By the end, you'll have a pattern you can reuse for any resource in your API.

Setting Up the Project

Let's create a clean project structure:

mkdir fiber-books-api
cd fiber-books-api
go mod init fiber-books-api
go get github.com/gofiber/fiber/v2
go get github.com/google/uuid

Define the Book Model

Create models/book.go:

package models

import "time"

type Book struct {
    ID        string    `json:"id"`
    Title     string    `json:"title"`
    Author    string    `json:"author"`
    ISBN      string    `json:"isbn"`
    Pages     int       `json:"pages"`
    Year      int       `json:"year"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

type CreateBookRequest struct {
    Title  string `json:"title"`
    Author string `json:"author"`
    ISBN   string `json:"isbn"`
    Pages  int    `json:"pages"`
    Year   int    `json:"year"`
}

type UpdateBookRequest struct {
    Title  *string `json:"title"`
    Author *string `json:"author"`
    ISBN   *string `json:"isbn"`
    Pages  *int    `json:"pages"`
    Year   *int    `json:"year"`
}

type APIResponse struct {
    Success bool        `json:"success"`
    Data    interface{} `json:"data,omitempty"`
    Message string      `json:"message,omitempty"`
    Error   string      `json:"error,omitempty"`
}

Notice the separate request types — CreateBookRequest for creating (all fields required) and UpdateBookRequest with pointer fields (allows partial updates — only non-nil fields are updated). The APIResponse struct provides a consistent response envelope.

Create the Handlers

Create handlers/book.go:

package handlers

import (
    "fiber-books-api/models"
    "time"

    "github.com/gofiber/fiber/v2"
    "github.com/google/uuid"
)

// In-memory store (replace with database in production)
var books = make(map[string]models.Book)

// GetAllBooks returns all books
func GetAllBooks(c *fiber.Ctx) error {
    bookList := make([]models.Book, 0, len(books))
    for _, book := range books {
        bookList = append(bookList, book)
    }

    return c.JSON(models.APIResponse{
        Success: true,
        Data:    bookList,
    })
}

// GetBook returns a single book by ID
func GetBook(c *fiber.Ctx) error {
    id := c.Params("id")

    book, exists := books[id]
    if !exists {
        return c.Status(fiber.StatusNotFound).JSON(models.APIResponse{
            Success: false,
            Error:   "Book not found",
        })
    }

    return c.JSON(models.APIResponse{
        Success: true,
        Data:    book,
    })
}

// CreateBook creates a new book
func CreateBook(c *fiber.Ctx) error {
    var req models.CreateBookRequest

    // Parse request body
    if err := c.BodyParser(&req); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(models.APIResponse{
            Success: false,
            Error:   "Invalid request body",
        })
    }

    // Validate required fields
    if req.Title == "" {
        return c.Status(fiber.StatusBadRequest).JSON(models.APIResponse{
            Success: false,
            Error:   "Title is required",
        })
    }
    if req.Author == "" {
        return c.Status(fiber.StatusBadRequest).JSON(models.APIResponse{
            Success: false,
            Error:   "Author is required",
        })
    }

    // Create the book
    now := time.Now()
    book := models.Book{
        ID:        uuid.New().String(),
        Title:     req.Title,
        Author:    req.Author,
        ISBN:      req.ISBN,
        Pages:     req.Pages,
        Year:      req.Year,
        CreatedAt: now,
        UpdatedAt: now,
    }

    books[book.ID] = book

    return c.Status(fiber.StatusCreated).JSON(models.APIResponse{
        Success: true,
        Data:    book,
        Message: "Book created successfully",
    })
}

// UpdateBook updates an existing book
func UpdateBook(c *fiber.Ctx) error {
    id := c.Params("id")

    book, exists := books[id]
    if !exists {
        return c.Status(fiber.StatusNotFound).JSON(models.APIResponse{
            Success: false,
            Error:   "Book not found",
        })
    }

    var req models.UpdateBookRequest
    if err := c.BodyParser(&req); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(models.APIResponse{
            Success: false,
            Error:   "Invalid request body",
        })
    }

    // Update only non-nil fields (partial update)
    if req.Title != nil {
        book.Title = *req.Title
    }
    if req.Author != nil {
        book.Author = *req.Author
    }
    if req.ISBN != nil {
        book.ISBN = *req.ISBN
    }
    if req.Pages != nil {
        book.Pages = *req.Pages
    }
    if req.Year != nil {
        book.Year = *req.Year
    }
    book.UpdatedAt = time.Now()

    books[id] = book

    return c.JSON(models.APIResponse{
        Success: true,
        Data:    book,
        Message: "Book updated successfully",
    })
}

// DeleteBook deletes a book
func DeleteBook(c *fiber.Ctx) error {
    id := c.Params("id")

    _, exists := books[id]
    if !exists {
        return c.Status(fiber.StatusNotFound).JSON(models.APIResponse{
            Success: false,
            Error:   "Book not found",
        })
    }

    delete(books, id)

    return c.JSON(models.APIResponse{
        Success: true,
        Message: "Book deleted successfully",
    })
}

Wire Up Routes

Create routes/routes.go:

package routes

import (
    "fiber-books-api/handlers"
    "github.com/gofiber/fiber/v2"
)

func SetupRoutes(app *fiber.App) {
    api := app.Group("/api/v1")

    books := api.Group("/books")
    books.Get("/", handlers.GetAllBooks)
    books.Get("/:id", handlers.GetBook)
    books.Post("/", handlers.CreateBook)
    books.Put("/:id", handlers.UpdateBook)
    books.Delete("/:id", handlers.DeleteBook)
}

Update main.go:

package main

import (
    "fiber-books-api/routes"
    "log"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/logger"
    "github.com/gofiber/fiber/v2/middleware/recover"
)

func main() {
    app := fiber.New(fiber.Config{
        AppName: "Books API v1.0",
    })

    app.Use(logger.New())
    app.Use(recover.New())

    routes.SetupRoutes(app)

    log.Fatal(app.Listen(":3000"))
}

Testing with cURL

Start the server and test every endpoint:

# Create a book
curl -X POST http://localhost:3000/api/v1/books \
  -H "Content-Type: application/json" \
  -d '{"title":"The Go Programming Language","author":"Alan Donovan","isbn":"978-0134190440","pages":380,"year":2015}'

# Get all books
curl http://localhost:3000/api/v1/books

# Get a specific book (use the ID from create response)
curl http://localhost:3000/api/v1/books/BOOK_ID

# Update a book (partial update)
curl -X PUT http://localhost:3000/api/v1/books/BOOK_ID \
  -H "Content-Type: application/json" \
  -d '{"pages":400}'

# Delete a book
curl -X DELETE http://localhost:3000/api/v1/books/BOOK_ID

Writing Automated Tests

Create a test setup helper in tests/setup.go:

package tests

import (
    "fiber-books-api/routes"
    "github.com/gofiber/fiber/v2"
)

func SetupTestApp() *fiber.App {
    app := fiber.New()
    routes.SetupRoutes(app)
    return app
}

Create tests/book_test.go:

package tests

import (
    "bytes"
    "encoding/json"
    "fiber-books-api/models"
    "io"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestCreateBook(t *testing.T) {
    app := SetupTestApp()

    body := `{"title":"Test Book","author":"Test Author","isbn":"123","pages":200,"year":2024}`
    req := httptest.NewRequest(http.MethodPost, "/api/v1/books", bytes.NewBufferString(body))
    req.Header.Set("Content-Type", "application/json")

    resp, err := app.Test(req)
    if err != nil {
        t.Fatalf("Failed to make request: %v", err)
    }

    if resp.StatusCode != http.StatusCreated {
        t.Errorf("Expected status 201, got %d", resp.StatusCode)
    }

    // Parse response
    respBody, _ := io.ReadAll(resp.Body)
    var apiResp models.APIResponse
    json.Unmarshal(respBody, &apiResp)

    if !apiResp.Success {
        t.Error("Expected success to be true")
    }
}

func TestCreateBookValidation(t *testing.T) {
    app := SetupTestApp()

    // Missing title should fail
    body := `{"author":"Test Author"}`
    req := httptest.NewRequest(http.MethodPost, "/api/v1/books", bytes.NewBufferString(body))
    req.Header.Set("Content-Type", "application/json")

    resp, _ := app.Test(req)

    if resp.StatusCode != http.StatusBadRequest {
        t.Errorf("Expected status 400, got %d", resp.StatusCode)
    }
}

func TestGetAllBooks(t *testing.T) {
    app := SetupTestApp()

    req := httptest.NewRequest(http.MethodGet, "/api/v1/books", nil)
    resp, err := app.Test(req)
    if err != nil {
        t.Fatalf("Failed to make request: %v", err)
    }

    if resp.StatusCode != http.StatusOK {
        t.Errorf("Expected status 200, got %d", resp.StatusCode)
    }
}

func TestGetBookNotFound(t *testing.T) {
    app := SetupTestApp()

    req := httptest.NewRequest(http.MethodGet, "/api/v1/books/nonexistent", nil)
    resp, _ := app.Test(req)

    if resp.StatusCode != http.StatusNotFound {
        t.Errorf("Expected status 404, got %d", resp.StatusCode)
    }
}

func TestDeleteBook(t *testing.T) {
    app := SetupTestApp()

    // First create a book
    body := `{"title":"Delete Me","author":"Author","isbn":"456","pages":100,"year":2024}`
    createReq := httptest.NewRequest(http.MethodPost, "/api/v1/books", bytes.NewBufferString(body))
    createReq.Header.Set("Content-Type", "application/json")
    createResp, _ := app.Test(createReq)

    // Get the created book ID
    respBody, _ := io.ReadAll(createResp.Body)
    var result map[string]interface{}
    json.Unmarshal(respBody, &result)
    data := result["data"].(map[string]interface{})
    bookID := data["id"].(string)

    // Delete the book
    deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/books/"+bookID, nil)
    deleteResp, _ := app.Test(deleteReq)

    if deleteResp.StatusCode != http.StatusOK {
        t.Errorf("Expected status 200, got %d", deleteResp.StatusCode)
    }

    // Verify it's deleted
    getReq := httptest.NewRequest(http.MethodGet, "/api/v1/books/"+bookID, nil)
    getResp, _ := app.Test(getReq)

    if getResp.StatusCode != http.StatusNotFound {
        t.Errorf("Expected status 404 after deletion, got %d", getResp.StatusCode)
    }
}

Run the tests:

go test ./tests/ -v

The key insight: Fiber's app.Test() method accepts a standard *http.Request and returns a standard *http.Response — no need to start a real server. This makes tests fast (milliseconds per test) and isolated (no port conflicts, no network I/O).

Testing Tips

  • Use table-driven tests — Go's testing convention for testing multiple scenarios with one test function
  • Test both success and failure paths — Don't just test the happy path; test validation errors, not found, etc.
  • Reset state between tests — Clear the in-memory store before each test to prevent test interdependence
  • Test response structure — Verify not just the status code but the JSON response body
  • Use httptest.NewRequest — Always use the standard library's test utilities for creating requests

What's Next

You've built a complete CRUD API with validation and automated tests. In the next tutorial, we'll explore downloading files, working with URL parameters, and building JSON APIs with more advanced response patterns.

GoFiberTutorialAPITesting