DEV Community

Cover image for Go 1.27 HTTP Router with Generic Methods and Zero-Alloc Routing: FURSY — What an Independent Audit Found at 94% Coverage
Andrey Kolkov
Andrey Kolkov

Posted on

Go 1.27 HTTP Router with Generic Methods and Zero-Alloc Routing: FURSY — What an Independent Audit Found at 94% Coverage


We shipped FURSY v0.5.4 thinking it was production-ready. Ninety-four percent test coverage. Thirty-five findings from our internal audit, all fixed. Twelve CI checks across three operating systems. Zero-alloc routing under 100 ns/op.

Then we handed the codebase to an independent auditor. They found the CORS middleware echoing back unfiltered headers to browsers. The SECURITY.md teaching six APIs that don't exist. Error responses claiming RFC 9457 compliance while returning text/plain. A path parser that panics on Google's most widely used REST convention.

This is the story of what went wrong, how we fixed it, and what fursy actually is after the cleanup.

What Is Fursy

FURSY (Fast Universal Routing SYstem) is an HTTP router for Go 1.27+ with three properties that don't usually come together:

  1. Generic methods on the router -- router.POST("/users", handler) where handler receives a typed *Box[CreateReq, UserRes]. Go 1.27 enabled this; to our knowledge, FURSY is the first Go router to ship generic methods on the router type.

  2. Zero-allocation routing -- sub-100 ns/op for static routes, 0 B/op, 0 allocs/op. The radix tree reuses a caller-provided param buffer from sync.Pool.

  3. RFC 9457 Problem Details as the default -- every auto-generated error (404, 405, 401, 429, 500) returns application/problem+json. Not opt-in. Not "if you use our error type." Every response.

We built it as the successor to ozzo-routing, Qiang Xue's router from the Yii framework ecosystem. When Xue handed over the go-ozzo organization in mid-2026, FURSY was already running in our internal services.

The Generic Methods Story

Before Go 1.27, if you wanted type-safe handlers in Go, your options were ugly:

// Package-level functions -- verbose, no type inference
fursy.POST[CreateUserReq, UserRes](router, "/users", handler)

// Or abandon type safety -- manual binding, no compile-time guarantees
router.Handle("POST", "/users", func(c *Context) error {
    var req CreateUserReq
    // manual JSON decode, manual validation, manual error handling...
})
Enter fullscreen mode Exit fullscreen mode

Go 1.27 allows methods on concrete types to declare their own type parameters. This changes everything:

router.POST("/users", func(c *fursy.Box[CreateUserReq, UserRes]) error {
    // c.ReqBody is *CreateUserReq -- already validated, already bound
    user := createUser(c.ReqBody)
    return c.Created("/users/"+user.ID, UserRes{ID: user.ID, Name: user.Name})
})
Enter fullscreen mode Exit fullscreen mode

No explicit type parameters at the call site. The compiler infers [CreateUserReq, UserRes] from the handler signature. Binding happens automatically before your handler runs -- if the JSON is malformed, the client gets a 400 before your code executes.

This sounds minor until you have 200 endpoints. The reduction in ceremony is significant.

What the Audit Found: CORS Was Echoing Everything

The most dangerous finding was in the CORS middleware. When a browser sends a preflight OPTIONS request, it includes Access-Control-Request-Headers listing which headers the JavaScript wants to use. The server should respond with only the headers it actually allows.

Our code:

// cors.go -- BEFORE the fix
func (cfg *CORSConfig) setPreflightHeaders(origin, method, reqHeaders string, headers http.Header) {
    allowed, allowedHeaders := cfg.isPreflightAllowed(origin, method, reqHeaders)
    if !allowed {
        return
    }
    // ...
    if allowedHeaders != "" {
        headers.Set("Access-Control-Allow-Headers", reqHeaders)  // BUG
    }
}
Enter fullscreen mode Exit fullscreen mode

See it? isPreflightAllowed returns allowedHeaders (the filtered list), but line 225 writes reqHeaders (the raw request value). The browser sends Content-Type, X-Evil-Header, we configured the server to only allow Content-Type, and the response says "sure, X-Evil-Header is fine too."

The fix was one word:

headers.Set("Access-Control-Allow-Headers", allowedHeaders)  // FIXED
Enter fullscreen mode Exit fullscreen mode

One word. Ninety-four percent test coverage didn't catch it because every test requested only headers that were in the allowlist. The regression test we added:

func TestCORS_PreflightFilteredHeaders(t *testing.T) {
    r := fursy.New()
    r.Use(CORSWithConfig(CORSConfig{
        AllowOrigins: "https://example.com",
        AllowMethods: "GET,POST",
        AllowHeaders: "Content-Type,Authorization",
    }))
    // ... setup ...

    req := httptest.NewRequest("OPTIONS", "/api", http.NoBody)
    req.Header.Set("Access-Control-Request-Headers", "Content-Type, X-Evil-Header")
    // ...

    got := w.Header().Get("Access-Control-Allow-Headers")
    if strings.Contains(got, "X-Evil-Header") {
        t.Errorf("preflight echoed disallowed header: got %q", got)
    }
}
Enter fullscreen mode Exit fullscreen mode

Lesson: test the negative case. Every CORS test we had sent only valid headers.

The Documentation Was Lying

The auditor checked whether our docs matched the actual API. They didn't.

Document said Reality
router.Run(":8080") Method doesn't exist. Never did.
fursy.CSRF(fursy.CSRFConfig{...}) No CSRF middleware
fursy.Timeout(30 * time.Second) Doesn't exist
fursy.BodyLimit(10 * 1024 * 1024) Doesn't exist. Body limit is router.SetMaxBodySize()
fursy.HTTPSRedirect() Doesn't exist
fursy.RateLimit(100, time.Minute) Actual signature: middleware.RateLimit(float64, int). This wouldn't even compile.
import "encoding/json/v2" Code uses encoding/json. Go 1.27 made v2 the default behind this import.
SECURITY.md: supported versions 0.1.x, 0.2.x Current version: 0.5.4

Six non-existent APIs documented as if they were real. An LLM coding assistant reading our llms.txt would confidently generate code that doesn't compile. A developer copying from SECURITY.md would get build errors.

How did this happen? The docs were written during the design phase and never reconciled with what was actually implemented. Classic drift -- the code evolves, the prose doesn't.

We cleaned out the worst offenders. Full documentation parity is still in progress -- doc drift is a hydra, and every time we grep for one stale reference, two more surface. The rule going forward: if the API doesn't exist in the source tree, it doesn't exist in the docs.

RFC 9457: Claiming It vs. Being It

Fursy's README said "Native RFC 9457 Problem Details." The auditor checked what GET /nonexistent actually returned:

HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8

Not Found
Enter fullscreen mode Exit fullscreen mode

Plain text. Not JSON. Not RFC 9457. The "native" Problem Details only activated when a handler explicitly returned a fursy.Problem. Every auto-generated error -- 404, 405, 413, 415, 400, 500, and all middleware rejections -- was text/plain.

We fixed this across all paths:

// BEFORE -- text/plain
terminalHandler = func(ctx *Context) error {
    return ctx.String(http.StatusNotFound, "Not Found")
}

// AFTER -- RFC 9457
terminalHandler = func(ctx *Context) error {
    return ctx.Problem(NewProblem(http.StatusNotFound, "Not Found", ""))
}
Enter fullscreen mode Exit fullscreen mode

Same change in JWT (401), BasicAuth (401), RateLimit (429), CircuitBreaker (503), Recovery (500). Every error response is now:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404
}
Enter fullscreen mode Exit fullscreen mode

With Content-Type: application/problem+json. This is a breaking change -- clients parsing text/plain error bodies will break. We shipped it in v0.6.0 because the project is pre-1.0 and the previous behavior was a lie.

The Colon That Broke Google's API Convention

A developer migrating from ozzo-routing reported that route registration panics:

router.GET("/clients:getOrganization/:id", handler1)
router.GET("/clients:getAddress/:id", handler2)
// panic: conflicting wildcard names: getOrganization vs getAddress
Enter fullscreen mode Exit fullscreen mode

The : in clients:getOrganization was being parsed as a wildcard param marker. But this is a Google AIP-136 custom method -- the most widely adopted enterprise REST convention for non-CRUD operations. Google Firestore alone has 20+ endpoints using this pattern: /documents:commit, /documents:batchGet, /documents:rollback.

RFC 3986 Section 3.3 lists : as a legal path character. It doesn't need escaping. The :param convention is a Sinatra invention from 2007, not an RFC.

The fix: colons are wildcards only at segment start (after / or position 0):

// BEFORE
func findWildcardIndex(path string) int {
    for i := 0; i < len(path); i++ {
        if path[i] == ':' || path[i] == '*' {
            return i  // any position
        }
    }
    return -1
}

// AFTER
func findWildcardIndex(path string) int {
    for i := 0; i < len(path); i++ {
        if (path[i] == ':' || path[i] == '*') && (i == 0 || path[i-1] == '/') {
            return i  // segment start only
        }
    }
    return -1
}
Enter fullscreen mode Exit fullscreen mode

For context: Gin fixed this in v1.11.0 after six years of open issues. Echo needed three separate PRs. httprouter never fixed it. Express.js has had it open since 2019.

Form Binding Errors: 500 Instead of 400

Submitting age=abc where age is an int field returned 500 Internal Server Error. The strconv parse error wasn't wrapped in our DecodeError type, so the error handler fell through to the catch-all:

// BEFORE -- raw error, caught by nothing
if err := setField(field, values[0]); err != nil {
    return fmt.Errorf("set field %s error: %w", structField.Name, err)
}

// AFTER -- wrapped, caught as 400
if err := setField(field, values[0]); err != nil {
    return &DecodeError{Err: fmt.Errorf("field %s: %w", structField.Name, err)}
}
Enter fullscreen mode Exit fullscreen mode

A user sending ?age=abc now gets 400 Bad Request with a Problem Details body explaining which field failed. Not a 500 that makes your monitoring think the server is crashing.

This fix covers setField type mismatches. Errors from ParseForm/ParseMultipartForm itself (malformed percent-encoding, corrupt multipart boundaries) were also wrapped in v0.6.2.

What Fursy Doesn't Do

  • No ORM -- use Relica or GORM or raw database/sql
  • No migrations -- use goose or atlas
  • No template rendering -- this is an API router, not a web framework
  • No WebSocket/SSE built-in -- SSE works via the stream plugin; WebSocket upgrade requires the base responseWriter (no middleware wrappers in the chain) until stream adopts http.NewResponseController
  • No process management -- use daemon if you need it
  • No CSRF middleware -- we removed the docs that claimed it existed. If you need CSRF, use gorilla/csrf or write your own. We'll add it when we have a design we're confident in, not before.

Fursy vs. Other Go Routers (2026)

Router Go Version Type Safety Allocs/op Error Format Colon Literal Dependencies
Go 1.22+ stdlib 1.22+ {param} only ~3 text/plain Yes ({param}) 0
chi v5 1.22+ {param} only ~2 text/plain Yes ({param}) 0
Gin 1.18+ No generics 0 custom JSON Yes (v1.11+) 8
Echo 1.18+ No generics 1 custom JSON Yes (escaped) 4
Fiber 1.21+ No generics 0 text/plain No fasthttp
fursy 1.27+ Generic methods 0 (plain) / 2 (generic) RFC 9457 Yes (v0.6.2) 2 (JWT, rate)

Fursy requires Go 1.27. That's a hard requirement. If you're on 1.22 or earlier, use chi or the stdlib mux. If you want zero dependencies, use the stdlib. If you need the fastest raw throughput, Fiber on fasthttp wins.

Fursy is for teams that want type-safe handlers, zero-alloc routing, and RFC-compliant error responses in a single package, and can target Go 1.27+.

Numbers

Metric Value
Core library ~8,200 lines
Test code ~16,000 lines (2:1 ratio)
Test functions 510+ (core + plugins)
Benchmarks 21
Coverage 93.8% core, 96.5% middleware
Static route lookup <100 ns/op, 0 alloc
Parametric route lookup <140 ns/op, 0 alloc
Production dependencies 2 (golang-jwt, x/time)
Plugin dependencies Isolated per plugin
CI checks 12 (3 OS, lint, format, vuln, 4 plugins, examples, codecov)
External audit findings 45 claims checked, 33 confirmed, all fixed in v0.6.0-v0.6.2

Gotchas

router.GET Is a Generic Method Now

After v0.5.0, router.GET expects Handler[Req, Res] (a function taking *Box[Req, Res]), not HandlerFunc (taking *Context). For plain handlers without type-safe binding:

// This does NOT compile:
router.GET("/health", func(c *fursy.Context) error { ... })

// Use Handle for plain handlers:
router.Handle("GET", "/health", func(c *fursy.Context) error { ... })

// Or use the generic form with Empty types:
router.GET("/health", func(c *fursy.Box[fursy.Empty, fursy.Empty]) error { ... })
Enter fullscreen mode Exit fullscreen mode

Plugins Have Their Own go.mod

Each plugin (plugins/opentelemetry, plugins/database, plugins/stream, plugins/validator) is a separate Go module. When upgrading fursy, update the plugin's go.mod too:

cd plugins/stream
go get github.com/coregx/fursy@v0.6.2
go mod tidy
Enter fullscreen mode Exit fullscreen mode

CORS Must Be Global

CORS middleware must be registered at the router level (router.Use()), not on a group. Preflight OPTIONS requests hit the global middleware chain before route matching. Group-level CORS will miss them. This matches Gin, Echo, and Chi behavior.

The Second Pass

The first audit found the CORS echo, the phantom docs, and the text/plain lie. We fixed those in v0.6.0. Then we sent the fix branch back for re-validation. The second pass found 11 more: an open redirect in the trailing-slash handler, WebSocket hijack broken by our own response wrapper, the circuit breaker counting 404s as backend failures, and the OpenAPI generator not knowing about the colon fix it shipped alongside. All fixed in v0.6.2.

The pattern is humbling: every fix creates a surface for the next bug. The CORS allowlist fix shipped next to a default that had been blocking JSON POST preflights all along -- no test ever sent one. The colon fix in the radix tree didn't propagate to the OpenAPI converter. The response wrapper that tracked written didn't implement Hijack. Ninety-four percent coverage catches syntax, not semantics.

Try It

go get github.com/coregx/fursy@v0.6.2
Enter fullscreen mode Exit fullscreen mode

The project is at v0.6.2. The API is not frozen -- we're pre-1.0 and will break things when the design demands it. The llms.txt documents every API pattern for both developers and AI coding assistants.

coregx/fursy -- Type-safe HTTP router for Go 1.27+. RFC 9457 errors. Zero-alloc routing. MIT license.

Top comments (0)