Your First App

Build a complete REST API with Forge step by step

In this tutorial, you will build a complete TODO REST API with CRUD operations, middleware, error handling, and health checks. Each step builds on the previous one, so follow along in order.

What You Will Build

  • A REST API with GET, POST, PUT, and DELETE endpoints for managing TODOs
  • Input validation and structured error responses
  • Request logging middleware
  • A custom health check
  • Proper use of Forge's handler pattern and context

Prerequisites

Make sure you have completed the Installation guide, have Go 1.21+ installed, and have the Forge CLI available (forge version).


Create the project

mkdir todo-api && cd todo-api
go mod init todo-api
go get github.com/xraph/forge@latest

Define the data model

Create main.go and start by defining the TODO struct and an in-memory store:

package main

import (
    "fmt"
    "sync"
    "time"

    "github.com/xraph/forge"
)

// Todo represents a single todo item.
type Todo struct {
    ID        string    `json:"id"`
    Title     string    `json:"title"`
    Completed bool      `json:"completed"`
    CreatedAt time.Time `json:"created_at"`
}

// TodoStore provides thread-safe in-memory storage.
type TodoStore struct {
    mu     sync.RWMutex
    todos  map[string]Todo
    nextID int
}

func NewTodoStore() *TodoStore {
    return &TodoStore{
        todos: make(map[string]Todo),
    }
}

func (s *TodoStore) All() []Todo {
    s.mu.RLock()
    defer s.mu.RUnlock()
    result := make([]Todo, 0, len(s.todos))
    for _, t := range s.todos {
        result = append(result, t)
    }
    return result
}

func (s *TodoStore) Get(id string) (Todo, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    t, ok := s.todos[id]
    return t, ok
}

func (s *TodoStore) Create(title string) Todo {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.nextID++
    todo := Todo{
        ID:        fmt.Sprintf("%d", s.nextID),
        Title:     title,
        Completed: false,
        CreatedAt: time.Now(),
    }
    s.todos[todo.ID] = todo
    return todo
}

func (s *TodoStore) Update(id string, title string, completed bool) (Todo, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    todo, ok := s.todos[id]
    if !ok {
        return Todo{}, false
    }
    todo.Title = title
    todo.Completed = completed
    s.todos[id] = todo
    return todo, true
}

func (s *TodoStore) Delete(id string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    _, ok := s.todos[id]
    if ok {
        delete(s.todos, id)
    }
    return ok
}

func (s *TodoStore) Count() int {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return len(s.todos)
}

Create the Forge application with routes

Add the main function with all CRUD routes:

func main() {
    // Initialize the store
    store := NewTodoStore()

    // Create the Forge application
    app := forge.New(
        forge.WithAppName("todo-api"),
        forge.WithAppVersion("1.0.0"),
        forge.WithHTTPAddress(":8080"),
    )

    // Group all API routes under /api/v1
    api := app.Router().Group("/api/v1")

    // LIST all todos
    api.GET("/todos", func(ctx forge.Context) error {
        return ctx.JSON(200, store.All())
    })

    // GET a single todo by ID
    api.GET("/todos/:id", func(ctx forge.Context) error {
        id := ctx.Param("id")
        todo, ok := store.Get(id)
        if !ok {
            return forge.NotFound("todo not found")
        }
        return ctx.JSON(200, todo)
    })

    // CREATE a new todo
    api.POST("/todos", func(ctx forge.Context) error {
        var input struct {
            Title string `json:"title"`
        }
        if err := ctx.Bind(&input); err != nil {
            return forge.BadRequest("invalid request body")
        }
        if input.Title == "" {
            return forge.BadRequest("title is required")
        }
        todo := store.Create(input.Title)
        return ctx.JSON(201, todo)
    })

    // UPDATE an existing todo
    api.PUT("/todos/:id", func(ctx forge.Context) error {
        id := ctx.Param("id")
        var input struct {
            Title     string `json:"title"`
            Completed bool   `json:"completed"`
        }
        if err := ctx.Bind(&input); err != nil {
            return forge.BadRequest("invalid request body")
        }
        todo, ok := store.Update(id, input.Title, input.Completed)
        if !ok {
            return forge.NotFound("todo not found")
        }
        return ctx.JSON(200, todo)
    })

    // DELETE a todo
    api.DELETE("/todos/:id", func(ctx forge.Context) error {
        id := ctx.Param("id")
        if !store.Delete(id) {
            return forge.NotFound("todo not found")
        }
        return ctx.NoContent()
    })

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

Add request logging middleware

Insert middleware before the route group to log every incoming request:

func requestLogger(next forge.Handler) forge.Handler {
    return func(ctx forge.Context) error {
        start := time.Now()

        // Call the next handler
        err := next(ctx)

        // Log after the request completes
        duration := time.Since(start)
        method := ctx.Request().Method
        path := ctx.Request().URL.Path
        status := ctx.Response().Status()

        fmt.Printf("[%s] %s %s -> %d (%s)\n",
            time.Now().Format("15:04:05"),
            method, path, status, duration,
        )

        return err
    }
}

Apply it to the route group:

api := app.Router().Group("/api/v1",
    forge.WithGroupMiddleware(requestLogger),
)

Add error handling

Forge's handler pattern returns errors, and the framework handles them automatically. When you return an HTTPError, Forge sends the appropriate status code and JSON body:

// These are built-in error constructors:
forge.BadRequest("message")    // 400
forge.Unauthorized("message")  // 401
forge.Forbidden("message")     // 403
forge.NotFound("message")      // 404
forge.InternalError("message") // 500

// Or create a custom HTTP error:
forge.NewHTTPError(422, "validation failed")

If a handler returns a plain error (not an HTTPError), Forge automatically wraps it as a 500 Internal Server Error and logs the details. In production mode, the internal error message is not exposed to the client.

Test with curl

Start the server and run through the full CRUD cycle:

# Start the development server
forge dev

In another terminal:

# Create a todo
curl -X POST http://localhost:8080/api/v1/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Forge"}'

# List all todos
curl http://localhost:8080/api/v1/todos

# Get a specific todo
curl http://localhost:8080/api/v1/todos/1

# Update a todo
curl -X PUT http://localhost:8080/api/v1/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Forge", "completed": true}'

# Delete a todo
curl -X DELETE http://localhost:8080/api/v1/todos/1

# Try getting a deleted todo (expect 404)
curl http://localhost:8080/api/v1/todos/1

Add a custom health check

Register a health check that verifies the store is accessible:

app.HealthManager().RegisterFn("todo-store", func(ctx context.Context) *forge.HealthResult {
    count := store.Count()
    return &forge.HealthResult{
        Status:  forge.HealthStatusHealthy,
        Message: "todo store operational",
        Details: map[string]any{
            "todo_count": count,
        },
    }
})

You will need to add "context" to your imports for this step.

Now GET /_/health will include your custom check in the aggregated health report:

curl http://localhost:8080/_/health | jq .

Complete Source Code

Here is the full main.go with all the pieces assembled:

package main

import (
    "context"
    "fmt"
    "sync"
    "time"

    "github.com/xraph/forge"
)

type Todo struct {
    ID        string    `json:"id"`
    Title     string    `json:"title"`
    Completed bool      `json:"completed"`
    CreatedAt time.Time `json:"created_at"`
}

type TodoStore struct {
    mu     sync.RWMutex
    todos  map[string]Todo
    nextID int
}

func NewTodoStore() *TodoStore {
    return &TodoStore{todos: make(map[string]Todo)}
}

func (s *TodoStore) All() []Todo {
    s.mu.RLock()
    defer s.mu.RUnlock()
    result := make([]Todo, 0, len(s.todos))
    for _, t := range s.todos {
        result = append(result, t)
    }
    return result
}

func (s *TodoStore) Get(id string) (Todo, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    t, ok := s.todos[id]
    return t, ok
}

func (s *TodoStore) Create(title string) Todo {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.nextID++
    todo := Todo{
        ID:        fmt.Sprintf("%d", s.nextID),
        Title:     title,
        CreatedAt: time.Now(),
    }
    s.todos[todo.ID] = todo
    return todo
}

func (s *TodoStore) Update(id, title string, completed bool) (Todo, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    todo, ok := s.todos[id]
    if !ok {
        return Todo{}, false
    }
    todo.Title = title
    todo.Completed = completed
    s.todos[id] = todo
    return todo, true
}

func (s *TodoStore) Delete(id string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    _, ok := s.todos[id]
    delete(s.todos, id)
    return ok
}

func (s *TodoStore) Count() int {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return len(s.todos)
}

func requestLogger(next forge.Handler) forge.Handler {
    return func(ctx forge.Context) error {
        start := time.Now()
        err := next(ctx)
        fmt.Printf("[%s] %s %s -> %d (%s)\n",
            time.Now().Format("15:04:05"),
            ctx.Request().Method,
            ctx.Request().URL.Path,
            ctx.Response().Status(),
            time.Since(start),
        )
        return err
    }
}

func main() {
    store := NewTodoStore()

    app := forge.New(
        forge.WithAppName("todo-api"),
        forge.WithAppVersion("1.0.0"),
        forge.WithHTTPAddress(":8080"),
    )

    // Register health check for the store
    app.HealthManager().RegisterFn("todo-store",
        func(ctx context.Context) *forge.HealthResult {
            return &forge.HealthResult{
                Status:  forge.HealthStatusHealthy,
                Message: "todo store operational",
                Details: map[string]any{"todo_count": store.Count()},
            }
        },
    )

    // API routes with logging middleware
    api := app.Router().Group("/api/v1",
        forge.WithGroupMiddleware(requestLogger),
    )

    api.GET("/todos", func(ctx forge.Context) error {
        return ctx.JSON(200, store.All())
    })

    api.GET("/todos/:id", func(ctx forge.Context) error {
        todo, ok := store.Get(ctx.Param("id"))
        if !ok {
            return forge.NotFound("todo not found")
        }
        return ctx.JSON(200, todo)
    })

    api.POST("/todos", func(ctx forge.Context) error {
        var input struct {
            Title string `json:"title"`
        }
        if err := ctx.Bind(&input); err != nil {
            return forge.BadRequest("invalid request body")
        }
        if input.Title == "" {
            return forge.BadRequest("title is required")
        }
        return ctx.JSON(201, store.Create(input.Title))
    })

    api.PUT("/todos/:id", func(ctx forge.Context) error {
        var input struct {
            Title     string `json:"title"`
            Completed bool   `json:"completed"`
        }
        if err := ctx.Bind(&input); err != nil {
            return forge.BadRequest("invalid request body")
        }
        todo, ok := store.Update(ctx.Param("id"), input.Title, input.Completed)
        if !ok {
            return forge.NotFound("todo not found")
        }
        return ctx.JSON(200, todo)
    })

    api.DELETE("/todos/:id", func(ctx forge.Context) error {
        if !store.Delete(ctx.Param("id")) {
            return forge.NotFound("todo not found")
        }
        return ctx.NoContent()
    })

    if err := app.Run(); err != nil {
        panic(err)
    }
}

Next Steps

How is this guide?

On this page