REST API
REST is the default API mode in Gofasta. When you run gofasta new myapp, the generated project includes a fully working REST API with no additional flags required. GraphQL can be added optionally via the --graphql flag.
This guide covers how controllers, routes, DTOs, middleware, and response helpers work together to handle HTTP requests.
Architecture Overview
Every REST request flows through four layers:
HTTP Request → Route → Controller → Service → Repository → Database- Routes map HTTP methods and paths to controller methods
- Controllers parse requests, validate input, call services, and return responses
- Services contain business logic and orchestrate repository calls
- Repositories interact with the database via GORM
Controllers
Controllers live in app/rest/controllers/. Each controller receives a service through dependency injection and exposes handler methods for CRUD operations.
package controllers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/gofastadev/gofasta/pkg/apperrors"
"github.com/gofastadev/gofasta/pkg/httputil"
"myapp/app/dtos"
"myapp/app/services/interfaces"
)
type ProductController struct {
service interfaces.ProductService
}
func NewProductController(service interfaces.ProductService) *ProductController {
return &ProductController{service: service}
}
func (c *ProductController) Create(w http.ResponseWriter, r *http.Request) error {
var req dtos.CreateProductRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return apperrors.BadRequest("invalid request body")
}
result, err := c.service.Create(r.Context(), req)
if err != nil {
return err
}
return httputil.Created(w, result)
}
func (c *ProductController) List(w http.ResponseWriter, r *http.Request) error {
params := httputil.ParsePaginationParams(r)
products, total, err := c.service.FindAll(r.Context(), params)
if err != nil {
return err
}
return httputil.PaginatedJSON(w, products, total, params)
}
func (c *ProductController) GetByID(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
product, err := c.service.FindByID(r.Context(), id)
if err != nil {
return err
}
return httputil.OK(w, product)
}
func (c *ProductController) Update(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
var req dtos.UpdateProductRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return apperrors.BadRequest("invalid request body")
}
result, err := c.service.Update(r.Context(), id, req)
if err != nil {
return err
}
return httputil.OK(w, result)
}
func (c *ProductController) Archive(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
if err := c.service.Delete(r.Context(), id); err != nil {
return err
}
return httputil.NoContent(w)
}Routes
Routes live in app/rest/routes/. Each resource has its own route file that registers paths and maps them to controller methods.
package routes
import (
"github.com/go-chi/chi/v5"
"github.com/gofastadev/gofasta/pkg/httputil"
"myapp/app/rest/controllers"
)
func ProductRoutes(r chi.Router, ctrl *controllers.ProductController) {
r.Get("/products", httputil.Handle(ctrl.List))
r.Post("/products", httputil.Handle(ctrl.Create))
r.Get("/products/{id}", httputil.Handle(ctrl.GetByID))
r.Put("/products/{id}", httputil.Handle(ctrl.Update))
r.Delete("/products/{id}", httputil.Handle(ctrl.Archive))
}All resource route files are registered in app/rest/routes/index.routes.go, which is called from cmd/serve.go:
package routes
import "github.com/go-chi/chi/v5"
func RegisterRoutes(api chi.Router, deps *di.Container) {
UserRoutes(api, deps.UserController)
ProductRoutes(api, deps.ProductController)
}DTOs (Data Transfer Objects)
DTOs live in app/dtos/ and define the shape of request and response payloads. They keep your API contract separate from your database models.
package dtos
import "time"
type CreateProductRequest struct {
Name string `json:"name" validate:"required"`
Price float64 `json:"price" validate:"required,gt=0"`
}
type UpdateProductRequest struct {
Name *string `json:"name"`
Price *float64 `json:"price" validate:"omitempty,gt=0"`
}
type ProductResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}Use pointer fields in update DTOs to distinguish between “not provided” (nil) and “set to zero value”.
Response Helpers
The github.com/gofastadev/gofasta/pkg/httputil package provides consistent response formatting across your API.
Standard Responses
| Helper | Status Code | Use Case |
|---|---|---|
httputil.OK(w, data) | 200 | Successful read or update |
httputil.Created(w, data) | 201 | Successful resource creation |
httputil.NoContent(w) | 204 | Successful deletion |
httputil.BadRequest(w, msg) | 400 | Validation or parsing failure |
httputil.Unauthorized(w, msg) | 401 | Missing or invalid auth token |
httputil.Forbidden(w, msg) | 403 | Insufficient permissions |
httputil.NotFound(w, msg) | 404 | Resource not found |
httputil.HandleError(w, err) | varies | Maps error types to status codes automatically |
All responses follow a consistent JSON envelope:
{
"success": true,
"data": { ... },
"message": "Resource created successfully"
}Error responses follow the same structure:
{
"success": false,
"error": "Product not found",
"message": "The requested resource does not exist"
}Pagination
httputil.ParsePaginationParams extracts page, per_page, sort, and order from query parameters with sensible defaults:
// GET /api/v1/products?page=2&per_page=20&sort=created_at&order=desc
params := httputil.ParsePaginationParams(r)
// params.Page = 2, params.PerPage = 20, params.Sort = "created_at", params.Order = "desc"httputil.PaginatedJSON wraps the response with pagination metadata:
{
"success": true,
"data": [ ... ],
"meta": {
"page": 2,
"per_page": 20,
"total": 143,
"total_pages": 8
}
}Request Decoding
Use json.NewDecoder from the standard library to parse request bodies, and go-playground/validator/v10 struct tags for validation:
var req dtos.CreateProductRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return apperrors.BadRequest("invalid request body")
}Middleware
Gofasta includes built-in middleware from github.com/gofastadev/gofasta/pkg/middleware. Middleware is applied in cmd/serve.go when the server starts.
import (
"github.com/go-chi/chi/v5"
"github.com/gofastadev/gofasta/pkg/middleware"
)
router := chi.NewRouter()
router.Use(middleware.Logger())
router.Use(middleware.Recovery())
router.Use(middleware.CORS(middleware.CORSConfig{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type"},
}))
router.Use(middleware.RateLimiter(middleware.RateLimiterConfig{
Max: 100,
Window: time.Minute,
}))Available Middleware
| Middleware | Description |
|---|---|
middleware.Logger() | Structured request logging with duration |
middleware.Recovery() | Panic recovery with stack trace logging |
middleware.CORS(config) | Cross-origin resource sharing |
middleware.RateLimiter(config) | Request rate limiting per IP |
middleware.Auth(jwtConfig) | JWT token verification |
middleware.RBAC(enforcer) | Casbin role-based access control |
middleware.RequestID() | Adds a unique request ID to each request |
Protecting Routes
Apply auth middleware to specific route groups:
func RegisterRoutes(api chi.Router, deps *di.Container) {
// Public routes
AuthRoutes(api, deps.AuthController)
// Protected routes — chi's Group runs the shared middleware for every
// route registered inside the callback without mounting at a new prefix.
api.Group(func(protected chi.Router) {
protected.Use(middleware.Auth(deps.JWTConfig))
ProductRoutes(protected, deps.ProductController)
OrderRoutes(protected, deps.OrderController)
})
// Admin-only routes — Route mounts the callback at a sub-path.
api.Route("/admin", func(admin chi.Router) {
admin.Use(middleware.Auth(deps.JWTConfig))
admin.Use(middleware.RBAC(deps.Enforcer))
AdminRoutes(admin, deps.AdminController)
})
}Adding a New Endpoint
The fastest way to add a new resource is with the scaffold generator:
gofasta g scaffold Order total:float status:string user_id:uuidThis generates the model, migration, repository, service, controller, routes, DTOs, and DI provider, then wires everything together automatically.
To add a custom endpoint to an existing controller, add the handler method and register the route:
// In app/rest/controllers/product.controller.go
func (c *ProductController) Search(w http.ResponseWriter, r *http.Request) error {
query := r.URL.Query().Get("q")
products, err := c.service.Search(r.Context(), query)
if err != nil {
return err
}
return httputil.OK(w, products)
}
// In app/rest/routes/product.routes.go
r.Get("/search", httputil.Handle(ctrl.Search))Swapping the Router
The scaffold uses go-chi/chi as its default HTTP router because it builds on net/http, has zero external dependencies, and adds ergonomic subrouters and middleware groups. Like every other pkg/* default in Gofasta, the router is an opt-out default, not a required dependency — you can swap it for gorilla/mux, go-chi v4, or even Go 1.22+‘s standard http.ServeMux without touching the rest of the project.
What “swapping the router” means
Every REST-facing file in a generated project touches the router in exactly one of three ways: it constructs the root router, it registers routes on a passed-in router argument, or it reads a path parameter. Replacing chi means editing those three touch points consistently across the files listed below. No code in the gofasta library depends on chi — so once these files compile, you’re done.
Files to edit
| File | What to change |
|---|---|
app/rest/routes/index.routes.go | Replace chi.NewRouter() with your router constructor. Replace r.Mount("/api/v1", api) with your router’s subrouter/prefix equivalent. |
app/rest/routes/*.routes.go (one per resource) | Change the r chi.Router parameter type and the r.Get / r.Post / r.Put / r.Delete calls to your router’s API. |
app/rest/controllers/*.controller.go (one per resource) | Replace every chi.URLParam(r, "id") call with your router’s path-parameter helper. |
cmd/serve.go | If your replacement router does not return http.Handler, adjust the top-level http.NewServeMux() mount at mux.Handle("/", apiRouter). |
go.mod | go get the replacement and remove the chi require line (run go mod tidy to clean up). |
That’s it — the gofasta library (github.com/gofastadev/gofasta/pkg/*) never imports any router. Middleware in pkg/middleware is plain func(http.Handler) http.Handler, so it works with any router that accepts net/http middleware.
Example — swapping to gorilla/mux
// app/rest/routes/user.routes.go
package routes
import (
"github.com/gorilla/mux"
"github.com/gofastadev/gofasta/pkg/httputil"
"myapp/app/rest/controllers"
)
func UserRoutes(r *mux.Router, uc *controllers.UserController) {
r.HandleFunc("/users", httputil.Handle(uc.ListUsers)).Methods("GET")
r.HandleFunc("/users/{id}", httputil.Handle(uc.GetUser)).Methods("GET")
// ...
}// app/rest/controllers/user.controller.go
func (uc *UserController) GetUser(w http.ResponseWriter, r *http.Request) error {
id := mux.Vars(r)["id"]
// ...
}Example — swapping to stdlib http.ServeMux (Go 1.22+)
// app/rest/routes/user.routes.go
package routes
import (
"net/http"
"github.com/gofastadev/gofasta/pkg/httputil"
"myapp/app/rest/controllers"
)
func UserRoutes(mux *http.ServeMux, uc *controllers.UserController) {
mux.HandleFunc("GET /users", httputil.Handle(uc.ListUsers))
mux.HandleFunc("GET /users/{id}", httputil.Handle(uc.GetUser))
// ...
}// app/rest/controllers/user.controller.go
func (uc *UserController) GetUser(w http.ResponseWriter, r *http.Request) error {
id := r.PathValue("id")
// ...
}Stdlib ServeMux has no built-in subrouter or per-group middleware. If you need those, either prefix every route manually or use http.StripPrefix with a nested *http.ServeMux.
gofasta routes after a swap
The gofasta routes introspection command parses route files for chi’s method-based API (r.Get, r.Post, etc.) and r.Mount(...) calls. If you swap routers, gofasta routes will return an empty list — it won’t crash your app, but the CLI-side listing stops working. You can either stop using it, or vendor a small fork of cli/internal/commands/routes.go that matches your router’s syntax.
Next Steps
- Set up GraphQL alongside REST
- Configure authentication and RBAC
- Generate code with the CLI
- HTTP Utilities API reference
- Middleware API reference