Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.16.0] - 2026-04-06

### Added

- **router** — Named routes via `RouteEntry.Name(name)` and `Router.URL(name, params...)` for reverse URL generation with `{param}` and `{param...}` placeholder substitution
- **router** — Route introspection: `Router.Routes()` returns a snapshot of all registered `RouteInfo` entries; `Router.Walk(fn)` iterates with early-exit support
- **router** — `RouteInfo.HandlerName` captures the original handler's function name via `runtime.FuncForPC` for debugging and documentation
- **router** — Parameter constraints: `ValidateParams(handler, constraints...)` wraps a handler with path-parameter validation; built-in constraint constructors `Int`, `UUID`, `Regex`, `OneOf`
- **router** — `With(middleware...)` returns a child group for per-route inline middleware without affecting sibling routes
- **router** — `Route(prefix, fn, middleware...)` for inline sub-routing — creates a child group, calls `fn` to register routes, and returns the group for further use
- **router** — `Mount(prefix, handler)` attaches an `http.Handler` (or `*Router`) at a prefix with `http.StripPrefix`; sub-router routes and named routes are merged into the parent's route table
- **router** — `Static(prefix, dir)` serves files from a filesystem directory; `File(pattern, filePath)` serves a single file for GET requests
- **router** — `WithNotFound(handler)` and `WithMethodNotAllowed(handler)` options for custom 404/405 handlers, taking precedence over the `ErrorHandler`
- **router** — `WithStripSlash()` silently removes trailing slashes before routing; `WithRedirectSlash()` sends 301 redirects (mutually exclusive, panics if both set)

### Changed

- **router** — All method helpers (`Get`, `Post`, `Put`, `Patch`, `Delete`, `Head`, `Options` and their `Func` variants) and `Handle`/`HandleFunc` now return `*RouteEntry` for optional `.Name()` chaining
- **router** — `register()` accepts an additional `origFn` parameter to capture the original handler name before middleware wrapping

## [0.15.0] - 2026-04-04

### Changed
Expand Down
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A production-ready Go toolkit for building REST APIs. Zero mandatory dependencie
- **`response`** — Consistent JSON envelope, fluent builder, pagination helpers, SSE streaming, XML, JSONP, and more
- **`middleware`** — Request ID, logging, panic recovery, CORS, rate limiting, auth, security headers, timeout
- **`httpclient`** — HTTP client with retries, exponential backoff, circuit breaker, and `HTTPClient` interface for mocking
- **`router`** — Route grouping with `.Get()`/`.Post()` method helpers, prefix groups, and per-group middleware on top of `http.ServeMux`
- **`router`** — Route grouping with method helpers, named routes, URL generation, parameter constraints, sub-router mounting, static file serving, and trailing-slash handling on top of `http.ServeMux`
- **`server`** — Graceful shutdown wrapper with signal handling, lifecycle hooks, and TLS support
- **`health`** — Health check endpoint builder with dependency checks, timeouts, and liveness/readiness probes
- **`config`** — Load configuration from env vars, `.env` files, and JSON files into typed structs with validation
Expand Down Expand Up @@ -376,6 +376,62 @@ admin.Delete("/users/{id}", deleteUser)
// Handle/HandleFunc for http.Handler (e.g. file servers)
api.Handle("GET /docs", http.FileServer(http.Dir("./docs")))

// --- Named routes & URL generation ---
r.Get("/users/{id}", getUser).Name("get-user")
r.Get("/files/{path...}", serveFile).Name("files")

url := r.URL("get-user", "id", "42") // "/users/42"
url = r.URL("files", "path", "docs/readme") // "/files/docs/readme"

// --- Inline sub-routing with Route() ---
r.Route("/users", func(sub *router.Group) {
sub.Get("/", listUsers)
sub.Get("/{id}", getUser)
sub.Post("/", createUser)
})

// --- Per-route middleware with With() ---
r.With(authMiddleware).Get("/admin", adminHandler)

// --- Mount sub-routers ---
adminRouter := router.New()
adminRouter.Get("/stats", statsHandler)
r.Mount("/admin", adminRouter) // routes & named routes are merged

// --- Static files ---
r.Static("/assets", "./public") // serve directory
r.File("/favicon.ico", "./favicon.ico") // serve single file

// --- Parameter constraints ---
r.Get("/users/{id}", router.ValidateParams(getUser,
router.Int("id"),
))
r.Get("/items/{slug}", router.ValidateParams(getItem,
router.Regex("slug", `^[a-z0-9-]+$`),
))
r.Get("/status/{s}", router.ValidateParams(getStatus,
router.OneOf("s", "active", "inactive", "pending"),
))

// --- Trailing slash handling ---
r = router.New(router.WithStripSlash()) // "/users/" → "/users" (silent)
r = router.New(router.WithRedirectSlash()) // "/users/" → 301 → "/users"

// --- Custom 404/405 handlers ---
r = router.New(
router.WithNotFound(custom404Handler),
router.WithMethodNotAllowed(custom405Handler),
)

// --- Route introspection ---
for _, ri := range r.Routes() {
fmt.Printf("%s %s → %s\n", ri.Method, ri.Pattern, ri.HandlerName)
}
r.Walk(func(ri router.RouteInfo) error {
// ...
return nil
})

// Use with server package
srv := server.New(r, server.WithAddr(":8080"))
srv.Start()
Expand Down
82 changes: 82 additions & 0 deletions router/constraint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package router

import (
"fmt"
"net/http"
"regexp"
"strconv"
"strings"

"github.com/KARTIKrocks/apikit/errors"
)

// ParamConstraint defines a validation rule for a single path parameter.
type ParamConstraint struct {
Name string // path parameter name (must match {name} in the pattern)
Validate func(string) bool // returns true if the value is valid
ErrMessage string // message for the BadRequest error on failure
}

// ValidateParams wraps a HandlerFunc with path parameter validation.
// Constraints are checked in order before the handler is called.
// On the first failure, it returns errors.BadRequest with the constraint's ErrMessage.
func ValidateParams(fn HandlerFunc, constraints ...ParamConstraint) HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
for _, c := range constraints {
val := r.PathValue(c.Name)
if !c.Validate(val) {
return errors.BadRequest(c.ErrMessage)
}
}
return fn(w, r)
}
}

// Int returns a constraint that requires the parameter to be a valid integer.
func Int(name string) ParamConstraint {
return ParamConstraint{
Name: name,
Validate: func(s string) bool {
_, err := strconv.Atoi(s)
return err == nil
},
ErrMessage: fmt.Sprintf("parameter %q must be an integer", name),
}
}

// UUID returns a constraint that requires the parameter to be a valid UUID.
func UUID(name string) ParamConstraint {
re := regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
return ParamConstraint{
Name: name,
Validate: re.MatchString,
ErrMessage: fmt.Sprintf("parameter %q must be a valid UUID", name),
}
}

// Regex returns a constraint that requires the parameter to match the given regular expression.
// It panics if the pattern is not a valid regular expression.
func Regex(name string, pattern string) ParamConstraint {
re := regexp.MustCompile(pattern)
return ParamConstraint{
Name: name,
Validate: re.MatchString,
ErrMessage: fmt.Sprintf("parameter %q has invalid format", name),
}
}

// OneOf returns a constraint that requires the parameter to be one of the allowed values.
func OneOf(name string, values ...string) ParamConstraint {
allowed := make(map[string]struct{}, len(values))
for _, v := range values {
allowed[v] = struct{}{}
}
return ParamConstraint{
Name: name,
Validate: func(s string) bool {
_, ok := allowed[s]
return ok
},
ErrMessage: fmt.Sprintf("parameter %q must be one of: %s", name, strings.Join(values, ", ")),
}
}
Loading
Loading