web
packageAPI reference for the web
package.
Imports
(27)net/http
INT
github.com/mirkobrombin/go-foundation/v2/core/contracts
STD
testing
STD
fmt
STD
regexp
STD
strconv
STD
strings
STD
sync
INT
github.com/mirkobrombin/go-foundation/v2/core/auth
STD
context
STD
encoding/json
STD
net/http/httptest
INT
github.com/mirkobrombin/go-foundation/v2/app/di
STD
compress/gzip
STD
errors
INT
github.com/mirkobrombin/go-foundation/v2/app/doctor
STD
io
STD
mime
STD
net
STD
reflect
STD
runtime/debug
STD
time
INT
github.com/mirkobrombin/go-foundation/v2/core/options
INT
github.com/mirkobrombin/go-foundation/v2/core/bind
STD
container/list
INT
github.com/mirkobrombin/go-foundation/v2/core/resiliency
INT
github.com/mirkobrombin/go-foundation/v2/core/validation
TestRunDoctorDisabled
Parameters
func TestRunDoctorDisabled(t *testing.T)
{
s := New()
if err := s.runDoctor(); err != nil {
t.Fatalf("runDoctor() error = %v", err)
}
}
methodHandlers
type methodHandlers map[string]HandlerFunc
paramConstraint
type paramConstraint struct
Fields
| Name | Type | Description |
|---|---|---|
| name | string | |
| validate | func(string) bool |
radixNode
type radixNode struct
Fields
| Name | Type | Description |
|---|---|---|
| children | []*radixNode | |
| segment | string | |
| isParam | bool | |
| isCatchAll | bool | |
| paramName | string | |
| constraints | []paramConstraint | |
| handlers | methodHandlers |
Uses
radixTree
type radixTree struct
Methods
Parameters
Returns
func (*radixTree) insert(method, path string, handler HandlerFunc) error
{
t.mu.Lock()
defer t.mu.Unlock()
if !validRouteMethod(method) {
return fmt.Errorf("web: unsupported HTTP method %q", method)
}
if handler == nil {
return fmt.Errorf("web: route handler cannot be nil")
}
if err := validateRoutePath(path); err != nil {
return err
}
segments := splitPath(path)
if len(segments) > 0 && isCatchAll(segments[len(segments)-1]) {
segments = segments[:len(segments)-1]
node, err := t.walkOrCreate(t.root, segments)
if err != nil {
return err
}
paramName := parseCatchAll(path)
if paramName == "" {
return fmt.Errorf("web: catch-all parameter name cannot be empty")
}
if strings.Contains(paramName, ":") {
return fmt.Errorf("web: catch-all parameters cannot have constraints")
}
for _, child := range node.children {
if child.isParam {
return fmt.Errorf("web: catch-all route %s conflicts with a parameter route", path)
}
if child.isCatchAll {
if child.paramName != paramName {
return fmt.Errorf("web: conflicting catch-all route %s", path)
}
if _, exists := child.handlers[method]; exists {
return fmt.Errorf("web: route %s %s is already registered", method, path)
}
child.handlers[method] = handler
return nil
}
}
catchAllNode := &radixNode{
isCatchAll: true,
paramName: paramName,
children: make([]*radixNode, 0),
}
node.children = append(node.children, catchAllNode)
if catchAllNode.handlers == nil {
catchAllNode.handlers = make(methodHandlers)
}
catchAllNode.handlers[method] = handler
return nil
}
node, err := t.walkOrCreate(t.root, segments)
if err != nil {
return err
}
if node.handlers == nil {
node.handlers = make(methodHandlers)
}
if _, exists := node.handlers[method]; exists {
return fmt.Errorf("web: route %s %s is already registered", method, path)
}
node.handlers[method] = handler
return nil
}
func (*radixTree) walkOrCreate(node *radixNode, segments []string) (*radixNode, error)
{
for _, seg := range segments {
if isParam(seg) {
name, constraints, err := parseParamConstraints(seg)
if err != nil {
return nil, err
}
node, err = findOrCreateParam(node, name, constraints)
if err != nil {
return nil, err
}
} else {
if strings.ContainsAny(seg, "{}") {
return nil, fmt.Errorf("web: malformed route segment %q", seg)
}
node = findOrCreateStatic(node, seg)
}
}
return node, nil
}
Parameters
Returns
func (*radixTree) lookup(method, path string) (HandlerFunc, map[string]string)
{
t.mu.RLock()
defer t.mu.RUnlock()
segments := splitPath(path)
params := make(map[string]string)
handler, ok := lookupNode(t.root, method, segments, 0, params)
if !ok {
return nil, nil
}
return handler, params
}
Fields
| Name | Type | Description |
|---|---|---|
| root | *radixNode | |
| mu | sync.RWMutex |
newRadixTree
Returns
func newRadixTree() *radixTree
{
return &radixTree{
root: &radixNode{children: make([]*radixNode, 0)},
}
}
findOrCreateStatic
func findOrCreateStatic(parent *radixNode, segment string) *radixNode
{
for _, child := range parent.children {
if !child.isParam && !child.isCatchAll && child.segment == segment {
return child
}
}
node := &radixNode{
segment: segment,
isParam: false,
children: make([]*radixNode, 0),
}
parent.children = append(parent.children, node)
return node
}
findOrCreateParam
Parameters
Returns
func findOrCreateParam(parent *radixNode, paramName string, constraints []paramConstraint) (*radixNode, error)
{
if paramName == "" {
return nil, fmt.Errorf("web: route parameter name cannot be empty")
}
for _, child := range parent.children {
if child.isCatchAll {
return nil, fmt.Errorf("web: parameter route %q conflicts with a catch-all route", paramName)
}
if child.isParam {
if child.paramName != paramName || !sameConstraints(child.constraints, constraints) {
return nil, fmt.Errorf("web: ambiguous parameter route for %q", paramName)
}
return child, nil
}
}
node := &radixNode{
isParam: true,
paramName: paramName,
constraints: constraints,
children: make([]*radixNode, 0),
}
parent.children = append(parent.children, node)
return node, nil
}
sameConstraints
Parameters
Returns
func sameConstraints(left, right []paramConstraint) bool
{
if len(left) != len(right) {
return false
}
for i := range left {
if left[i].name != right[i].name {
return false
}
}
return true
}
lookupNode
Parameters
Returns
func lookupNode(node *radixNode, method string, segments []string, index int, params map[string]string) (HandlerFunc, bool)
{
if index == len(segments) {
if handler, ok := node.handlers[method]; ok {
return handler, true
}
for _, child := range node.children {
if !child.isCatchAll {
continue
}
if handler, ok := child.handlers[method]; ok {
params[child.paramName] = ""
return handler, true
}
}
return nil, false
}
segment := segments[index]
for _, child := range node.children {
if child.isParam || child.isCatchAll || child.segment != segment {
continue
}
if handler, ok := lookupNode(child, method, segments, index+1, params); ok {
return handler, true
}
}
for _, child := range node.children {
if !child.isParam || !constraintsMatch(child.constraints, segment) {
continue
}
previous, existed := params[child.paramName]
params[child.paramName] = segment
if handler, ok := lookupNode(child, method, segments, index+1, params); ok {
return handler, true
}
if existed {
params[child.paramName] = previous
} else {
delete(params, child.paramName)
}
}
for _, child := range node.children {
if !child.isCatchAll {
continue
}
handler, ok := child.handlers[method]
if !ok {
continue
}
params[child.paramName] = strings.Join(segments[index:], "/")
return handler, true
}
return nil, false
}
Uses
constraintsMatch
Parameters
Returns
func constraintsMatch(constraints []paramConstraint, value string) bool
{
for _, constraint := range constraints {
if !constraint.validate(value) {
return false
}
}
return true
}
splitPath
Parameters
Returns
func splitPath(path string) []string
{
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
isParam
Parameters
Returns
func isParam(seg string) bool
{
return len(seg) > 2 && seg[0] == '{' && seg[len(seg)-1] == '}'
}
isCatchAll
Parameters
Returns
func isCatchAll(seg string) bool
{
return len(seg) > 3 && seg[0] == '{' && seg[1] == '*' && seg[len(seg)-1] == '}'
}
parseCatchAll
Parameters
Returns
func parseCatchAll(path string) string
{
parts := strings.Split(strings.Trim(path, "/"), "/")
last := parts[len(parts)-1]
if isCatchAll(last) {
return last[2 : len(last)-1]
}
return ""
}
parseParamConstraints
Parameters
Returns
func parseParamConstraints(seg string) (string, []paramConstraint, error)
{
inner := seg[1 : len(seg)-1]
colonIdx := strings.Index(inner, ":")
if colonIdx < 0 {
return inner, nil, nil
}
name := inner[:colonIdx]
constraintStr := inner[colonIdx+1:]
var constraints []paramConstraint
rawConstraints := strings.Split(constraintStr, ",")
if strings.HasPrefix(constraintStr, "regex(") {
rawConstraints = []string{constraintStr}
}
for _, c := range rawConstraints {
switch c {
case "int":
constraints = append(constraints, paramConstraint{
name: "int",
validate: isIntConstraint,
})
case "alpha":
constraints = append(constraints, paramConstraint{
name: "alpha",
validate: isAlphaConstraint,
})
default:
if strings.HasPrefix(c, "regex(") && strings.HasSuffix(c, ")") {
pattern := c[6 : len(c)-1]
validator, err := isRegexConstraint(pattern)
if err != nil {
return "", nil, fmt.Errorf("web: invalid regex constraint %q: %w", pattern, err)
}
constraints = append(constraints, paramConstraint{
name: c,
validate: validator,
})
continue
}
return "", nil, fmt.Errorf("web: unknown route constraint %q", c)
}
}
return name, constraints, nil
}
isIntConstraint
Parameters
Returns
func isIntConstraint(s string) bool
{
_, err := strconv.Atoi(s)
return err == nil
}
isAlphaConstraint
Parameters
Returns
func isAlphaConstraint(s string) bool
{
for _, r := range s {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) {
return false
}
}
return true
}
isRegexConstraint
Parameters
Returns
func isRegexConstraint(pattern string) (func(string) bool, error)
{
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
return func(s string) bool {
return re.MatchString(s)
}, nil
}
validRouteMethod
Parameters
Returns
func validRouteMethod(method string) bool
{
switch method {
case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut,
http.MethodPatch, http.MethodDelete, http.MethodConnect,
http.MethodOptions, http.MethodTrace:
return true
default:
return false
}
}
validateRoutePath
Parameters
Returns
func validateRoutePath(path string) error
{
if !strings.HasPrefix(path, "/") {
return fmt.Errorf("web: route path must start with /")
}
seen := make(map[string]struct{})
segments := splitPath(path)
for index, segment := range segments {
if !strings.ContainsAny(segment, "{}") {
continue
}
if !isParam(segment) {
return fmt.Errorf("web: malformed route segment %q", segment)
}
name := segment[1 : len(segment)-1]
if strings.HasPrefix(name, "*") {
if !isCatchAll(segment) || index != len(segments)-1 {
return fmt.Errorf("web: catch-all parameter must be last and well formed")
}
name = strings.TrimPrefix(name, "*")
if strings.Contains(name, ":") {
return fmt.Errorf("web: catch-all parameters cannot have constraints")
}
} else if separator := strings.IndexByte(name, ':'); separator >= 0 {
if _, _, err := parseParamConstraints(segment); err != nil {
return err
}
name = name[:separator]
}
if !validRouteParameterName(name) {
return fmt.Errorf("web: invalid route parameter name %q", name)
}
if _, exists := seen[name]; exists {
return fmt.Errorf("web: duplicate route parameter %q", name)
}
seen[name] = struct{}{}
}
return nil
}
validRouteParameterName
Parameters
Returns
func validRouteParameterName(name string) bool
{
if name == "" {
return false
}
for index, character := range name {
if index == 0 {
if character != '_' &&
(character < 'A' || character > 'Z') &&
(character < 'a' || character > 'z') {
return false
}
continue
}
if character != '_' &&
(character < 'A' || character > 'Z') &&
(character < 'a' || character > 'z') &&
(character < '0' || character > '9') {
return false
}
}
return true
}
Auth
Auth returns middleware that validates a Bearer token using auth.VerifyToken.
A valid token is attached to the context via auth.Payload.
Parameters
Returns
func Auth(secret []byte) Middleware
{
if err := auth.ValidateHMACSecret(secret); err != nil {
panic(err)
}
secret = append([]byte(nil), secret...)
return func(next HandlerFunc) HandlerFunc {
return func(ctx *Context) error {
h := ctx.Request.Header.Get("Authorization")
if h == "" {
return ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "missing Authorization header"})
}
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid Authorization format"})
}
payload, err := auth.VerifyToken(parts[1], secret)
if err != nil {
return ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid bearer token"})
}
ctx.Set("auth.payload", payload)
return next(ctx)
}
}
}
Uses
PingEndpoint
type PingEndpoint struct
Methods
Parameters
Returns
func (*PingEndpoint) Handle(_ context.Context) (any, error)
{
out := make([]string, e.Times)
for i := range out {
out[i] = "pong"
}
return pingResponse{Message: out}, nil
}
Fields
| Name | Type | Description |
|---|---|---|
| Meta | struct{} | method:"GET" path:"/ping" |
| Times | int | query:"times" default:"1" |
pingResponse
type pingResponse struct
Fields
| Name | Type | Description |
|---|---|---|
| Message | []string | json:"message" |
TestRegisterHandler_Basic
Parameters
func TestRegisterHandler_Basic(t *testing.T)
{
s := New()
b := di.NewBuilder()
container, _ := b.Build()
if err := s.RegisterHandler(&PingEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/ping", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("status = %d, want 200, body: %s", w.Code, w.Body.String())
}
var resp pingResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Message) != 1 {
t.Errorf("messages = %d, want 1", len(resp.Message))
}
}
TestRegisterHandler_Routes
Parameters
func TestRegisterHandler_Routes(t *testing.T)
{
s := New()
b := di.NewBuilder()
container, _ := b.Build()
if err := s.RegisterHandler(&PingEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
routes := s.Routes()
if len(routes) != 1 {
t.Fatalf("routes = %d, want 1", len(routes))
}
if routes[0].Method != "GET" || routes[0].Path != "/ping" {
t.Errorf("route = %s %s, want GET /ping", routes[0].Method, routes[0].Path)
}
}
TestRegisterHandler_WithQuery
Parameters
func TestRegisterHandler_WithQuery(t *testing.T)
{
s := New()
b := di.NewBuilder()
container, _ := b.Build()
if err := s.RegisterHandler(&PingEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/ping?times=3", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("status = %d, want 200", w.Code)
}
var resp pingResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Message) != 3 {
t.Errorf("messages = %d, want 3", len(resp.Message))
}
}
TestRegisterHandler_InvalidQueryReturnsBadRequest
Parameters
func TestRegisterHandler_InvalidQueryReturnsBadRequest(t *testing.T)
{
s := New()
container := di.NewBuilder().MustBuild()
if err := s.RegisterHandler(&PingEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/ping?times=invalid", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 400 {
t.Fatalf("status = %d, want 400", w.Code)
}
}
DiEndpoint
type DiEndpoint struct
Methods
Parameters
Returns
func (*DiEndpoint) Handle(_ context.Context) (any, error)
{
return map[string]string{"greeting": "hello " + e.Name}, nil
}
Fields
| Name | Type | Description |
|---|---|---|
| Meta | struct{} | method:"GET" path:"/greet" |
| Name | string | query:"name" default:"world" |
| Greet | string | inject:"greet" |
TestRegisterHandler_WithDI
Parameters
func TestRegisterHandler_WithDI(t *testing.T)
{
s := New()
b := di.NewBuilder()
b.Provide("greet", "custom-greeting")
container, _ := b.Build()
if err := s.RegisterHandler(&DiEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/greet?name=alice", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("status = %d, want 200", w.Code)
}
}
NoReturnEndpoint
type NoReturnEndpoint struct
Methods
Parameters
Returns
func (*NoReturnEndpoint) Handle(_ context.Context) (any, error)
{
return nil, nil
}
Fields
| Name | Type | Description |
|---|---|---|
| Meta | struct{} | method:"DELETE" path:"/item" |
TestRegisterHandler_NilReturn
Parameters
func TestRegisterHandler_NilReturn(t *testing.T)
{
s := New()
b := di.NewBuilder()
container, _ := b.Build()
if err := s.RegisterHandler(&NoReturnEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
req := httptest.NewRequest("DELETE", "/item", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 204 {
t.Errorf("status = %d, want 204", w.Code)
}
}
TestRegisterHandler_RejectsMissingDependency
Parameters
func TestRegisterHandler_RejectsMissingDependency(t *testing.T)
{
s := New()
container := di.NewBuilder().MustBuild()
if err := s.RegisterHandler(&DiEndpoint{}, container); err == nil {
t.Fatal("RegisterHandler() accepted a missing dependency")
}
}
TestRegisterHandler_RejectsDuplicateRoute
Parameters
func TestRegisterHandler_RejectsDuplicateRoute(t *testing.T)
{
s := New()
container := di.NewBuilder().MustBuild()
if err := s.RegisterHandler(&PingEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
if err := s.RegisterHandler(&PingEndpoint{}, container); err == nil {
t.Fatal("RegisterHandler() accepted a duplicate route")
}
}
TestRegisterDefinition
Parameters
func TestRegisterDefinition(t *testing.T)
{
s := New()
container := di.NewBuilder().MustBuild()
err := s.RegisterDefinition(HandlerDefinition{
Method: "GET",
Path: "/static",
New: func() Handler {
return &PingEndpoint{}
},
}, container)
if err != nil {
t.Fatalf("RegisterDefinition() error = %v", err)
}
req := httptest.NewRequest("GET", "/static", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
}
BodyEndpoint
type BodyEndpoint struct
Methods
Parameters
Returns
func (*BodyEndpoint) Handle(_ context.Context) (any, error)
{
return e.Body, nil
}
Fields
| Name | Type | Description |
|---|---|---|
| Meta | struct{} | method:"POST" path:"/body" |
| Body | struct { Name string `json:"name"` } | body:"json" |
TestRegisterHandler_RejectsUnknownBodyField
Parameters
func TestRegisterHandler_RejectsUnknownBodyField(t *testing.T)
{
s := New()
container := di.NewBuilder().MustBuild()
if err := s.RegisterHandler(&BodyEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
req := httptest.NewRequest("POST", "/body", strings.NewReader(`{"name":"Ada","admin":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 400 {
t.Fatalf("status = %d, want 400", w.Code)
}
}
TestRegisterHandler_RejectsOversizedBody
Parameters
func TestRegisterHandler_RejectsOversizedBody(t *testing.T)
{
s := New()
container := di.NewBuilder().MustBuild()
if err := s.RegisterHandler(&BodyEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
req := httptest.NewRequest("POST", "/body", strings.NewReader(strings.Repeat("x", maxRequestBodySize+1)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 413 {
t.Fatalf("status = %d, want 413", w.Code)
}
}
TestRegisterHandler_RejectsWrongBodyContentType
Parameters
func TestRegisterHandler_RejectsWrongBodyContentType(t *testing.T)
{
s := New()
container := di.NewBuilder().MustBuild()
if err := s.RegisterHandler(&BodyEndpoint{}, container); err != nil {
t.Fatalf("RegisterHandler() error = %v", err)
}
req := httptest.NewRequest("POST", "/body", strings.NewReader(`{"name":"Ada"}`))
req.Header.Set("Content-Type", "text/plain")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 415 {
t.Fatalf("status = %d, want 415", w.Code)
}
}
TestServer_MapGet
Parameters
func TestServer_MapGet(t *testing.T)
{
s := New()
s.MapGet("/hello", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"message": "hello"})
})
req := httptest.NewRequest("GET", "/hello", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("status: got %d, want 200", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["message"] != "hello" {
t.Errorf("body: got %q, want %q", resp["message"], "hello")
}
}
TestServer_Params
Parameters
func TestServer_Params(t *testing.T)
{
s := New()
s.MapGet("/users/{id}", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"id": ctx.Params["id"]})
})
req := httptest.NewRequest("GET", "/users/42", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["id"] != "42" {
t.Errorf("param: got %q, want %q", resp["id"], "42")
}
}
TestServer_NotFound
Parameters
func TestServer_NotFound(t *testing.T)
{
s := New()
req := httptest.NewRequest("GET", "/missing", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 404 {
t.Errorf("status: got %d, want 404", w.Code)
}
}
TestServer_Middleware
Parameters
func TestServer_Middleware(t *testing.T)
{
s := New()
s.Use(RequestID())
s.MapGet("/test", func(ctx *Context) error {
id, _ := ctx.Get("request_id")
return ctx.JSON(200, map[string]any{"request_id": id})
})
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Request-ID", "test-123")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
var resp map[string]any
json.NewDecoder(w.Body).Decode(&resp)
if resp["request_id"] != "test-123" {
t.Errorf("middleware: got %v, want test-123", resp["request_id"])
}
}
TestServer_RecoveryDoesNotExposePanic
Parameters
func TestServer_RecoveryDoesNotExposePanic(t *testing.T)
{
s := New()
s.Use(Recovery())
s.MapGet("/panic", func(ctx *Context) error {
panic("private failure detail")
})
req := httptest.NewRequest("GET", "/panic", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 500 {
t.Fatalf("status = %d, want 500", w.Code)
}
if strings.Contains(w.Body.String(), "private failure detail") ||
strings.Contains(w.Body.String(), "goroutine") {
t.Fatalf("response exposes panic details: %s", w.Body.String())
}
}
TestServer_DoesNotExposeInternalError
Parameters
func TestServer_DoesNotExposeInternalError(t *testing.T)
{
s := New()
s.MapGet("/failure", func(ctx *Context) error {
return errors.New("database connection private detail")
})
req := httptest.NewRequest("GET", "/failure", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 500 {
t.Fatalf("status = %d, want 500", w.Code)
}
if strings.Contains(w.Body.String(), "database") {
t.Fatalf("response exposes internal error: %s", w.Body.String())
}
}
TestServer_ExposesExplicitHTTPError
Parameters
func TestServer_ExposesExplicitHTTPError(t *testing.T)
{
s := New()
s.MapGet("/teapot", func(ctx *Context) error {
return Error(418, "short and stout")
})
req := httptest.NewRequest("GET", "/teapot", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 418 {
t.Fatalf("status = %d, want 418", w.Code)
}
if !strings.Contains(w.Body.String(), "short and stout") {
t.Fatalf("response lost explicit error: %s", w.Body.String())
}
}
TestServer_Group
Parameters
func TestServer_Group(t *testing.T)
{
s := New()
api := s.Group("/api")
api.MapGet("/users", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"path": "/api/users"})
})
req := httptest.NewRequest("GET", "/api/users", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("group route: got %d, want 200", w.Code)
}
}
TestServer_Routes
Parameters
func TestServer_Routes(t *testing.T)
{
s := New()
s.MapGet("/hello", func(ctx *Context) error {
ctx.String(200, "ok")
return nil
})
s.MapPost("/data", func(ctx *Context) error {
ctx.String(201, "ok")
return nil
})
routes := s.Routes()
if len(routes) != 2 {
t.Fatalf("routes = %d, want 2", len(routes))
}
if routes[0].Method != "GET" || routes[0].Path != "/hello" {
t.Errorf("route[0] = %s %s, want GET /hello", routes[0].Method, routes[0].Path)
}
if routes[1].Method != "POST" || routes[1].Path != "/data" {
t.Errorf("route[1] = %s %s, want POST /data", routes[1].Method, routes[1].Path)
}
}
TestServer_HTTPTimeoutDefaults
Parameters
func TestServer_HTTPTimeoutDefaults(t *testing.T)
{
server := New().httpServer(":8080")
if server.ReadHeaderTimeout <= 0 ||
server.ReadTimeout <= 0 ||
server.WriteTimeout <= 0 ||
server.IdleTimeout <= 0 {
t.Fatalf("HTTP timeouts are not configured: %#v", server)
}
if server.MaxHeaderBytes != maxRequestBodySize {
t.Fatalf("MaxHeaderBytes = %d, want %d", server.MaxHeaderBytes, maxRequestBodySize)
}
}
TestServer_CORS
Parameters
func TestServer_CORS(t *testing.T)
{
s := New()
s.Use(CORS("*"))
s.MapGet("/test", func(ctx *Context) error {
ctx.String(200, "ok")
return nil
})
req := httptest.NewRequest("OPTIONS", "/test", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 204 {
t.Errorf("CORS preflight: got %d, want 204", w.Code)
}
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Errorf("CORS header: got %q", w.Header().Get("Access-Control-Allow-Origin"))
}
}
TestServer_CORSSelectsMatchingOrigin
Parameters
func TestServer_CORSSelectsMatchingOrigin(t *testing.T)
{
server := New()
server.Use(CORS("https://one.example", "https://two.example"))
if err := server.MapGet("/cors", func(ctx *Context) error {
ctx.String(http.StatusOK, "ok")
return nil
}); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/cors", nil)
request.Header.Set("Origin", "https://two.example")
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
if got := response.Header().Get("Access-Control-Allow-Origin"); got != "https://two.example" {
t.Fatalf("Access-Control-Allow-Origin = %q", got)
}
if !strings.Contains(response.Header().Get("Vary"), "Origin") {
t.Fatalf("Vary = %q, want Origin", response.Header().Get("Vary"))
}
}
TestServer_CompressWritesGzip
Parameters
func TestServer_CompressWritesGzip(t *testing.T)
{
s := New()
s.Use(Compress())
s.MapGet("/compressed", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"message": "hello"})
})
req := httptest.NewRequest("GET", "/compressed", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("Content-Encoding = %q", w.Header().Get("Content-Encoding"))
}
reader, err := gzip.NewReader(w.Body)
if err != nil {
t.Fatalf("gzip.NewReader() error = %v", err)
}
defer reader.Close()
var body map[string]string
if err := json.NewDecoder(reader).Decode(&body); err != nil {
t.Fatalf("Decode() error = %v", err)
}
if body["message"] != "hello" {
t.Fatalf("message = %q", body["message"])
}
}
TestServer_CompressRestoresWriterAfterPanic
Parameters
func TestServer_CompressRestoresWriterAfterPanic(t *testing.T)
{
s := New()
s.Use(Recovery())
s.Use(Compress())
if err := s.MapGet("/panic", func(*Context) error {
panic("private failure")
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/panic", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
if strings.Contains(w.Body.String(), "private failure") {
t.Fatalf("response exposes panic details: %s", w.Body.String())
}
}
TestServer_CompressRequiresGzipEncodingToken
Parameters
func TestServer_CompressRequiresGzipEncodingToken(t *testing.T)
{
s := New()
s.Use(Compress())
if err := s.MapGet("/plain", func(ctx *Context) error {
return ctx.JSON(http.StatusOK, map[string]string{"message": "hello"})
}); err != nil {
t.Fatal(err)
}
for _, encoding := range []string{"xgzip", "gzip;q=0"} {
req := httptest.NewRequest("GET", "/plain", nil)
req.Header.Set("Accept-Encoding", encoding)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding for %q = %q, want empty", encoding, got)
}
}
}
TestServer_CompressHandlesJSONEncodingError
Parameters
func TestServer_CompressHandlesJSONEncodingError(t *testing.T)
{
s := New()
s.Use(Compress())
if err := s.MapGet("/invalid", func(ctx *Context) error {
return ctx.JSON(http.StatusOK, make(chan int))
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/invalid", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty on encoding error", got)
}
}
TestServer_Bind
Parameters
func TestServer_Bind(t *testing.T)
{
s := New()
s.MapPost("/data", func(ctx *Context) error {
var body map[string]string
if err := ctx.Bind(&body); err != nil {
return err
}
return ctx.JSON(200, body)
})
req := httptest.NewRequest("POST", "/data", strings.NewReader(`{"key":"value"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("bind: got %d, want 200", w.Code)
}
}
TestServer_BindRejectsUnknownJSONField
Parameters
func TestServer_BindRejectsUnknownJSONField(t *testing.T)
{
s := New()
s.MapPost("/data", func(ctx *Context) error {
var body struct {
Name string `json:"name"`
}
return ctx.Bind(&body)
})
req := httptest.NewRequest("POST", "/data", strings.NewReader(`{"name":"Ada","admin":true}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 400 {
t.Fatalf("status = %d, want 400", w.Code)
}
}
TestServer_BindRejectsOversizedBody
Parameters
func TestServer_BindRejectsOversizedBody(t *testing.T)
{
s := New()
s.MapPost("/data", func(ctx *Context) error {
var body map[string]string
return ctx.Bind(&body)
})
body := `{"key":"` + strings.Repeat("x", maxRequestBodySize) + `"}`
req := httptest.NewRequest("POST", "/data", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 413 {
t.Fatalf("status = %d, want 413", w.Code)
}
}
TestServer_BindRejectsUnsupportedMediaType
Parameters
func TestServer_BindRejectsUnsupportedMediaType(t *testing.T)
{
s := New()
s.MapPost("/data", func(ctx *Context) error {
var body map[string]string
return ctx.Bind(&body)
})
req := httptest.NewRequest("POST", "/data", strings.NewReader("name=Ada"))
req.Header.Set("Content-Type", "text/plain")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 415 {
t.Fatalf("status = %d, want 415", w.Code)
}
}
TestServer_FormBindUsesExplicitFields
Parameters
func TestServer_FormBindUsesExplicitFields(t *testing.T)
{
s := New()
s.MapPost("/form", func(ctx *Context) error {
var body struct {
Name string `form:"name"`
IsAdmin bool `json:"-"`
}
if err := ctx.Bind(&body); err != nil {
return err
}
return ctx.JSON(200, body)
})
req := httptest.NewRequest(
"POST",
"/form",
strings.NewReader("name=Ada&IsAdmin=true"),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
if strings.Contains(w.Body.String(), "true") {
t.Fatalf("form bound an unlisted field: %s", w.Body.String())
}
}
TestHealthEndpoint
Parameters
func TestHealthEndpoint(t *testing.T)
{
s := New()
s.MapGet("/health", HealthEndpoint(func(ctx context.Context) error {
return nil
}))
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("health: got %d, want 200", w.Code)
}
}
TestServer_IntConstraint
Parameters
func TestServer_IntConstraint(t *testing.T)
{
s := New()
s.MapGet("/users/{id:int}", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"id": ctx.Params["id"]})
})
req := httptest.NewRequest("GET", "/users/42", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("int constraint valid: got %d, want 200", w.Code)
}
req = httptest.NewRequest("GET", "/users/abc", nil)
w = httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 404 {
t.Errorf("int constraint invalid: got %d, want 404", w.Code)
}
}
TestServer_AlphaConstraint
Parameters
func TestServer_AlphaConstraint(t *testing.T)
{
s := New()
s.MapGet("/items/{slug:alpha}", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"slug": ctx.Params["slug"]})
})
req := httptest.NewRequest("GET", "/items/hello", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("alpha constraint valid: got %d, want 200", w.Code)
}
req = httptest.NewRequest("GET", "/items/hello123", nil)
w = httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 404 {
t.Errorf("alpha constraint invalid: got %d, want 404", w.Code)
}
}
TestServer_CatchAll
Parameters
func TestServer_CatchAll(t *testing.T)
{
s := New()
if err := s.MapGet("/static/{*filepath}", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"path": ctx.Params["filepath"]})
}); err != nil {
t.Fatal(err)
}
if err := s.MapPost("/static/{*filepath}", func(ctx *Context) error {
ctx.Response.WriteHeader(http.StatusCreated)
return nil
}); err != nil {
t.Fatalf("MapPost() for existing catch-all: %v", err)
}
req := httptest.NewRequest("GET", "/static/css/main.css", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("catch-all: got %d, want 200", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["path"] != "css/main.css" {
t.Errorf("catch-all path: got %q, want %q", resp["path"], "css/main.css")
}
req = httptest.NewRequest("GET", "/static/a/b/c/d", nil)
w = httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("deep catch-all: got %d, want 200", w.Code)
}
json.NewDecoder(w.Body).Decode(&resp)
if resp["path"] != "a/b/c/d" {
t.Errorf("deep catch-all: got %q, want %q", resp["path"], "a/b/c/d")
}
req = httptest.NewRequest("POST", "/static/css/main.css", nil)
w = httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("POST catch-all: got %d, want %d", w.Code, http.StatusCreated)
}
}
TestServer_RouteLookupBacktracksFromStaticBranch
Parameters
func TestServer_RouteLookupBacktracksFromStaticBranch(t *testing.T)
{
server := New()
if err := server.MapGet("/users/new/details", func(ctx *Context) error {
ctx.String(http.StatusOK, "static")
return nil
}); err != nil {
t.Fatal(err)
}
if err := server.MapGet("/users/{id}/settings", func(ctx *Context) error {
ctx.String(http.StatusOK, ctx.Params["id"])
return nil
}); err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
server.ServeHTTP(
response,
httptest.NewRequest(http.MethodGet, "/users/new/settings", nil),
)
if response.Code != http.StatusOK || response.Body.String() != "new" {
t.Fatalf("response = (%d, %q), want (200, new)", response.Code, response.Body.String())
}
}
TestServer_RouteLookupBacktracksAcrossMethods
Parameters
func TestServer_RouteLookupBacktracksAcrossMethods(t *testing.T)
{
server := New()
if err := server.MapGet("/items/static", func(ctx *Context) error {
ctx.String(http.StatusOK, "static")
return nil
}); err != nil {
t.Fatal(err)
}
if err := server.MapPost("/items/{id}", func(ctx *Context) error {
ctx.String(http.StatusCreated, ctx.Params["id"])
return nil
}); err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
server.ServeHTTP(
response,
httptest.NewRequest(http.MethodPost, "/items/static", nil),
)
if response.Code != http.StatusCreated || response.Body.String() != "static" {
t.Fatalf("response = (%d, %q), want (201, static)", response.Code, response.Body.String())
}
}
TestServer_RejectsMalformedCatchAllRoutes
Parameters
func TestServer_RejectsMalformedCatchAllRoutes(t *testing.T)
{
for _, path := range []string{
"/static/{*}",
"/static/{*path}/suffix",
"/static/{*path:int}",
"/static/{{id}}",
"/static/{id}/{id}",
"/static/{bad-name}",
} {
t.Run(path, func(t *testing.T) {
server := New()
if err := server.MapGet(path, func(*Context) error { return nil }); err == nil {
t.Fatalf("MapGet(%q) accepted a malformed catch-all", path)
}
})
}
}
TestServer_RejectsParameterCatchAllConflicts
Parameters
func TestServer_RejectsParameterCatchAllConflicts(t *testing.T)
{
handler := func(*Context) error { return nil }
for _, register := range []func(*Server) error{
func(server *Server) error {
if err := server.MapGet("/items/{id}", handler); err != nil {
return err
}
return server.MapPost("/items/{*rest}", handler)
},
func(server *Server) error {
if err := server.MapGet("/items/{*rest}", handler); err != nil {
return err
}
return server.MapPost("/items/{id}", handler)
},
} {
if err := register(New()); err == nil {
t.Fatal("router accepted conflicting parameter and catch-all routes")
}
}
}
TestServer_MethodMultiplex
Parameters
func TestServer_MethodMultiplex(t *testing.T)
{
s := New()
s.MapGet("/items/{id}", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"method": "GET"})
})
s.MapPost("/items/{id}", func(ctx *Context) error {
return ctx.JSON(201, map[string]string{"method": "POST"})
})
s.MapDelete("/items/{id}", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"method": "DELETE"})
})
req := httptest.NewRequest("GET", "/items/1", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("GET: got %d, want 200", w.Code)
}
req = httptest.NewRequest("POST", "/items/1", nil)
w = httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 201 {
t.Errorf("POST: got %d, want 201", w.Code)
}
req = httptest.NewRequest("DELETE", "/items/1", nil)
w = httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("DELETE: got %d, want 200", w.Code)
}
req = httptest.NewRequest("PUT", "/items/1", nil)
w = httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 404 {
t.Errorf("PUT not registered: got %d, want 404", w.Code)
}
}
TestServer_RegexConstraint
Parameters
func TestServer_RegexConstraint(t *testing.T)
{
s := New()
s.MapGet("/files/{name:regex(^[a-z]+\\.txt$)}", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"name": ctx.Params["name"]})
})
req := httptest.NewRequest("GET", "/files/readme.txt", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("regex valid: got %d, want 200, body: %s", w.Code, w.Body.String())
}
req = httptest.NewRequest("GET", "/files/README.TXT", nil)
w = httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 404 {
t.Errorf("regex invalid: got %d, want 404", w.Code)
}
}
TestServer_RejectsUnknownConstraint
Parameters
func TestServer_RejectsUnknownConstraint(t *testing.T)
{
s := New()
if err := s.MapGet("/items/{id:innt}", func(ctx *Context) error {
return nil
}); err == nil {
t.Fatal("MapGet() accepted an unknown constraint")
}
}
TestServer_RejectsInvalidRegexConstraint
Parameters
func TestServer_RejectsInvalidRegexConstraint(t *testing.T)
{
s := New()
if err := s.MapGet("/items/{id:regex([)}", func(ctx *Context) error {
return nil
}); err == nil {
t.Fatal("MapGet() accepted an invalid regex constraint")
}
}
TestServer_RejectsAmbiguousParameterRoute
Parameters
func TestServer_RejectsAmbiguousParameterRoute(t *testing.T)
{
s := New()
if err := s.MapGet("/items/{id}", func(ctx *Context) error {
return nil
}); err != nil {
t.Fatalf("MapGet() error = %v", err)
}
if err := s.MapGet("/items/{name}", func(ctx *Context) error {
return nil
}); err == nil {
t.Fatal("MapGet() accepted an ambiguous parameter route")
}
}
TestRateLimitRejectsInvalidConfiguration
Parameters
func TestRateLimitRejectsInvalidConfiguration(t *testing.T)
{
if _, err := RateLimit(0, 1); err == nil {
t.Fatal("RateLimit() accepted a zero rate")
}
if _, err := RateLimit(1, 0); err == nil {
t.Fatal("RateLimit() accepted a zero burst")
}
}
TestRateLimitSeparatesClientsWithoutBlocking
Parameters
func TestRateLimitSeparatesClientsWithoutBlocking(t *testing.T)
{
middleware, err := RateLimit(1, 1)
if err != nil {
t.Fatalf("RateLimit() error = %v", err)
}
server := New()
server.Use(middleware)
server.MapGet("/", func(ctx *Context) error {
return ctx.JSON(200, map[string]bool{"ok": true})
})
request := httptest.NewRequest("GET", "/", nil)
request.RemoteAddr = "192.0.2.1:1000"
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
if response.Code != 200 {
t.Fatalf("first client status = %d", response.Code)
}
request = httptest.NewRequest("GET", "/", nil)
request.RemoteAddr = "192.0.2.1:1001"
response = httptest.NewRecorder()
server.ServeHTTP(response, request)
if response.Code != 429 {
t.Fatalf("limited client status = %d, want 429", response.Code)
}
request = httptest.NewRequest("GET", "/", nil)
request.RemoteAddr = "192.0.2.2:1000"
response = httptest.NewRecorder()
server.ServeHTTP(response, request)
if response.Code != 200 {
t.Fatalf("second client status = %d, want 200", response.Code)
}
}
Context
Context holds the request state for a single HTTP request.
type Context struct
Methods
Set stores a key-value pair in the request context.
Parameters
func (*Context) Set(key string, val any)
{
if c.values == nil {
c.values = make(map[string]any)
}
c.values[key] = val
}
Get retrieves a value from the request context by key.
Parameters
Returns
func (*Context) Get(key string) (any, bool)
{
v, ok := c.values[key]
return v, ok
}
JSON writes a JSON response with the given status code.
Parameters
Returns
func (*Context) JSON(code int, v any) error
{
data, err := json.Marshal(v)
if err != nil {
return err
}
data = append(data, '\n')
c.Response.Header().Set("Content-Type", "application/json; charset=utf-8")
c.Response.WriteHeader(code)
_, err = c.Response.Write(data)
return err
}
String writes a plain text response with the given status code.
Parameters
Returns
func (*Context) String(code int, s string) error
{
c.Response.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Response.WriteHeader(code)
_, err := c.Response.Write([]byte(s))
return err
}
Bind decodes the request body into v based on the Content-Type header.
Parameters
Returns
func (*Context) Bind(v any) error
{
c.Request.Body = http.MaxBytesReader(c.Response, c.Request.Body, maxRequestBodySize)
ct := c.Request.Header.Get("Content-Type")
contentType, _, err := mime.ParseMediaType(ct)
if err != nil {
return Error(http.StatusUnsupportedMediaType, fmt.Sprintf("web: unsupported content type: %s", ct))
}
if isJSONContentType(ct) {
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(v); err != nil {
return requestBodyError(err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return Error(http.StatusBadRequest, "web: request body contains multiple JSON values")
}
return requestBodyError(err)
}
return nil
}
if contentType == "application/x-www-form-urlencoded" {
if err := c.Request.ParseForm(); err != nil {
return requestBodyError(err)
}
if err := bindForm(c.Request.Form, v); err != nil {
return Error(http.StatusBadRequest, err.Error())
}
return nil
}
return Error(http.StatusUnsupportedMediaType, fmt.Sprintf("web: unsupported content type: %s", ct))
}
Validate runs validation rules on the target object and returns the collected validation errors. Returns nil if there are no errors.
Parameters
Returns
func (*Context) Validate(target any) validation.Errors
{
return validation.New().Validate(target)
}
BindAndValidate decodes the request body into target and then runs validation. Returns a validation.Errors slice if decoding or validation fails.
Parameters
Returns
func (*Context) BindAndValidate(target any) validation.Errors
{
if err := c.Bind(target); err != nil {
return validation.Errors{{Field: "", Message: err.Error()}}
}
return c.Validate(target)
}
Fields
| Name | Type | Description |
|---|---|---|
| Request | *http.Request | |
| Response | http.ResponseWriter | |
| Params | map[string]string | |
| Ctx | context.Context | |
| values | map[string]any |
bindForm
Parameters
Returns
func bindForm(form map[string][]string, v any) error
{
val := reflect.ValueOf(v)
if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct {
return fmt.Errorf("web: form binding requires pointer to struct")
}
elem := val.Elem()
for i := 0; i < elem.NumField(); i++ {
field := elem.Field(i)
if !field.CanSet() {
continue
}
fieldName, ok := elem.Type().Field(i).Tag.Lookup("form")
if !ok || fieldName == "" || fieldName == "-" {
continue
}
if vals, ok := form[fieldName]; ok && len(vals) > 0 {
if err := setFieldValue(field, vals[0]); err != nil {
return fmt.Errorf("web: form field %s: %w", fieldName, err)
}
}
}
return nil
}
setFieldValue
Parameters
Returns
func setFieldValue(field reflect.Value, value string) error
{
switch field.Kind() {
case reflect.String:
field.SetString(value)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return err
}
field.SetInt(n)
case reflect.Float32, reflect.Float64:
n, err := strconv.ParseFloat(value, 64)
if err != nil {
return err
}
field.SetFloat(n)
case reflect.Bool:
b, err := strconv.ParseBool(value)
if err != nil {
return err
}
field.SetBool(b)
default:
return fmt.Errorf("unsupported type %s", field.Kind())
}
return nil
}
requestBodyError
Parameters
Returns
func requestBodyError(err error) error
{
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return Error(http.StatusRequestEntityTooLarge, "web: request body exceeds 1 MiB")
}
return Error(http.StatusBadRequest, fmt.Sprintf("web: invalid request body: %v", err))
}
HandlerFunc
HandlerFunc is the handler signature for web routes.
type HandlerFunc func(*Context) error
Middleware
Middleware wraps a HandlerFunc, returning a new HandlerFunc.
type Middleware func(HandlerFunc) HandlerFunc
route
type route struct
Fields
| Name | Type | Description |
|---|---|---|
| method | string | |
| path | string | |
| handler | HandlerFunc |
Uses
RouteInfo
RouteInfo describes a registered route.
type RouteInfo struct
Fields
| Name | Type | Description |
|---|---|---|
| Method | string | |
| Path | string |
RouteDefinition
RouteDefinition describes a raw HTTP route for batch registration.
type RouteDefinition struct
Fields
| Name | Type | Description |
|---|---|---|
| Method | string | |
| Path | string | |
| Handler | HandlerFunc | |
| Middleware | []Middleware |
Uses
Server
Server is a minimal API HTTP server with routing and middleware support.
type Server struct
Methods
Returns
func (*Server) runDoctor() error
{
routes := s.Routes()
doctorRoutes := make([]doctor.Route, 0, len(routes))
for _, route := range routes {
doctorRoutes = append(doctorRoutes, doctor.Route{
Method: route.Method,
Path: route.Path,
})
}
return doctor.Run(doctor.Source{Routes: doctorRoutes})
}
Group creates a route group with a common prefix and optional middleware.
Parameters
Returns
func (*Server) Group(prefix string, mw ...Middleware) *group
{
g := &group{prefix: prefix, middleware: mw, server: s}
s.mu.Lock()
s.groups = append(s.groups, g)
s.mu.Unlock()
return g
}
Use registers global middleware on the server.
Parameters
func (*Server) Use(mw Middleware)
{
s.mu.Lock()
s.middleware = append(s.middleware, mw)
s.mu.Unlock()
}
MapGet registers a GET route at the given path.
Parameters
Returns
func (*Server) MapGet(path string, handler HandlerFunc, mw ...Middleware) error
{
return s.addRoute("GET", path, handler, mw...)
}
MapPost registers a POST route at the given path.
Parameters
Returns
func (*Server) MapPost(path string, handler HandlerFunc, mw ...Middleware) error
{
return s.addRoute("POST", path, handler, mw...)
}
MapPut registers a PUT route at the given path.
Parameters
Returns
func (*Server) MapPut(path string, handler HandlerFunc, mw ...Middleware) error
{
return s.addRoute("PUT", path, handler, mw...)
}
MapDelete registers a DELETE route at the given path.
Parameters
Returns
func (*Server) MapDelete(path string, handler HandlerFunc, mw ...Middleware) error
{
return s.addRoute("DELETE", path, handler, mw...)
}
Parameters
Returns
func (*Server) addRoute(method, path string, handler HandlerFunc, mw ...Middleware) error
{
return s.RegisterRoutes(RouteDefinition{
Method: method,
Path: path,
Handler: handler,
Middleware: mw,
})
}
RegisterRoutes validates and registers raw routes as one operation.
Parameters
Returns
func (*Server) RegisterRoutes(definitions ...RouteDefinition) error
{
s.mu.Lock()
defer s.mu.Unlock()
prepared := make([]route, 0, len(definitions))
check := newRadixTree()
for _, existing := range s.routes {
if err := check.insert(existing.method, existing.path, existing.handler); err != nil {
return err
}
}
for _, definition := range definitions {
chained := chainMiddleware(definition.Handler, definition.Middleware...)
if err := check.insert(definition.Method, definition.Path, chained); err != nil {
return err
}
prepared = append(prepared, route{
method: definition.Method,
path: definition.Path,
handler: chained,
})
}
for _, item := range prepared {
if err := s.tree.insert(item.method, item.path, item.handler); err != nil {
return err
}
s.routes = append(s.routes, item)
}
return nil
}
Routes returns the registered route metadata.
Returns
func (*Server) Routes() []RouteInfo
{
s.mu.RLock()
defer s.mu.RUnlock()
routes := make([]RouteInfo, 0, len(s.routes))
for _, route := range s.routes {
routes = append(routes, RouteInfo{Method: route.method, Path: route.path})
}
return routes
}
ServeHTTP implements http.Handler, dispatching requests through global middleware and routes.
Parameters
func (*Server) ServeHTTP(w http.ResponseWriter, r *http.Request)
{
s.mu.RLock()
globalMW := make([]Middleware, len(s.middleware))
copy(globalMW, s.middleware)
s.mu.RUnlock()
ctx := &Context{Request: r, Response: w, Ctx: r.Context()}
ctx.values = make(map[string]any)
handler := func(c *Context) error {
return s.serveRoutesWithContext(c)
}
wrapped := chainMiddleware(handler, globalMW...)
if err := wrapped(ctx); err != nil {
ctx.Response.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Response.WriteHeader(statusFromError(err))
json.NewEncoder(ctx.Response).Encode(map[string]string{"error": publicErrorMessage(err)})
}
}
Parameters
Returns
func (*Server) serveRoutesWithContext(ctx *Context) error
{
s.mu.RLock()
handler, params := s.tree.lookup(ctx.Request.Method, ctx.Request.URL.Path)
s.mu.RUnlock()
if handler == nil {
return Error(http.StatusNotFound, "not found")
}
ctx.Params = params
return handler(ctx)
}
ListenAndServe starts the server on the given address.
Parameters
Returns
func (*Server) ListenAndServe(addr string) error
{
if err := s.runDoctor(); err != nil {
return err
}
return s.serve(addr, "", "")
}
ListenAndServeTLS starts the server with TLS on the given address.
Parameters
Returns
func (*Server) ListenAndServeTLS(addr, certFile, keyFile string) error
{
if err := s.runDoctor(); err != nil {
return err
}
return s.serve(addr, certFile, keyFile)
}
Parameters
Returns
func (*Server) serve(addr, certFile, keyFile string) error
{
server, err := s.prepareHTTPServer(addr)
if err != nil {
return err
}
listener, err := net.Listen("tcp", addr)
if err != nil {
s.mu.Lock()
if s.server == server {
s.server = nil
}
s.mu.Unlock()
return err
}
s.mu.Lock()
close(s.started)
s.mu.Unlock()
if certFile != "" {
return server.ServeTLS(listener, certFile, keyFile)
}
return server.Serve(listener)
}
Parameters
Returns
func (*Server) prepareHTTPServer(addr string) (*http.Server, error)
{
server := s.httpServer(addr)
s.mu.Lock()
defer s.mu.Unlock()
if s.server != nil {
return nil, fmt.Errorf("web: server already started")
}
s.server = server
if s.started == nil {
s.started = make(chan struct{})
}
return server, nil
}
Started reports when the underlying HTTP server has been prepared.
Returns
func (*Server) Started() <-chan struct{}
{
s.mu.Lock()
defer s.mu.Unlock()
if s.started == nil {
s.started = make(chan struct{})
if s.server != nil {
close(s.started)
}
}
return s.started
}
Parameters
Returns
func (*Server) httpServer(addr string) *http.Server
{
return &http.Server{
Addr: addr,
Handler: s,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
MaxHeaderBytes: maxRequestBodySize,
}
}
Shutdown gracefully shuts down the server.
Parameters
Returns
func (*Server) Shutdown(ctx context.Context) error
{
s.mu.RLock()
server := s.server
s.mu.RUnlock()
if server == nil {
return fmt.Errorf("web: server not started")
}
return server.Shutdown(ctx)
}
RegisterHandler registers a struct that implements Handler as an endpoint. The struct must have method and path tags: type MyEndpoint struct { Pattern `method:"GET" path:"/api/v1/ping"` Times int `query:"times" default:"1"` } The container is used for dependency injection and bind for field population.
Parameters
Returns
func (*Server) RegisterHandler(prototype Handler, container *di.Container) error
{
definition, err := DefinitionFromHandler(prototype)
if err != nil {
return err
}
return s.RegisterDefinition(definition, container)
}
RegisterDefinition registers a statically described handler.
Parameters
Returns
func (*Server) RegisterDefinition(def HandlerDefinition, container *di.Container) error
{
return s.RegisterDefinitions(container, def)
}
ValidateDefinitions checks static handlers without changing the server.
Parameters
Returns
func (*Server) ValidateDefinitions(container *di.Container, defs ...HandlerDefinition) error
{
prepared, err := s.prepareDefinitions(container, defs)
if err != nil {
return err
}
s.mu.RLock()
defer s.mu.RUnlock()
return validatePreparedDefinitions(s.routes, prepared)
}
RegisterDefinitions validates and registers static handlers as one operation.
Parameters
Returns
func (*Server) RegisterDefinitions(container *di.Container, defs ...HandlerDefinition) error
{
prepared, err := s.prepareDefinitions(container, defs)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := validatePreparedDefinitions(s.routes, prepared); err != nil {
return err
}
for _, item := range prepared {
if err := s.tree.insert(item.meta.method, item.meta.path, item.handler); err != nil {
return err
}
s.routes = append(s.routes, route{
method: item.meta.method,
path: item.meta.path,
handler: item.handler,
})
}
return nil
}
Parameters
Returns
func (*Server) prepareDefinitions(container *di.Container, defs []HandlerDefinition) ([]preparedDefinition, error)
{
prepared := make([]preparedDefinition, 0, len(defs))
for _, def := range defs {
item, err := s.prepareDefinition(container, def)
if err != nil {
return nil, err
}
prepared = append(prepared, item)
}
return prepared, nil
}
Parameters
Returns
func (*Server) prepareDefinition(container *di.Container, def HandlerDefinition) (preparedDefinition, error)
{
if def.Method == "" || def.Path == "" {
return preparedDefinition{}, fmt.Errorf("web: handler definition requires method and path")
}
if def.New == nil {
return preparedDefinition{}, fmt.Errorf("web: handler definition requires a constructor")
}
prototype := def.New()
if prototype == nil {
return preparedDefinition{}, fmt.Errorf("web: handler constructor returned nil")
}
if container != nil {
if err := container.ValidateTarget(prototype); err != nil {
return preparedDefinition{}, fmt.Errorf("web: invalid handler %T: %w", prototype, err)
}
}
meta := &handlerMeta{
new: def.New,
name: fmt.Sprintf("%T", prototype),
method: def.Method,
path: def.Path,
container: container,
jsonBody: hasJSONBody(prototype),
}
handler := s.buildHandler(meta)
return preparedDefinition{meta: meta, handler: handler}, nil
}
Parameters
Returns
func (*Server) buildHandler(meta *handlerMeta) HandlerFunc
{
return func(ctx *Context) error {
handler := meta.new()
if meta.container != nil {
if err := meta.container.Inject(handler); err != nil {
return fmt.Errorf("web: inject %s: %w", meta.name, err)
}
}
b := bind.New()
if len(ctx.Params) > 0 {
b.FromPath(func(key string) string {
return ctx.Params[key]
})
}
b.FromQuery(ctx.Request)
b.FromHeader(ctx.Request)
if err := b.Bind(handler); err != nil {
return Error(http.StatusBadRequest, fmt.Sprintf("web: invalid request input: %v", err))
}
if meta.jsonBody && ctx.Request.Body != nil && ctx.Request.ContentLength != 0 {
if !isJSONContentType(ctx.Request.Header.Get("Content-Type")) {
return Error(http.StatusUnsupportedMediaType, "web: JSON body requires application/json content type")
}
body, err := io.ReadAll(io.LimitReader(ctx.Request.Body, maxRequestBodySize+1))
if err != nil {
return fmt.Errorf("web: read request body: %w", err)
}
if len(body) > maxRequestBodySize {
return Error(http.StatusRequestEntityTooLarge, "web: request body exceeds 1 MiB")
}
if len(body) > 0 {
if err := bind.New().BindJSON(handler, body); err != nil {
return Error(http.StatusBadRequest, fmt.Sprintf("web: invalid request body: %v", err))
}
}
}
result, err := handler.Handle(ctx.Request.Context())
if err != nil {
return err
}
if result == nil {
ctx.Response.WriteHeader(http.StatusNoContent)
return nil
}
return ctx.JSON(http.StatusOK, result)
}
}
Fields
| Name | Type | Description |
|---|---|---|
| middleware | []Middleware | |
| routes | []route | |
| groups | []*group | |
| mu | sync.RWMutex | |
| server | *http.Server | |
| tree | *radixTree | |
| started | chan struct{} |
Option
Option configures a Server.
type Option options.Option[Server]
New
New creates a new Server with optional configuration.
Parameters
Returns
func New(opts ...Option) *Server
{
s := &Server{
tree: newRadixTree(),
started: make(chan struct{}),
}
for _, opt := range opts {
opt(s)
}
return s
}
group
type group struct
Methods
MapGet registers a GET route in the group.
Parameters
Returns
func (*group) MapGet(path string, handler HandlerFunc) error
{
return g.server.MapGet(g.prefix+path, handler, g.middleware...)
}
MapPost registers a POST route in the group.
Parameters
Returns
func (*group) MapPost(path string, handler HandlerFunc) error
{
return g.server.MapPost(g.prefix+path, handler, g.middleware...)
}
MapPut registers a PUT route in the group.
Parameters
Returns
func (*group) MapPut(path string, handler HandlerFunc) error
{
return g.server.MapPut(g.prefix+path, handler, g.middleware...)
}
MapDelete registers a DELETE route in the group.
Parameters
Returns
func (*group) MapDelete(path string, handler HandlerFunc) error
{
return g.server.MapDelete(g.prefix+path, handler, g.middleware...)
}
Fields
| Name | Type | Description |
|---|---|---|
| prefix | string | |
| middleware | []Middleware | |
| server | *Server |
chainMiddleware
Parameters
Returns
func chainMiddleware(h HandlerFunc, mw ...Middleware) HandlerFunc
{
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
matchRoute
Parameters
Returns
func matchRoute(pattern, path string) (map[string]string, bool)
{
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(patternParts) != len(pathParts) {
return nil, false
}
params := make(map[string]string)
for i := 0; i < len(patternParts); i++ {
if strings.HasPrefix(patternParts[i], "{") && strings.HasSuffix(patternParts[i], "}") {
paramName := patternParts[i][1 : len(patternParts[i])-1]
params[paramName] = pathParts[i]
} else if patternParts[i] != pathParts[i] {
return nil, false
}
}
return params, true
}
JSON
JSON writes a JSON response using the context.
Parameters
Returns
func JSON(ctx *Context, code int, v any) error
{
return ctx.JSON(code, v)
}
Error
Error creates an HTTP error with a status code and message.
Parameters
Returns
func Error(code int, msg string) error
{
return &httpError{Code: code, Message: msg}
}
httpError
type httpError struct
Methods
Fields
| Name | Type | Description |
|---|---|---|
| Code | int | |
| Message | string |
panicError
type panicError struct
Methods
Fields
| Name | Type | Description |
|---|---|---|
| recovered | any | |
| stack | string |
Recovery
Recovery returns middleware that catches panics and converts them to errors with stack traces.
Returns
func Recovery() Middleware
{
return func(next HandlerFunc) HandlerFunc {
return func(ctx *Context) (err error) {
defer func() {
if r := recover(); r != nil {
err = &panicError{
recovered: r,
stack: string(debug.Stack()),
}
}
}()
return next(ctx)
}
}
}
Uses
Logger
Logger returns middleware that logs request method, path, duration, and status.
Returns
func Logger() Middleware
{
return func(next HandlerFunc) HandlerFunc {
return func(ctx *Context) error {
start := time.Now()
err := next(ctx)
fmt.Printf("%s %q %v %d\n", ctx.Request.Method, ctx.Request.URL.Path, time.Since(start), statusFromError(err))
return err
}
}
}
Uses
CORS
CORS returns middleware that sets CORS headers. Preflight OPTIONS requests get 204.
Parameters
Returns
func CORS(allowOrigins ...string) Middleware
{
allowAny := len(allowOrigins) == 0
allowed := make(map[string]struct{}, len(allowOrigins))
for _, origin := range allowOrigins {
if origin == "*" {
allowAny = true
}
allowed[origin] = struct{}{}
}
return func(next HandlerFunc) HandlerFunc {
return func(ctx *Context) error {
origin := ctx.Request.Header.Get("Origin")
if allowAny {
ctx.Response.Header().Set("Access-Control-Allow-Origin", "*")
} else if _, ok := allowed[origin]; ok && origin != "" {
ctx.Response.Header().Set("Access-Control-Allow-Origin", origin)
ctx.Response.Header().Add("Vary", "Origin")
}
ctx.Response.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
ctx.Response.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if ctx.Request.Method == "OPTIONS" {
ctx.Response.WriteHeader(http.StatusNoContent)
return nil
}
return next(ctx)
}
}
}
Uses
RequestID
RequestID returns middleware that sets and propagates X-Request-ID.
Returns
func RequestID() Middleware
{
return func(next HandlerFunc) HandlerFunc {
return func(ctx *Context) error {
id := ctx.Request.Header.Get("X-Request-ID")
if id == "" {
id = fmt.Sprintf("%d", time.Now().UnixNano())
}
ctx.Set("request_id", id)
ctx.Response.Header().Set("X-Request-ID", id)
return next(ctx)
}
}
}
Uses
Compress
Compress returns middleware that writes gzip responses when requested.
Returns
func Compress() Middleware
{
return func(next HandlerFunc) HandlerFunc {
return func(ctx *Context) error {
if !acceptsGzip(ctx.Request.Header.Get("Accept-Encoding")) {
return next(ctx)
}
original := ctx.Response
writer := &gzipResponseWriter{
ResponseWriter: original,
writer: gzip.NewWriter(original),
}
ctx.Response = writer
defer func() {
ctx.Response = original
}()
err := next(ctx)
if writer.compressed {
if closeErr := writer.writer.Close(); err == nil {
err = closeErr
}
}
return err
}
}
}
Uses
acceptsGzip
Parameters
Returns
func acceptsGzip(value string) bool
{
for _, item := range strings.Split(value, ",") {
parts := strings.Split(item, ";")
if !strings.EqualFold(strings.TrimSpace(parts[0]), "gzip") {
continue
}
quality := 1.0
for _, parameter := range parts[1:] {
name, raw, ok := strings.Cut(strings.TrimSpace(parameter), "=")
if !ok || !strings.EqualFold(name, "q") {
continue
}
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return false
}
quality = parsed
}
return quality > 0
}
return false
}
gzipResponseWriter
type gzipResponseWriter struct
Methods
Parameters
func (*gzipResponseWriter) WriteHeader(status int)
{
if status < http.StatusOK || status == http.StatusNoContent || status == http.StatusNotModified {
w.passthrough = true
w.ResponseWriter.WriteHeader(status)
return
}
w.prepare()
w.ResponseWriter.WriteHeader(status)
}
Parameters
Returns
func (*gzipResponseWriter) Write(content []byte) (int, error)
{
if w.passthrough {
return w.ResponseWriter.Write(content)
}
w.prepare()
return w.writer.Write(content)
}
func (*gzipResponseWriter) prepare()
{
if w.compressed {
return
}
w.Header().Set("Content-Encoding", "gzip")
w.Header().Del("Content-Length")
w.Header().Add("Vary", "Accept-Encoding")
w.compressed = true
}
Fields
| Name | Type | Description |
|---|---|---|
| writer | *gzip.Writer | |
| compressed | bool | |
| passthrough | bool |
HealthEndpoint
HealthEndpoint returns a handler that reports health status based on the checker function.
Parameters
Returns
func HealthEndpoint(checker func(ctx context.Context) error) HandlerFunc
{
return func(ctx *Context) error {
if err := checker(ctx.Ctx); err != nil {
return ctx.JSON(http.StatusServiceUnavailable, map[string]string{"status": "unhealthy"})
}
return ctx.JSON(http.StatusOK, map[string]string{"status": "healthy"})
}
}
Uses
publicErrorMessage
Parameters
Returns
func publicErrorMessage(err error) string
{
var recovered *panicError
if errors.As(err, &recovered) {
return "internal server error"
}
var explicit *httpError
if errors.As(err, &explicit) {
return explicit.Message
}
if statusFromError(err) >= http.StatusInternalServerError {
return "internal server error"
}
return err.Error()
}
statusFromError
Parameters
Returns
func statusFromError(err error) int
{
if err == nil {
return http.StatusOK
}
for {
if he, ok := err.(interface{ HTTPCode() int }); ok {
return he.HTTPCode()
}
if e := errors.Unwrap(err); e != nil {
err = e
continue
}
break
}
return http.StatusInternalServerError
}
TestAuth_MissingHeader
Parameters
func TestAuth_MissingHeader(t *testing.T)
{
s := New()
s.Use(Auth(authTestSecret))
s.MapGet("/api", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"ok": "yes"})
})
req := httptest.NewRequest("GET", "/api", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status: got %d, want 401", w.Code)
}
}
TestAuth_InvalidToken
Parameters
func TestAuth_InvalidToken(t *testing.T)
{
s := New()
s.Use(Auth(authTestSecret))
s.MapGet("/api", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"ok": "yes"})
})
req := httptest.NewRequest("GET", "/api", nil)
req.Header.Set("Authorization", "Bearer badtoken")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status: got %d, want 401", w.Code)
}
}
TestAuth_ValidToken
Parameters
func TestAuth_ValidToken(t *testing.T)
{
s := New()
s.Use(Auth(authTestSecret))
s.MapGet("/api", func(ctx *Context) error {
return ctx.JSON(200, map[string]string{"ok": "yes"})
})
token, _ := auth.SignToken(auth.Payload{Sub: "user1", Exp: 9999999999}, authTestSecret)
req := httptest.NewRequest("GET", "/api", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("status: got %d, want 200", w.Code)
}
}
TestAuth_RejectsShortSecretAtConfiguration
Parameters
func TestAuth_RejectsShortSecretAtConfiguration(t *testing.T)
{
defer func() {
if recover() == nil {
t.Fatal("Auth() accepted a short HMAC secret")
}
}()
Auth([]byte("short"))
}
TestRunDoctorEnabled
Parameters
func TestRunDoctorEnabled(t *testing.T)
{
t.Setenv("FOUNDATION_DOCTOR", "fail")
s := New()
s.MapGet("/ping", func(ctx *Context) error {
ctx.String(200, "pong")
return nil
})
if err := s.runDoctor(); err != nil {
t.Fatalf("runDoctor() error = %v", err)
}
}
TestRunDoctorEnabledFails
Parameters
func TestRunDoctorEnabledFails(t *testing.T)
{
t.Setenv("FOUNDATION_DOCTOR", "fail")
s := New()
if err := s.runDoctor(); err == nil {
t.Fatal("runDoctor() error = nil, want error")
}
}
Handler
Handler is the interface for declarative struct-tagged endpoints.
type Handler interface
Methods
HandlerDefinition
HandlerDefinition describes a handler without runtime metadata discovery.
type HandlerDefinition struct
Fields
| Name | Type | Description |
|---|---|---|
| Method | string | |
| Path | string | |
| New | func() Handler |
DefinitionFromHandler
DefinitionFromHandler describes a reflection-based prototype for batch validation.
Parameters
Returns
func DefinitionFromHandler(prototype Handler) (HandlerDefinition, error)
{
value := reflect.ValueOf(prototype)
if !value.IsValid() || value.Kind() != reflect.Ptr || value.IsNil() || value.Elem().Kind() != reflect.Struct {
return HandlerDefinition{}, fmt.Errorf("web: handler prototype must be a non-nil pointer to a struct")
}
typ := value.Elem().Type()
var method, path string
for i := 0; i < typ.NumField(); i++ {
sf := typ.Field(i)
if m := sf.Tag.Get("method"); m != "" {
method = m
}
if p := sf.Tag.Get("path"); p != "" {
path = p
}
}
if method == "" || path == "" {
return HandlerDefinition{}, fmt.Errorf("web: handler %T must declare method and path tags", prototype)
}
return HandlerDefinition{
Method: method,
Path: path,
New: func() Handler {
newVal := reflect.New(typ)
newVal.Elem().Set(value.Elem())
return newVal.Interface().(Handler)
},
}, nil
}
preparedDefinition
type preparedDefinition struct
Fields
| Name | Type | Description |
|---|---|---|
| meta | *handlerMeta | |
| handler | HandlerFunc |
Uses
validatePreparedDefinitions
Parameters
Returns
func validatePreparedDefinitions(routes []route, prepared []preparedDefinition) error
{
tree := newRadixTree()
for _, registered := range routes {
if err := tree.insert(registered.method, registered.path, registered.handler); err != nil {
return err
}
}
for _, item := range prepared {
if err := tree.insert(item.meta.method, item.meta.path, item.handler); err != nil {
return err
}
}
return nil
}
handlerMeta
type handlerMeta struct
Fields
| Name | Type | Description |
|---|---|---|
| new | func() Handler | |
| name | string | |
| method | string | |
| path | string | |
| container | *di.Container | |
| jsonBody | bool |
hasJSONBody
Parameters
Returns
func hasJSONBody(handler Handler) bool
{
typ := reflect.TypeOf(handler)
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
for i := 0; i < typ.NumField(); i++ {
if typ.Field(i).Tag.Get("body") == "json" {
return true
}
}
return false
}
Uses
isJSONContentType
Parameters
Returns
func isJSONContentType(value string) bool
{
contentType, _, err := mime.ParseMediaType(value)
if err != nil {
return false
}
return contentType == "application/json" || strings.HasSuffix(contentType, "+json")
}
clientRateLimiter
type clientRateLimiter struct
Fields
| Name | Type | Description |
|---|---|---|
| key | string | |
| limiter | *resiliency.RateLimiter | |
| lastSeen | time.Time |
clientRateLimitStore
type clientRateLimitStore struct
Methods
Parameters
Returns
func (*clientRateLimitStore) forClient(key string, now time.Time) (*resiliency.RateLimiter, error)
{
s.mu.Lock()
defer s.mu.Unlock()
if s.lastCleanup.IsZero() || now.Sub(s.lastCleanup) >= rateLimitClientTTL {
s.removeExpired(now)
s.lastCleanup = now
}
if element, ok := s.clients[key]; ok {
entry := element.Value.(*clientRateLimiter)
entry.lastSeen = now
s.recency.MoveToFront(element)
return entry.limiter, nil
}
if len(s.clients) >= rateLimitMaxClients {
s.removeElement(s.recency.Back())
}
limiter, err := resiliency.NewRateLimiter(s.rate, s.burst)
if err != nil {
return nil, err
}
entry := &clientRateLimiter{key: key, limiter: limiter, lastSeen: now}
s.clients[key] = s.recency.PushFront(entry)
return limiter, nil
}
Parameters
func (*clientRateLimitStore) removeExpired(now time.Time)
{
for element := s.recency.Back(); element != nil; {
entry := element.Value.(*clientRateLimiter)
if now.Sub(entry.lastSeen) <= rateLimitClientTTL {
return
}
previous := element.Prev()
s.removeElement(element)
element = previous
}
}
Parameters
func (*clientRateLimitStore) removeElement(element *list.Element)
{
if element == nil {
return
}
entry := element.Value.(*clientRateLimiter)
delete(s.clients, entry.key)
s.recency.Remove(element)
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.Mutex | |
| clients | map[string]*list.Element | |
| recency | *list.List | |
| rate | int | |
| burst | int | |
| lastCleanup | time.Time |
RateLimit
RateLimit returns nonblocking per-client rate limit middleware.
Parameters
Returns
func RateLimit(rate, burst int) (Middleware, error)
{
if _, err := resiliency.NewRateLimiter(rate, burst); err != nil {
return nil, err
}
store := &clientRateLimitStore{
clients: make(map[string]*list.Element),
recency: list.New(),
rate: rate,
burst: burst,
}
return func(next HandlerFunc) HandlerFunc {
return func(ctx *Context) error {
limiter, err := store.forClient(clientIdentity(ctx.Request), time.Now())
if err != nil {
return fmt.Errorf("web: rate limiter: %w", err)
}
if !limiter.Allow() {
return Error(http.StatusTooManyRequests, "rate limit exceeded")
}
return next(ctx)
}
}, nil
}
Uses
clientIdentity
Parameters
Returns
func clientIdentity(request *http.Request) string
{
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err == nil && host != "" {
return host
}
if request.RemoteAddr != "" {
return request.RemoteAddr
}
return "unknown"
}