web
packageAPI reference for the web
package.
Imports
(27)context
STD
fmt
STD
io
STD
mime
STD
net/http
STD
reflect
STD
strings
INT
github.com/mirkobrombin/go-foundation/v2/app/di
INT
github.com/mirkobrombin/go-foundation/v2/core/bind
STD
regexp
STD
strconv
STD
sync
STD
container/list
STD
net
STD
time
INT
github.com/mirkobrombin/go-foundation/v2/core/resiliency
STD
net/http/httptest
STD
testing
INT
github.com/mirkobrombin/go-foundation/v2/core/validation
INT
github.com/mirkobrombin/go-foundation/v2/core/contracts
STD
compress/gzip
STD
encoding/json
STD
errors
STD
runtime/debug
INT
github.com/mirkobrombin/go-foundation/v2/core/options
INT
github.com/mirkobrombin/go-foundation/v2/core/auth
INT
github.com/mirkobrombin/go-foundation/v2/app/doctor
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")
}
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
}
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"
}
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
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)
}
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))
}
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
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)
}
}
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)
}
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})
}
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
}
TestRunDoctorDisabled
Parameters
func TestRunDoctorDisabled(t *testing.T)
{
s := New()
if err := s.runDoctor(); err != nil {
t.Fatalf("runDoctor() error = %v", err)
}
}
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")
}
}
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"))
}
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")
}
}
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)
}
}
}