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.