Build a Todo API in about 30 minutes using the current safe public APIs.
By the end of this tutorial you will have:
GET /todosPOST /todosGET /todos/{id}PUT /todos/{id}DELETE /todos/{id}GET /openapi.json
The finished version in this repository lives at examples/todo-api.
mkdir todo-api
cd todo-api
go mod init todo-api
go get github.com/fgrzl/mux
go get github.com/google/uuidCreate main.go and start with the data model and in-memory storage:
package main
import (
"context"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/fgrzl/mux"
"github.com/google/uuid"
)
type Todo struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreateTodoRequest struct {
Title string `json:"title"`
Description string `json:"description"`
}
type UpdateTodoRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Completed *bool `json:"completed,omitempty"`
}
var (
todos = make(map[string]*Todo)
todosMu sync.RWMutex
)Add the handlers below the model definitions.
func listTodos(c mux.RouteContext) {
completed, hasCompleted := c.Query().Bool("completed")
todosMu.RLock()
defer todosMu.RUnlock()
result := make([]*Todo, 0, len(todos))
for _, todo := range todos {
if hasCompleted && todo.Completed != completed {
continue
}
result = append(result, todo)
}
c.OK(result)
}func createTodo(c mux.RouteContext) {
var req CreateTodoRequest
if err := c.Bind(&req); err != nil {
c.BadRequest("Invalid JSON", err.Error())
return
}
if req.Title == "" {
c.BadRequest("Validation Error", "Title is required")
return
}
todo := &Todo{
ID: uuid.New().String(),
Title: req.Title,
Description: req.Description,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
todosMu.Lock()
todos[todo.ID] = todo
todosMu.Unlock()
c.Created(todo)
}func getTodo(c mux.RouteContext) {
id, ok := c.Params().String("id")
if !ok {
c.BadRequest("Missing parameter", "id parameter is required")
return
}
todosMu.RLock()
todo, exists := todos[id]
todosMu.RUnlock()
if !exists {
c.NotFound()
return
}
c.OK(todo)
}
func updateTodo(c mux.RouteContext) {
id, ok := c.Params().String("id")
if !ok {
c.BadRequest("Missing parameter", "id parameter is required")
return
}
var req UpdateTodoRequest
if err := c.Bind(&req); err != nil {
c.BadRequest("Invalid JSON", err.Error())
return
}
todosMu.Lock()
defer todosMu.Unlock()
todo, exists := todos[id]
if !exists {
c.NotFound()
return
}
if req.Title != nil {
todo.Title = *req.Title
}
if req.Description != nil {
todo.Description = *req.Description
}
if req.Completed != nil {
todo.Completed = *req.Completed
}
todo.UpdatedAt = time.Now()
c.OK(todo)
}
func deleteTodo(c mux.RouteContext) {
id, ok := c.Params().String("id")
if !ok {
c.BadRequest("Missing parameter", "id parameter is required")
return
}
todosMu.Lock()
defer todosMu.Unlock()
if _, exists := todos[id]; !exists {
c.NotFound()
return
}
delete(todos, id)
c.NoContent()
}Now add main() and register the routes inside Configure(...).
func main() {
router := mux.NewRouter()
if err := router.Configure(func(router *mux.Router) {
api := router.Group("/todos")
api.WithTags("Todos")
api.GET("/", listTodos).
WithOperationID("listTodos").
WithSummary("List all todos").
WithQueryParam("completed", "Filter todos by completion state", true).
WithOKResponse([]Todo{})
api.POST("/", createTodo).
WithOperationID("createTodo").
WithSummary("Create a new todo").
WithJSONBody(CreateTodoRequest{}).
WithCreatedResponse(Todo{})
api.GET("/{id}", getTodo).
WithOperationID("getTodo").
WithSummary("Get a todo by ID").
WithPathParam("id", "The unique identifier of the todo", "todo-123").
WithOKResponse(Todo{}).
WithResponse(404, mux.ProblemDetails{})
api.PUT("/{id}", updateTodo).
WithOperationID("updateTodo").
WithSummary("Update a todo").
WithPathParam("id", "The unique identifier of the todo", "todo-123").
WithJSONBody(UpdateTodoRequest{}).
WithOKResponse(Todo{})
api.DELETE("/{id}", deleteTodo).
WithOperationID("deleteTodo").
WithSummary("Delete a todo").
WithPathParam("id", "The unique identifier of the todo", "todo-123").
WithNoContentResponse()
router.GET("/openapi.json", func(c mux.RouteContext) {
spec, err := mux.GenerateSpecWithGenerator(mux.NewGenerator(), router)
if err != nil {
c.ServerError("OpenAPI generation failed", err.Error())
return
}
c.OK(spec)
})
router.GET("/", func(c mux.RouteContext) {
c.OK(map[string]string{
"message": "Todo API",
"docs": "/openapi.json",
})
})
}); err != nil {
panic(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := mux.NewServer(":8080", router)
if err := server.Listen(ctx); err != nil {
panic(err)
}
}go run .Create a todo:
curl -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn Mux","description":"Finish the tutorial"}'List todos:
curl http://localhost:8080/todosFilter completed todos:
curl "http://localhost:8080/todos?completed=true"Update a todo:
curl -X PUT http://localhost:8080/todos/{id} \
-H "Content-Type: application/json" \
-d '{"completed":true}'Delete a todo:
curl -X DELETE http://localhost:8080/todos/{id}Inspect the generated OpenAPI document:
curl http://localhost:8080/openapi.jsonOnce you have your own version working, compare it with the maintained example in examples/todo-api/main.go. That version includes the same flow with repository-style naming and comments.
After the in-memory version works, the next practical upgrades are:
- Replace the map with a real database.
- Add authentication middleware.
- Add pagination and sorting.
- Add integration tests.
- Serve the OpenAPI document with Swagger UI or another viewer.