phi is an opinionated fork of go-chi. It keeps chi's
fast radix-tree router and 100% net/http compatibility, and adds the thing chi leaves to
you: built-in error handling for route handlers.
Instead of writing status codes and JSON bodies by hand in every handler, a phi handler just returns an error:
r.GET("/users/{id}", func(w *phi.Response, r *phi.Request) *phi.Error {
id, err := r.URLParam("id")
if err != nil {
return err // -> 400 { "error": "missingURLParameters", "message": "id" }
}
user, e := getUser(id)
if e != nil {
return phi.UnknownError(e) // -> 500 { "error": "unknownError", "message": "..." }
}
return w.JSON(user) // -> 200 { "data": { ... } }
})On top of the router, phi adds typed helpers for JSON responses, request-body validation,
URL/query params, request-context injection (Resolve), and a JWT / API-token auth
middleware suite. It's used in production across several services.
phi is a fork — most of the router internals and the base middleware come from go-chi, and all credit for that work goes to the go-chi project.
go get go.philip.id/phiimport (
"go.philip.id/phi"
"go.philip.id/phi/middleware"
)phi supports two handler styles on the same router:
| Style | Methods | Signature |
|---|---|---|
| stdlib (chi) | Get Post Put … |
func(w http.ResponseWriter, r *http.Request) |
| phi (errors) | GET POST PUT DELETE |
func(w *phi.Response, r *phi.Request) *phi.Error |
The error-aware variants are the uppercase GET, POST, PUT, DELETE. They register a
phi.Handler:
type Handler func(w *Response, r *Request) *ErrorWhen a handler returns a non-nil *phi.Error, phi passes it to the active ErrorHandler
(see Errors) which writes the HTTP response. When it returns nil, you've already
written the response (typically via w.JSON(...)).
Only
GET/POST/PUT/DELETEhave uppercase error-aware variants. For other methods or plainnet/httphandlers, use the lowercase chi methods (Get,Post,Patch,Handle,Method, …) — they coexist on the same router.
package main
import (
"net/http"
"os"
"go.philip.id/phi"
"go.philip.id/phi/middleware"
)
func main() {
r := phi.NewRouter()
// base middleware stack — must be registered BEFORE any routes
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.GET("/", func(w *phi.Response, r *phi.Request) *phi.Error {
return w.Response([]byte("API Online!"), "text/plain")
})
http.ListenAndServe(":"+os.Getenv("PORT"), r)
}Middleware (
Use,Resolve) must be registered before routes are added to that mux — phi panics otherwise.
phi.Error is the value handlers return on failure:
type Error struct {
Error string // machine-readable code
Message string // human-readable message
StatusCode int // HTTP status; 0 falls back to the writer's status
}Build them yourself, or use the constructors:
| Constructor | Status | Code |
|---|---|---|
phi.ValidatingError(err) |
400 | validatingError |
phi.URLParameterError(s) |
400 | missingURLParameters |
phi.QueryParameterError(s) |
400 | missingQueryParameters |
phi.BodyParameterError(s) |
400 | missingBodyParameters |
phi.Unauthorized() |
401 | unauthorized |
phi.UnknownError(err) |
500 | unknownError |
// custom error
return &phi.Error{Error: "userNotFound", Message: "user not found", StatusCode: 404}
// or a constructor
return phi.UnknownError(err)A common pattern in the projects is to predefine reusable error values per package:
var errorTakenEmail = phi.Error{
Error: "errorTakenEmail",
Message: "this email is already in use",
StatusCode: 409,
}
// ...
return &errorTakenEmailThe default ErrorHandler writes:
{ "error": "<Error>", "message": "<Message>" }with StatusCode (when non-zero). Override it globally to fit your own envelope:
phi.SetErrorHandler(func(w http.ResponseWriter, r *http.Request, e *phi.Error) {
// e.g. delegate to your own response wrapper
p.Response(w, nil, e)
})*phi.Response embeds http.ResponseWriter, so all stdlib methods are available, plus:
w.JSON(data) // 200, wraps body as { "data": <data> }
w.Response(bytes, "text/plain") // raw body with explicit Content-Type
w.Error(err) // { "error": "unknownError", "message": err.Error() }
w.ErrorCustomStatus(err, 422) // same, with an explicit status
w.Redirect(req, "/login", 302) // http.Redirect
JSONalways wraps the payload in a{ "data": ... }envelope. If you need a different shape, write it viaResponse/the stdlib writer, or override theErrorHandler.
*phi.Request embeds *http.Request and adds param helpers that return a *phi.Error:
id, err := r.URLParam("id") // from /users/{id}; err is 400 if missing
if err != nil { return err }
page, err := r.QueryParam("page") // from ?page=2; err is 400 if missing
// for optional query params, just ignore the error:
if cid, _ := r.QueryParam("contextId"); cid != "" {
filter["contextId"] = cid
}The package-level phi.URLParam(r *http.Request, key string) string (chi-style, returns ""
if absent) is also available for stdlib handlers and middleware.
phi.Validate[T] decodes a JSON body into T and checks that all required fields are set.
Mark required fields by adding required to the json tag:
type RegisterUser struct {
FirstName string `json:"firstName,required"`
LastName string `json:"lastName,required"`
Company string `json:"company"` // optional
Email string `json:"email,required"`
Password string `json:"password,required"`
}
func register(w *phi.Response, r *phi.Request) *phi.Error {
body, err := phi.Validate[RegisterUser](r)
if err != nil {
return err // 400 { "error": "missingBodyParameters", "message": "missing 'email, password'" }
}
// body is *RegisterUser with all required fields guaranteed non-zero
return w.JSON(body)
}Validation recurses into nested structs, slices, arrays, maps and pointers. There's also
phi.ValidateString[T](s string) for validating a JSON string instead of the request body.
requiredonly checks presence (non-zero value) — it does not validate format or ranges.
Resolve is a Use-style middleware helper: it runs a resolver, and on success stores the
returned value in the request context under a token. On error it short-circuits through the
ErrorHandler. It removes the boilerplate of writing a full middleware just to put one value
on the context (e.g. loading the current user once for a whole route group).
const USER_CONTEXT = "user"
r.Route("/user", func(r phi.Router) {
r.Use(middleware.JWTOrAPIAuth)
r.Resolve(USER_CONTEXT, resolveUser) // runs for every route below
r.GET("/", getUser)
r.POST("/", updateUser)
})
// resolver: return a *T (or an error)
func resolveUser(w *phi.Response, r *phi.Request) (any, *phi.Error) {
id, err := middleware.GetUserID(r)
if err != nil {
return nil, err
}
user, e := db.FindOne[models.User]("users", bson.M{"_id": id})
if e != nil {
return nil, phi.UnknownError(e)
}
return user, nil // stored under USER_CONTEXT
}
// handler: read it back, typed
func getUser(w *phi.Response, r *phi.Request) *phi.Error {
user := phi.GetContext[models.User](r, USER_CONTEXT)
return w.JSON(user)
}Set/read context values manually:
// in a plain middleware
req := r.SetContext("requestStart", &start) // returns *http.Request with the value set
next.ServeHTTP(w, req)
// typed read; returns *T
user := phi.GetContext[models.User](r, USER_CONTEXT)
GetContext[T]does an unchecked type assertion to*Tand will panic if the value is missing or of a different type. Only read keys you know are set (e.g. by aResolveabove the handler). See TODO.md.
phi's middlewares are plain net/http middlewares, so anything in the ecosystem that is
net/http-compatible works too.
| Handler | Description |
|---|---|
RequestID |
Inject a request ID into each request's context |
RealIP |
Set RemoteAddr from X-Real-IP/X-Forwarded-For |
Logger |
Log start/end of each request with elapsed time |
Recoverer |
Recover from panics and print the stack trace |
Timeout(d) |
Signal the request context when the deadline is hit |
Throttle / ThrottleBacklog |
Limit the number of concurrent requests |
Compress |
Gzip responses for clients that accept them |
Heartbeat(path) |
Health endpoint that returns . |
Profiler |
Mount net/http/pprof on a router |
AllowContentType / AllowContentEncoding |
Whitelist request Content-Type / Content-Encoding |
ContentCharset |
Enforce charset on Content-Type request headers |
CleanPath / StripSlashes / RedirectSlashes |
Path normalization |
GetHead |
Route undefined HEAD requests to GET handlers |
NoCache |
Set headers to prevent client caching |
SetHeader(k, v) / WithValue(k, v) |
Short-hand response-header / context-value setters |
URLFormat / RouteHeaders / PageRoute / PathRewrite |
Routing helpers |
See the package docs for the full list.
phi ships an auth suite in the same middleware package, used together with the
jwtauth subpackage:
import (
"go.philip.id/phi/jwtauth"
"go.philip.id/phi/middleware"
)
// setup: build a verifier and mount it
jwt := jwtauth.New("HS256", []byte(os.Getenv("JWT_SECRET")), nil)
r.Use(jwtauth.Verifier(jwt))
// protect a route group
r.Route("/user", func(r phi.Router) {
r.Use(middleware.JWTAuth) // require a valid JWT
// r.Use(middleware.JWTOrAPIAuth) // accept a JWT OR an API token
r.GET("/", getUser)
})
// read the authenticated user inside a handler
uid, err := middleware.GetUserID(r) // -> user id from the token| Function | Purpose |
|---|---|
JWTAuth / JWTAuthOptional |
Require (or optionally read) a JWT |
APIAuth / APIAuthOptional |
Require (or optionally read) an API token |
JWTOrAPIAuth / JWTOrAPIAuthOptional |
Accept either a JWT or an API token |
GetUserID(r) / GetToken(r) |
Read the authenticated user id / raw token |
SetTokenCheckFunc(fn) |
Plug in how API tokens are validated |
SetUnauthorizedFunc(fn) |
Customize the 401 response |
BasicAuth |
HTTP Basic authentication |
import "go.philip.id/phi/cors"
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
}))The convention across phi services is to register routes per domain via a
RegisterEndpoint(r phi.Router) function, then wire them together in one router package.
// pkg/server/main.go — the base stack + mounting
func Start() error {
phi.SetErrorHandler(myErrorHandler) // optional: project-wide error envelope
r := phi.NewRouter()
r.Use(jwtauth.Verifier(config.JWT))
r.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
r.Use(cors.Handler(corsOptions))
r.Use(middleware.Timeout(60 * time.Second))
r.GET("/", func(w *phi.Response, r *phi.Request) *phi.Error {
return w.Response([]byte("API Online!"), "text/plain")
})
user.RegisterEndpoint(r)
payment.RegisterEndpoint(r)
return http.ListenAndServe(":"+os.Getenv("PORT"), r)
}// pkg/user/main.go — one domain owns its routes
func RegisterEndpoint(r phi.Router) {
r.POST("/register", register)
r.POST("/login", login)
r.Route("/user", func(r phi.Router) {
r.Use(middleware.JWTOrAPIAuth)
r.Resolve(USER_CONTEXT, resolveUser)
r.GET("/", getUser)
r.POST("/", updateUser)
})
}phi's router is a Patricia radix trie, fully
compatible with net/http. URL patterns support named params (/users/{userID}), regex
params (/users/{userID:[0-9]+}) and wildcards (/files/*). The full Router interface
(routing methods, Use, With, Group, Route, Mount, NotFound, …) is documented in
the Go reference.
r.Route("/articles", func(r phi.Router) {
r.With(paginate).GET("/", listArticles) // GET /articles
r.POST("/", createArticle) // POST /articles
r.Route("/{articleID}", func(r phi.Router) {
r.Resolve("article", resolveArticle)
r.GET("/", getArticle) // GET /articles/123
r.PUT("/", updateArticle) // PUT /articles/123
r.DELETE("/", deleteArticle) // DELETE /articles/123
})
})
r.Mount("/admin", adminRouter()) // attach a separate http.HandlerSee _examples/ for runnable
examples.
| Package | Import | Description |
|---|---|---|
| middleware | go.philip.id/phi/middleware |
Standard + auth middleware |
| jwtauth | go.philip.id/phi/jwtauth |
JWT signing/verification |
| cors | go.philip.id/phi/cors |
CORS handler |
phi is a fork of go-chi/chi by Peter Kieltyka and
contributors — the router core, the radix tree and the base middleware originate there. phi
adds the error-handling layer, typed request/response helpers, validation, Resolve, and the
auth middleware suite.
Licensed under the MIT License. Includes MIT-licensed code from go-chi (Copyright (c) 2015-present Peter Kieltyka).