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, andDELETEendpoints 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@latestDefine 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 devIn 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/1Add 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
Architecture
Understand how the App, Router, Container, and Extensions work together.
Configuration
Move hardcoded values into config files and environment variables.
Dependency Injection
Register the TodoStore as a service and resolve it in handlers.
Lifecycle
Add startup and shutdown hooks for database connections and background tasks.
How is this guide?