Skip to content

PhilipJovanovic/phi

 
 

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

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:

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) *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"
)

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.

Errors

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 &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 wrapper
    p.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-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

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

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:

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.

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

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

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.

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

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

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 /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.Handler

Examples

See _examples/ for runnable examples.

Subpackages

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

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

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages

  • Go 99.9%
  • Makefile 0.1%