Skip to content

Repository files navigation

phi

Go Reference

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")
iferr!=nil {
returnerr// -> 400 { "error": "missingURLParameters", "message": "id" }
}
user, e:=getUser(id)
ife!=nil {
returnphi.UnknownError(e) // -> 500 { "error": "unknownError", "message": "..." }
}
returnw.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.

Install

go get go.philip.id/phi
import (
"go.philip.id/phi""go.philip.id/phi/middleware"
)

The phi handler model

phi supports two handler styles on the same router:

StyleMethodsSignature
stdlib (chi)GetPostPutfunc(w http.ResponseWriter, r *http.Request)
phi (errors)GETPOSTPUTDELETEfunc(w *phi.Response, r *phi.Request) *phi.Error

The error-aware variants are the uppercaseGET, POST, PUT, DELETE. They register a phi.Handler:

typeHandlerfunc(w*Response, r*Request) *Error

When 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/DELETE have uppercase error-aware variants. For other methods or plain net/http handlers, use the lowercase chi methods (Get, Post, Patch, Handle, Method, …) — they coexist on the same router.

Minimal server

package main
import (
"net/http""os""go.philip.id/phi""go.philip.id/phi/middleware"
)
funcmain() {
r:=phi.NewRouter()
// base middleware stack — must be registered BEFORE any routesr.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 {
returnw.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.

Errors

phi.Error is the value handlers return on failure:

typeErrorstruct {
Errorstring// machine-readable codeMessagestring// human-readable messageStatusCodeint// HTTP status; 0 falls back to the writer's status
}

Build them yourself, or use the constructors:

ConstructorStatusCode
phi.ValidatingError(err)400validatingError
phi.URLParameterError(s)400missingURLParameters
phi.QueryParameterError(s)400missingQueryParameters
phi.BodyParameterError(s)400missingBodyParameters
phi.Unauthorized()401unauthorized
phi.UnknownError(err)500unknownError
// custom errorreturn&phi.Error{Error: "userNotFound", Message: "user not found", StatusCode: 404}
// or a constructorreturnphi.UnknownError(err)

A common pattern in the projects is to predefine reusable error values per package:

varerrorTakenEmail= phi.Error{
Error: "errorTakenEmail",
Message: "this email is already in use",
StatusCode: 409,
}
// ...return&errorTakenEmail

Error response shape & custom handler

The 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 wrapperp.Response(w, nil, e)
})

Responses

*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-Typew.Error(err) // { "error": "unknownError", "message": err.Error() }w.ErrorCustomStatus(err, 422) // same, with an explicit statusw.Redirect(req, "/login", 302) // http.Redirect

JSON always wraps the payload in a { "data": ... } envelope. If you need a different shape, write it via Response/the stdlib writer, or override the ErrorHandler.

Requests

*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 missingiferr!=nil { returnerr }
page, err:=r.QueryParam("page") // from ?page=2; err is 400 if missing// for optional query params, just ignore the error:ifcid, _:=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.

Validation

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:

typeRegisterUserstruct {
FirstNamestring`json:"firstName,required"`LastNamestring`json:"lastName,required"`Companystring`json:"company"`// optionalEmailstring`json:"email,required"`Passwordstring`json:"password,required"`
}
funcregister(w*phi.Response, r*phi.Request) *phi.Error {
body, err:= phi.Validate[RegisterUser](r)
iferr!=nil {
returnerr// 400 { "error": "missingBodyParameters", "message": "missing 'email, password'" }
}
// body is *RegisterUser with all required fields guaranteed non-zeroreturnw.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.

required only checks presence (non-zero value) — it does not validate format or ranges.

Context & Resolve

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).

constUSER_CONTEXT="user"r.Route("/user", func(r phi.Router) {
r.Use(middleware.JWTOrAPIAuth)
r.Resolve(USER_CONTEXT, resolveUser) // runs for every route belowr.GET("/", getUser)
r.POST("/", updateUser)
})
// resolver: return a *T (or an error)funcresolveUser(w*phi.Response, r*phi.Request) (any, *phi.Error) {
id, err:=middleware.GetUserID(r)
iferr!=nil {
returnnil, err
}
user, e:=db.FindOne[models.User]("users", bson.M{"_id": id})
ife!=nil {
returnnil, phi.UnknownError(e)
}
returnuser, nil// stored under USER_CONTEXT
}
// handler: read it back, typedfuncgetUser(w*phi.Response, r*phi.Request) *phi.Error {
user:=phi.GetContext[models.User](r, USER_CONTEXT)
returnw.JSON(user)
}

Set/read context values manually:

// in a plain middlewarereq:=r.SetContext("requestStart", &start) // returns *http.Request with the value setnext.ServeHTTP(w, req)
// typed read; returns *Tuser:=phi.GetContext[models.User](r, USER_CONTEXT)

GetContext[T] does an unchecked type assertion to *T and will panic if the value is missing or of a different type. Only read keys you know are set (e.g. by a Resolve above the handler). See TODO.md.

Middleware

phi's middlewares are plain net/http middlewares, so anything in the ecosystem that is net/http-compatible works too.

Standard middleware (go.philip.id/phi/middleware)

HandlerDescription
RequestIDInject a request ID into each request's context
RealIPSet RemoteAddr from X-Real-IP/X-Forwarded-For
LoggerLog start/end of each request with elapsed time
RecovererRecover from panics and print the stack trace
Timeout(d)Signal the request context when the deadline is hit
Throttle / ThrottleBacklogLimit the number of concurrent requests
CompressGzip responses for clients that accept them
Heartbeat(path)Health endpoint that returns .
ProfilerMount net/http/pprof on a router
AllowContentType / AllowContentEncodingWhitelist request Content-Type / Content-Encoding
ContentCharsetEnforce charset on Content-Type request headers
CleanPath / StripSlashes / RedirectSlashesPath normalization
GetHeadRoute undefined HEAD requests to GET handlers
NoCacheSet headers to prevent client caching
SetHeader(k, v) / WithValue(k, v)Short-hand response-header / context-value setters
URLFormat / RouteHeaders / PageRoute / PathRewriteRouting helpers

See the package docs for the full list.

Auth middleware

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 itjwt:=jwtauth.New("HS256", []byte(os.Getenv("JWT_SECRET")), nil)
r.Use(jwtauth.Verifier(jwt))
// protect a route groupr.Route("/user", func(r phi.Router) {
r.Use(middleware.JWTAuth) // require a valid JWT// r.Use(middleware.JWTOrAPIAuth) // accept a JWT OR an API tokenr.GET("/", getUser)
})
// read the authenticated user inside a handleruid, err:=middleware.GetUserID(r) // -> user id from the token
FunctionPurpose
JWTAuth / JWTAuthOptionalRequire (or optionally read) a JWT
APIAuth / APIAuthOptionalRequire (or optionally read) an API token
JWTOrAPIAuth / JWTOrAPIAuthOptionalAccept 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
BasicAuthHTTP Basic authentication

CORS (go.philip.id/phi/cors)

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,
}))

Real-world project layout

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 + mountingfuncStart() error {
phi.SetErrorHandler(myErrorHandler) // optional: project-wide error enveloper:=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 {
returnw.Response([]byte("API Online!"), "text/plain")
})
user.RegisterEndpoint(r)
payment.RegisterEndpoint(r)
returnhttp.ListenAndServe(":"+os.Getenv("PORT"), r)
}
// pkg/user/main.go — one domain owns its routesfuncRegisterEndpoint(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)
})
}

Router interface

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 /articlesr.POST("/", createArticle) // POST /articlesr.Route("/{articleID}", func(r phi.Router) {
r.Resolve("article", resolveArticle)
r.GET("/", getArticle) // GET /articles/123r.PUT("/", updateArticle) // PUT /articles/123r.DELETE("/", deleteArticle) // DELETE /articles/123
})
})
r.Mount("/admin", adminRouter()) // attach a separate http.Handler

Examples

See _examples/ for runnable examples.

Subpackages

PackageImportDescription
middlewarego.philip.id/phi/middlewareStandard + auth middleware
jwtauthgo.philip.id/phi/jwtauthJWT signing/verification
corsgo.philip.id/phi/corsCORS handler

Credits

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.

License

Licensed under the MIT License. Includes MIT-licensed code from go-chi (Copyright (c) 2015-present Peter Kieltyka).

About

lightweight, idiomatic and composable router for building Go HTTP services + new stuff

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages