sim

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: MIT Imports: 28 Imported by: 0

README

Sim

Latest Release Tests Lint Go Reference

Not another web framework — the missing layer on top of net/http.

Sim (short for simple) is a minimal HTTP web framework for Go, built on top of net/http and http.ServeMux — no third-party dependencies. It adds wrappers and utilities while keeping stdlib handlers intact and native performance untouched. Simple, not simplistic.

Features

Core
  • Zero dependencies — only the Go standard library
  • Method-based routing: Get, Post, Put, Delete, Patch, Options, Head, Connect, Trace, and Any
  • Routing follows the net/http.ServeMux patterns
  • Route groups under a common prefix
  • Standard net/http handlers work everywhere — no framework-specific context type to learn
  • Wrapper composition with Chain and ChainFunc
  • Conditional wrapper application with Selector
  • Request binding: BindJSON, BindXML, BindQuery, BindForm, BindPath and BindHeader
  • Response helpers: JSON, XML, Text, Bytes, Stream and Attachment
  • Graceful shutdown with Run
Built-in wrappers
Wrapper What it does
ClientIPResolution Resolves the real client IP behind trusted proxies and adds client_ip to request logs
RequestLogging Writes structured slog records per request
Recovery Turns panics into a logged stack trace and HTTP 500 instead of a crash

Default bundles all three wrappers, ready to use with no configuration.

Request binding
  • Bind incoming request data into your own structs from JSON, XML, query, form, path, and header values.
  • Struct tags with default= values, embedded structs, multipart file uploads, and map targets
  • Validation via Validator, custom formats via Decoder
  • Read the request body more than once with BufferBody

See the package documentation for the full struct-tag rules.

Response helpers
  • Write JSON, XML, text, byte, streaming, and attachment responses
  • JSON options: EscapeForHTML for safe HTML embedding, Indented for readable output
  • Stream for large or in-progress bodies without loading them into memory, Attachment for file downloads

Installation

Requires Go 1.26+.

go get github.com/qm012/sim

Quick start

package main

import (
	"context"
	"net/http"

	"github.com/qm012/sim"
)

func main() {
	app := sim.Default()
	app.Get("/", func(w http.ResponseWriter, _ *http.Request) {
		_ = sim.Text(w, http.StatusOK, "hello, sim")
	})
	_ = app.Run(context.Background(), ":8080")
}

Example

A complete runnable REST API:

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"

	"github.com/qm012/sim"
)

func main() {
	// NewApp starts with no wrappers; register them explicitly,
	// outermost first.
	app := sim.NewApp()

	logging := new(sim.RequestLogging)
	app.Use(
		new(sim.ClientIPResolution).Handler,
		// Log every request except the ping endpoint.
		sim.Selector(logging.Handler, func(r *http.Request) bool {
			return r.URL.Path != "/ping"
		}),
		new(sim.Recovery).Handler,
	)

	app.Get("/", func(w http.ResponseWriter, _ *http.Request) {
		_ = sim.Text(w, http.StatusOK, "welcome")
	})
	app.Any("/ping", func(w http.ResponseWriter, _ *http.Request) {
		_ = sim.Text(w, http.StatusOK, "pong")
	})

	// Group routes under a common prefix.
	app.Group("/api", func(r sim.Router) {
		r.Get("/users", listUsers)
		r.Get("/users/{id}", getUser)
		r.Post("/users", createUser)
		r.Put("/users/{id}", updateUser)
		r.Delete("/users/{id}", deleteUser)
	})

	// Compose wrappers with Chain / ChainFunc.
	app.Get("/admin", sim.ChainFunc(auth)(adminPanel))

	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()
	if err := app.Run(ctx, ":8080"); err != nil {
		log.Fatal(err)
	}
}

func auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") == "" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func listUsers(w http.ResponseWriter, r *http.Request) {
	// BindQuery fills a struct from the URL query; page defaults to 1.
	q, err := sim.BindQuery[struct {
		Page int `query:"page,default=1"`
	}](r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	_ = sim.JSON(w, http.StatusOK, struct {
		Page  int    `json:"page"`
		Users []user `json:"users"`
	}{q.Page, []user{
		{Name: "alice", Age: 30},
		{Name: "bob", Age: 25},
	}})
}

func getUser(w http.ResponseWriter, r *http.Request) {
  // BindPath fills a struct from path values.
	p, err := sim.BindPath[struct {
		ID string `path:"id"`
	}](r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	_ = sim.JSON(w, http.StatusOK, user{ID: p.ID, Name: "alice", Age: 30})
}

// user is the payload the API exchanges with its clients.
type user struct {
	ID   string `json:"id,omitempty"`
	Name string `json:"name"`
	Age  int    `json:"age"`
}

func createUser(w http.ResponseWriter, r *http.Request) {
	u, err := sim.BindJSON[user](r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	_ = sim.JSON(w, http.StatusCreated, u)
}

func updateUser(w http.ResponseWriter, r *http.Request) {
	p, err := sim.BindPath[struct {
		ID string `path:"id"`
	}](r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	u, err := sim.BindJSON[user](r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	u.ID = p.ID

	_ = sim.JSON(w, http.StatusOK, u)
}

func deleteUser(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusNoContent)
}

func adminPanel(w http.ResponseWriter, _ *http.Request) {
	_ = sim.Text(w, http.StatusOK, "admin")
}

Save it as main.go and run it:

go run main.go

Open http://localhost:8080/ to see "welcome", and http://localhost:8080/api/users for the user list. The endpoints return JSON. Try them:

curl 'localhost:8080/api/users?page=2'
# {"page":2,"users":[{"name":"alice","age":30},{"name":"bob","age":25}]}

curl localhost:8080/api/users/1
# {"id":"1","name":"alice","age":30}

curl -X POST localhost:8080/api/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","age":30}'
# {"name":"alice","age":30}

curl -X PUT localhost:8080/api/users/1 \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","age":31}'
# {"id":"1","name":"alice","age":31}

Contributing

See CONTRIBUTING.md for how to report bugs, suggest features, improve docs, write tests, and submit changes.

Acknowledgements

Sim's design was inspired by:

License

MIT, see LICENSE.

Documentation

Overview

Package sim provides a small, idiomatic HTTP router built on top of net/http.ServeMux, extending it with method-based routing helpers such as App.Get, App.Post, and App.Any.

Example:

package main

import (
	"context"
	"log"
	"net/http"

	"github.com/qm012/sim"
)

func main() {
	app := sim.Default()

	app.Get("/", func(w http.ResponseWriter, _ *http.Request) {
		_ = sim.Text(w, http.StatusOK, "root.")
	})
	app.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
		_ = sim.Text(w, http.StatusOK, "user "+r.PathValue("id"))
	})
	app.Group("/api", func(r sim.Router) {
		r.Post("/users", func(w http.ResponseWriter, _ *http.Request) {
			w.WriteHeader(http.StatusCreated)
		})
	})

	if err := app.Run(context.Background(), ":8080"); err != nil {
		log.Fatal(err)
	}
}

Routes are registered on an App, which implements http.Handler and can be passed directly to http.ListenAndServe or served with App.Run, which shuts the server down gracefully when its context is canceled.

Patterns

Pattern matching uses the same syntax and precedence rules as http.ServeMux since Go 1.22. A pattern may carry an optional method and host prefix, and a path may contain wildcard segments such as {name} and {name...}. Wildcard values are read from the request with http.Request.PathValue. For example:

  • "GET /users/{id}" matches only GET requests, capturing the id.
  • "/static/" matches every method and any path under "/static/".
  • "/files/{path...}" matches the remainder of the URL, including slashes.

The method helpers register the same pattern for a single method: App.Get registers "GET /path", App.Post registers "POST /path", and App.Any registers "/path" for every method. The pattern given to a method helper must be a plain path; method prefixes belong to the helper itself.

See the http.ServeMux documentation for the complete pattern syntax, precedence rules, and trailing-slash redirection behavior.

Wrappers registered with App.Use are applied to every handler registered after the call, with the first wrapper outermost. Chain composes wrappers into one; ChainFunc is its counterpart over http.HandlerFunc, the type accepted by the method helpers such as App.Get. Default returns an App with the standard wrappers already registered.

Binding

BindJSON and BindXML decode the request body, while BindQuery, BindForm, BindPath and BindHeader fill a value from request strings; Bind does the same with a custom Decoder. Every helper validates the decoded value when it implements Validator, and BufferBody makes a request body readable more than once.

The string binders share one set of rules, keyed by the struct tag named after the binder — "query", "form", "path" or "header":

  • The bind key is the tag name, or the field name when the tag carries none. A tag name of "-" skips the field, and unexported fields never bind, including anonymous fields whose type name is unexported.
  • Anonymous struct fields recurse with the same tag; a nil embedded pointer is allocated only when a field inside it binds, which includes binding from default=. Named struct fields do not recurse. An anonymous self-decoding field binds as a single value instead of recursing.
  • Bindable types are strings, bools, ints, uints, floats, time.Duration, any type whose pointer implements encoding.TextUnmarshaler, slices of those or of pointers to them (as []*int or []*string), []byte, and pointers to any of them. A self-decoding type binds as a single value even when it is a slice of bytes, as net.IP is.
  • Scalar and []byte fields take the last value of a repeated key; slice fields take every value.
  • The tag option default=value applies when the key is absent or all of its values are empty. The value runs to the next option, so it cannot contain a comma.

BindQuery and BindForm also accept map[string]string and map[string][]string as the target type; the other binders require a struct.

Responding

JSON, XML, Text, Bytes, Stream and Attachment write responses with a single call:

  • JSON encodes data as JSON. EscapeForHTML and Indented control escaping and formatting.
  • XML encodes data as XML, prepending the standard XML header.
  • Text writes a plain-text string.
  • Bytes writes raw bytes with a caller-supplied content type.
  • Stream copies an io.Reader to the response, suitable for large or streaming bodies such as proxied responses.
  • Attachment streams a body with a Content-Disposition header for download.

For static files that need Range requests or caching, prefer http.ServeFile or http.ServeFileFS.

See the documentation of App for the full routing API.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrBindTarget is returned when a bind target is not a non-nil
	// pointer to a struct.
	ErrBindTarget = errors.New("sim: bind target must be a non-nil struct pointer")
	// ErrUnsupportedKind is returned when a field's kind cannot be bound
	// from strings.
	ErrUnsupportedKind = errors.New("sim: unsupported field kind")
	// ErrDecodeNil is returned when a decoder returns a nil value without an error.
	ErrDecodeNil = errors.New("sim: decoder returned nil value")
)
View Source
var TrustAllCIDRs = []netip.Prefix{
	netip.MustParsePrefix("0.0.0.0/0"),
	netip.MustParsePrefix("::/0"),
}

TrustAllCIDRs trusts every peer; assign to TrustedCIDRs only when a trusted proxy always overwrites the forwarding headers.

Functions

func Attachment added in v0.5.0

func Attachment(w http.ResponseWriter, filename string, r io.Reader) error

Attachment writes r with status 200 OK as a download attachment, prompting browsers to save it as filename. Content-Disposition is set from filename, and Content-Type is inferred from filename's extension, falling back to application/octet-stream. Prefer http.ServeFile / http.ServeFileFS for disk files that need Range and caching support.

Example
package main

import (
	"fmt"
	"net/http/httptest"
	"strings"

	"github.com/qm012/sim"
)

func main() {
	w := httptest.NewRecorder()
	_ = sim.Attachment(w, "report.txt", strings.NewReader("hello"))
	fmt.Println(w.Header().Get("Content-Disposition"))
	fmt.Print(w.Body)
}
Output:
attachment; filename=report.txt
hello

func Bind added in v0.5.0

func Bind[T any](r *http.Request, src Decoder[T]) (*T, error)

Bind decodes the request with src and returns the decoded value. If the value implements Validator, Bind validates it against the request context before returning. It returns ErrDecodeNil if the decoder returns a nil value without an error.

func BindForm added in v0.5.0

func BindForm[T any](r *http.Request) (*T, error)

BindForm binds form values — the URL query plus the request body form — into a *T using `form` struct tags and validates it when T implements Validator. T may also be map[string]string or map[string][]string, which receive the form values directly. The Binding section of the package documentation describes the shared struct-tag rules.

Both urlencoded and multipart bodies are parsed with a fixed 32 MiB memory cap; multipart file parts bind into *multipart.FileHeader or []*multipart.FileHeader fields. A body buffered with BufferBody is parsed from the cached copy. Parsing populates r.Form, r.PostForm and r.MultipartForm in place. To reject oversized uploads, limit the body with http.MaxBytesHandler — globally in a wrapper or per handler — before the request reaches BindForm.

func BindHeader added in v0.5.0

func BindHeader[T any](r *http.Request) (*T, error)

BindHeader binds request headers into a *T using `header` struct tags and validates it when T implements Validator. Header names are matched case-insensitively; map targets are not supported. The Binding section of the package documentation describes the shared struct-tag rules.

func BindJSON added in v0.5.0

func BindJSON[T any](r *http.Request, opts ...JSONDecoderOption) (*T, error)

BindJSON decodes the request body as JSON into a *T and validates it when T implements Validator. Only the first JSON value is decoded; trailing content is not rejected.

func BindPath added in v0.5.0

func BindPath[T any](r *http.Request) (*T, error)

BindPath binds path values into a *T using `path` struct tags and validates it when T implements Validator. A wildcard that is missing or that matched an empty value counts as absent. Map targets are not supported, because path wildcards cannot be enumerated. The Binding section of the package documentation describes the shared struct-tag rules.

func BindQuery added in v0.5.0

func BindQuery[T any](r *http.Request) (*T, error)

BindQuery binds the URL query into a *T using `query` struct tags and validates it when T implements Validator. A malformed query string is rejected instead of silently dropping the affected keys. T may also be map[string]string or map[string][]string, which receive the query values directly. The Binding section of the package documentation describes the shared struct-tag rules.

func BindXML added in v0.5.0

func BindXML[T any](r *http.Request) (*T, error)

BindXML decodes the request body as XML into a *T and validates it when T implements Validator.

func BodyFromContext added in v0.5.0

func BodyFromContext(ctx context.Context) ([]byte, bool)

BodyFromContext returns the body cached by BufferBody and reports whether the context carried one.

func BufferBody added in v0.5.0

func BufferBody(r *http.Request) (*http.Request, error)

BufferBody reads the request body and returns a shallow copy of the request whose Body is restored for subsequent reads and whose context carries a copy of the body for repeated reads via BodyFromContext. The whole body is buffered in memory; to cap it, limit the request body beforehand — globally with http.MaxBytesHandler in a wrapper, or per handler:

app.Post("/upload", http.MaxBytesHandler(h, 10<<20))

func Bytes added in v0.5.0

func Bytes(w http.ResponseWriter, statusCode int, contentType string, b []byte) error

Bytes writes raw bytes with the given status code and content type.

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/qm012/sim"
)

func main() {
	w := httptest.NewRecorder()
	_ = sim.Bytes(w, http.StatusOK, "text/csv", []byte("name,age\nalice,30\n"))
	fmt.Println(w.Header().Get("Content-Type"))
	fmt.Print(w.Body)
}
Output:
text/csv
name,age
alice,30

func Chain

func Chain(ss ...func(http.Handler) http.Handler) func(http.Handler) http.Handler

Chain returns a function that composes the given wrappers into a single wrapper. Applying the returned function to a handler h returns a new handler that runs each wrapper in order: ss[0] is outermost, receives the request first, and its response is what the caller ultimately sees.

Chain(Logging, Auth)(h) is equivalent to Logging(Auth(h)). With no wrappers, Chain returns a function that leaves its argument unchanged.

func ChainFunc

func ChainFunc(ss ...func(http.Handler) http.Handler) func(http.HandlerFunc) http.HandlerFunc

ChainFunc returns a function that composes the given wrappers into a single wrapper over http.HandlerFunc handlers, the counterpart of Chain for func-typed registration methods such as App.Get and App.Put.

The wrappers are the same func(http.Handler) http.Handler type as Chain's, so wrappers written for Chain work unchanged.

With no wrappers, ChainFunc returns a function that leaves its argument unchanged.

func ClientIPFromContext added in v0.2.0

func ClientIPFromContext(ctx context.Context) string

ClientIPFromContext returns the client IP stored by ClientIPResolution.Handler, or "" when the request was not wrapped.

func JSON added in v0.5.0

func JSON(w http.ResponseWriter, statusCode int, data any, opts ...JSONEncoderOption) error

JSON writes data as JSON with the given status code.

Example
package main

import (
	"encoding/xml"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/qm012/sim"
)

type exampleUser struct {
	XMLName xml.Name `json:"-" xml:"user"`
	Name    string   `json:"name" xml:"name"`
	Age     int      `json:"age" xml:"age"`
}

func main() {
	w := httptest.NewRecorder()
	_ = sim.JSON(w, http.StatusOK, exampleUser{Name: "alice", Age: 30})
	fmt.Println(w.Code, w.Header().Get("Content-Type"))
	fmt.Print(w.Body)
}
Output:
200 application/json; charset=utf-8
{"name":"alice","age":30}
Example (EscapeForHTML)
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/qm012/sim"
)

func main() {
	data := map[string]string{"url": "a<b&c"}

	escaped := httptest.NewRecorder()
	_ = sim.JSON(escaped, http.StatusOK, data)
	fmt.Print(escaped.Body)

	raw := httptest.NewRecorder()
	_ = sim.JSON(raw, http.StatusOK, data, sim.EscapeForHTML(false))
	fmt.Print(raw.Body)
}
Output:
{"url":"a\u003cb\u0026c"}
{"url":"a<b&c"}
Example (Indented)
package main

import (
	"encoding/xml"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/qm012/sim"
)

type exampleUser struct {
	XMLName xml.Name `json:"-" xml:"user"`
	Name    string   `json:"name" xml:"name"`
	Age     int      `json:"age" xml:"age"`
}

func main() {
	w := httptest.NewRecorder()
	_ = sim.JSON(w, http.StatusOK, exampleUser{Name: "alice", Age: 30}, sim.Indented(true))
	fmt.Print(w.Body)
}
Output:
{
    "name": "alice",
    "age": 30
}

func Selector added in v0.5.0

func Selector(s func(http.Handler) http.Handler, match func(*http.Request) bool) func(http.Handler) http.Handler

Selector returns a wrapper that conditionally applies s: requests for which match reports true run through s; all others bypass s and go straight to the next handler.

s wraps the next handler once, at composition time; match is evaluated on every request.

The returned wrapper composes with Chain and App.Use. For example, to log only requests under /api:

app.Use(sim.Selector(logging.Handler, func(r *http.Request) bool {
	return strings.HasPrefix(r.URL.Path, "/api")
}))

As an element of Chain, it conditions one wrapper without affecting the others:

sim.Chain(
	sim.Selector(logging.Handler, func(r *http.Request) bool {
		return r.URL.Path != "/healthz"
	}),
	new(Recovery).Handler,
)

func Stream added in v0.5.0

func Stream(w http.ResponseWriter, statusCode int, contentType string, r io.Reader) error

Stream writes r with the given status code and content type. The body is copied in chunks, keeping memory use constant, so Stream suits large or not-yet-complete bodies: file downloads, proxied upstream responses and generated streams. With an *os.File, the copy uses sendfile when possible. Prefer http.ServeFile / http.ServeFileFS for disk files that need Range and caching support, and Attachment for downloads that prompt the client to save the body under a filename. Extra headers such as Content-Length can be set on w before calling Stream.

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/qm012/sim"
)

func main() {
	w := httptest.NewRecorder()
	_ = sim.Stream(w, http.StatusOK, "text/plain", strings.NewReader("hello"))
	fmt.Print(w.Body)
}
Output:
hello

func Text added in v0.5.0

func Text(w http.ResponseWriter, statusCode int, s string) error

Text writes s as plain text with the given status code.

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/qm012/sim"
)

func main() {
	w := httptest.NewRecorder()
	_ = sim.Text(w, http.StatusOK, "hello, sim")
	fmt.Println(w.Header().Get("Content-Type"))
	fmt.Print(w.Body)
}
Output:
text/plain; charset=utf-8
hello, sim

func XML added in v0.5.0

func XML(w http.ResponseWriter, statusCode int, data any) error

XML writes data as XML with the given status code.

Example
package main

import (
	"encoding/xml"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/qm012/sim"
)

type exampleUser struct {
	XMLName xml.Name `json:"-" xml:"user"`
	Name    string   `json:"name" xml:"name"`
	Age     int      `json:"age" xml:"age"`
}

func main() {
	w := httptest.NewRecorder()
	_ = sim.XML(w, http.StatusOK, exampleUser{Name: "alice", Age: 30})
	fmt.Println(w.Header().Get("Content-Type"))
	fmt.Print(w.Body)
}
Output:
application/xml; charset=utf-8
<?xml version="1.0" encoding="UTF-8"?>
<user><name>alice</name><age>30</age></user>

Types

type App

type App struct {
	// contains filtered or unexported fields
}

App is an HTTP router built on top of http.ServeMux, extending it with method-based routing helpers such as App.Get, App.Post and App.Any. Requests are matched against registered patterns using the same syntax and precedence rules as http.ServeMux. App implements http.Handler; create one with NewApp.

func Default added in v0.2.0

func Default() *App

Default returns a new App with the standard wrappers already registered by App.Use, outermost first:

The order is fixed by the wrappers themselves: ClientIPResolution must run before RequestLogging reads the client IP, and RequestLogging must sit outside Recovery so a recovered panic is logged as the 500 response it becomes. Recovery is therefore innermost, and a panic raised by ClientIPResolution.Lookup is not recovered.

Default takes no configuration; every wrapper runs with its zero-value defaults. To tune one, register the same set explicitly:

clientIP := &ClientIPResolution{
	TrustedCIDRs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
}
app := NewApp()
app.Use(clientIP.Handler, new(RequestLogging).Handler, new(Recovery).Handler)

Each wrapper snapshots its fields when a registration method such as App.Get applies it, so configure a wrapper before registering routes.

func NewApp

func NewApp() *App

NewApp returns a new App value.

func (*App) Any

func (a *App) Any(path string, handlerFunc http.HandlerFunc)

Any registers handlerFunc for the given path, matching all HTTP methods.

func (*App) Connect

func (a *App) Connect(path string, handlerFunc http.HandlerFunc)

Connect registers handlerFunc for CONNECT requests to the given path.

func (*App) Delete

func (a *App) Delete(path string, handlerFunc http.HandlerFunc)

Delete registers handlerFunc for DELETE requests to the given path.

func (*App) Get

func (a *App) Get(path string, handlerFunc http.HandlerFunc)

Get registers handlerFunc for GET requests to the given path.

func (*App) Group

func (a *App) Group(relativePath string, fn func(r Router))

Group creates a new router group with the given relative path and invokes fn with it. Routes registered by fn are resolved relative to the group's path (see Router.Group). If fn is nil, Group does nothing.

func (*App) Handle

func (a *App) Handle(pattern string, handler http.Handler)

Handle registers the handler for the given pattern, with the same behavior as http.ServeMux.Handle and http.Handle.

func (*App) HandleFunc

func (a *App) HandleFunc(pattern string, handlerFunc http.HandlerFunc)

HandleFunc registers the handler function for the given pattern, with the same behavior as http.ServeMux.HandleFunc and http.HandleFunc.

func (*App) Handler

func (a *App) Handler(r *http.Request) (http.Handler, string)

Handler returns the handler and the matching pattern for the given request.

func (*App) Head

func (a *App) Head(path string, handlerFunc http.HandlerFunc)

Head registers handlerFunc for HEAD requests to the given path.

func (*App) Options

func (a *App) Options(path string, handlerFunc http.HandlerFunc)

Options registers handlerFunc for OPTIONS requests to the given path.

func (*App) Patch

func (a *App) Patch(path string, handlerFunc http.HandlerFunc)

Patch registers handlerFunc for PATCH requests to the given path.

func (*App) Post

func (a *App) Post(path string, handlerFunc http.HandlerFunc)

Post registers handlerFunc for POST requests to the given path.

func (*App) Put

func (a *App) Put(path string, handlerFunc http.HandlerFunc)

Put registers handlerFunc for PUT requests to the given path.

func (*App) Run

func (a *App) Run(ctx context.Context, addr string) error

Run listens on the given TCP address and serves HTTP requests until ctx is canceled or the server fails. If ctx is canceled, Run shuts the server down gracefully and returns nil.

func (*App) ServeHTTP

func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request)

func (*App) Trace

func (a *App) Trace(path string, handlerFunc http.HandlerFunc)

Trace registers handlerFunc for TRACE requests to the given path.

func (*App) Use

func (a *App) Use(ss ...func(http.Handler) http.Handler)

Use registers the given wrappers and applies them to every handler registered after this call. Wrappers run in registration order: the first is outermost and receives the request first, the same composition as Chain.

type ClientIPResolution added in v0.2.0

type ClientIPResolution struct {
	// TrustedCIDRs lists the peer CIDRs whose X-Forwarded-For and
	// X-Real-IP headers are trusted. Nil or empty trusts no peer: only
	// the remote address is reported. Peers are compared after folding
	// IPv4-mapped IPv6 to plain IPv4, so an IPv6-only prefix such as
	// "::/0" never matches IPv4 peers; to trust every peer, assign
	// [TrustAllCIDRs]. Typical values are your reverse proxy's CIDRs,
	// e.g. netip.MustParsePrefix("10.0.0.0/8") or
	// netip.MustParsePrefix("2001:db8::/32").
	TrustedCIDRs []netip.Prefix

	// Lookup specifies an optional function consulted before the built-in
	// resolution. A non-empty result is used as the client IP as-is,
	// bypassing the trust gate; an empty result falls back to the built-in
	// resolution. It can trust headers the built-in resolution does not
	// read, such as CF-Connecting-IP, but callers must ensure their
	// deployment overwrites the header, or a client can forge the reported
	// IP.
	Lookup func(*http.Request) string
}

ClientIPResolution resolves the client IP address and stores it in the request context, where RequestLogging and ClientIPFromContext read it.

The remote address is always reported. The X-Forwarded-For and X-Real-IP headers are consulted only when the peer is inside ClientIPResolution.TrustedCIDRs; a header from any other peer is ignored, since anyone can set it. For a trusted peer, the X-Forwarded-For chain is walked from the right, skipping trusted proxies, so an attacker cannot forge an entry past the last trusted hop.

func (*ClientIPResolution) Handler added in v0.2.0

func (c *ClientIPResolution) Handler(h http.Handler) http.Handler

Handler resolves the client IP for each request and stores it in the request context for ClientIPFromContext. It captures the current field values at call time; later changes do not affect the returned handler.

type Decoder added in v0.5.0

type Decoder[T any] interface {
	Decode(r *http.Request) (*T, error)
}

Decoder decodes an HTTP request into a *T. A decoder must return a non-nil value when the error is nil; otherwise, Bind returns ErrDecodeNil. BindJSON, BindXML, BindQuery, BindForm, BindPath and BindHeader use the built-in decoders; custom formats plug in through this interface.

type DecoderFunc added in v0.5.0

type DecoderFunc[T any] func(r *http.Request) (*T, error)

DecoderFunc adapts an ordinary function to the Decoder interface.

Example

ExampleDecoderFunc plugs a custom format into Bind: a JSON webhook whose X-Signature header is verified over the raw body bytes before the payload is decoded. The decoder reads the body once and reuses those bytes for both the HMAC check and the JSON decode.

package main

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/qm012/sim"
)

// errBadSignature is returned when X-Signature does not match the body's HMAC.
var errBadSignature = errors.New("bad signature")

// webhookEvent is the payload carried by a signed JSON webhook.
type webhookEvent struct {
	EventID string `json:"event_id"`
	Type    string `json:"type"`
}

func main() {
	secret := []byte("s3cret")

	// verify yields an event only when X-Signature matches the HMAC-SHA256 of
	// the raw body; a mismatch fails before any JSON is parsed. The Decoder is
	// stateless per request, so one value serves every call.
	verify := sim.DecoderFunc[webhookEvent](func(r *http.Request) (*webhookEvent, error) {
		raw, err := io.ReadAll(r.Body)
		if err != nil {
			return nil, fmt.Errorf("read body: %w", err)
		}
		mac := hmac.New(sha256.New, secret)
		_, _ = mac.Write(raw)
		if !hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) {
			return nil, errBadSignature
		}
		var e webhookEvent
		if err := json.Unmarshal(raw, &e); err != nil {
			return nil, fmt.Errorf("decode event: %w", err)
		}
		return &e, nil
	})

	body := `{"event_id":"evt_9f3a","type":"order.paid"}`
	mac := hmac.New(sha256.New, secret)
	_, _ = mac.Write([]byte(body))
	sig := hex.EncodeToString(mac.Sum(nil))
	ctx := context.Background()

	// A correctly signed webhook decodes into the event.
	ok := httptest.NewRequestWithContext(ctx, http.MethodPost, "/webhook", strings.NewReader(body))
	ok.Header.Set("X-Signature", sig)
	e, _ := sim.Bind(ok, verify)
	fmt.Println("valid:", e.Type, e.EventID)

	// A tampered signature is rejected before the payload is used.
	bad := httptest.NewRequestWithContext(ctx, http.MethodPost, "/webhook", strings.NewReader(body))
	bad.Header.Set("X-Signature", "deadbeef")
	_, err := sim.Bind(bad, verify)
	fmt.Println("tampered:", err)

}
Output:
valid: order.paid evt_9f3a
tampered: bad signature

func (DecoderFunc[T]) Decode added in v0.5.0

func (f DecoderFunc[T]) Decode(r *http.Request) (*T, error)

Decode implements Decoder.

type JSONDecoderOption added in v0.5.0

type JSONDecoderOption func(*json.Decoder)

JSONDecoderOption configures the JSON decoder.

func DisallowUnknownFields added in v0.5.0

func DisallowUnknownFields() JSONDecoderOption

DisallowUnknownFields makes decoding fail when the JSON contains fields that do not match any field of T.

func UseNumber added in v0.5.0

func UseNumber() JSONDecoderOption

UseNumber parses JSON numbers into json.Number instead of float64.

type JSONEncoderOption added in v0.5.0

type JSONEncoderOption func(*jsonEncoderOptions)

JSONEncoderOption configures JSON.

func EscapeForHTML added in v0.5.0

func EscapeForHTML(v bool) JSONEncoderOption

EscapeForHTML controls HTML character escaping in JSON output. Escaping is enabled by default; EscapeForHTML(false) disables it.

func Indented added in v0.5.0

func Indented(v bool) JSONEncoderOption

Indented controls indentation in JSON output. Indentation is disabled by default; Indented(true) enables it.

type PanicError added in v0.2.0

type PanicError struct {
	// Value is the value passed to panic. It may not be an error.
	Value any
	// Stack is the goroutine stack trace captured at the recovery point.
	Stack []byte
}

PanicError carries the value and stack trace of a recovered panic.

func (*PanicError) Error added in v0.2.0

func (p *PanicError) Error() string

Error implements the error interface.

func (*PanicError) LogValue added in v0.2.0

func (p *PanicError) LogValue() slog.Value

LogValue implements the slog.LogValuer interface.

func (*PanicError) Unwrap added in v0.2.0

func (p *PanicError) Unwrap() error

Unwrap returns the panic value if it is an error, enabling errors.Is, errors.As, and errors.AsType to match against it.

type Recovery added in v0.2.0

type Recovery struct {
	// HandlePanic is called after a panic is recovered to handle it,
	// typically by writing the HTTP response. For most panics, Recovery
	// already logs the stack trace; HandlePanic only needs to take care of
	// the response (and optional side-effects such as error reporting).
	// Connection-related panics are handled internally and never invoke this function.
	// If the response was already committed prior to the panic, net/http
	// ignores further WriteHeader calls and appends further writes to the body.
	// Implementations should be aware of this behavior.
	// If nil, defaultHandlePanic is used.
	HandlePanic func(http.ResponseWriter, *http.Request, *PanicError)
}

Recovery wraps an http.Handler to recover from panics, logging them with a stack trace and writing error responses via HandlePanic.

func (*Recovery) Handler added in v0.2.0

func (rc *Recovery) Handler(h http.Handler) http.Handler

Handler returns a handler that recovers from panics raised while invoking h and logs them via slog.

If a panic is recovered, the error response is written by Recovery.HandlePanic (or defaultHandlePanic if it is nil). Panics whose value is or wraps http.ErrAbortHandler are re-panicked so net/http can abort the connection silently, and panics caused by a dead connection (such as a reset or a broken pipe) are logged as warnings without writing a response.

type RequestLogging added in v0.2.0

type RequestLogging struct {
	// OmitBytesWritten omits the response body bytes from the logged record.
	OmitBytesWritten bool
	// HideQueryString omits the query string from the logged uri,
	// e.g. for tokens or API keys.
	HideQueryString bool
	// ExtraAttrs appends attributes to each record.
	ExtraAttrs func(*http.Request) []slog.Attr
}

RequestLogging logs each HTTP request via slog. If a ClientIPResolution handler wrapped the request, the resolved client IP is included as a client_ip attribute.

func (*RequestLogging) Handler added in v0.2.0

func (rl *RequestLogging) Handler(h http.Handler) http.Handler

Handler wraps h and logs each request it serves. It captures the current field values at call time; later changes do not affect the returned handler. If h panics, no record is written for that request; compose this handler outside any panic recovery so recovered panics are recorded as the error responses they become.

type Router

type Router interface {
	// Use registers the given wrappers and applies them to every handler
	// registered after this call. Wrappers run in registration order:
	// the first is outermost and receives the request first, the same
	// composition as [Chain].
	Use(ss ...func(http.Handler) http.Handler)

	// Handle registers the handler for the given pattern, with the same
	// behavior as [http.ServeMux.Handle] and [http.Handle].
	Handle(pattern string, handler http.Handler)
	// HandleFunc registers the handler function for the given pattern,
	// with the same behavior as [http.ServeMux.HandleFunc] and [http.HandleFunc].
	HandleFunc(pattern string, handler http.HandlerFunc)

	// Any matches all HTTP methods. Get, Post, Delete, Patch, Put, Options,
	// Head, Connect, and Trace register handlerFunc for their respective HTTP
	// methods. Unlike Handle and HandleFunc, these helpers take a path without
	// a method prefix.
	Any(path string, handlerFunc http.HandlerFunc)
	Get(path string, handlerFunc http.HandlerFunc)
	Post(path string, handlerFunc http.HandlerFunc)
	Delete(path string, handlerFunc http.HandlerFunc)
	Patch(path string, handlerFunc http.HandlerFunc)
	Put(path string, handlerFunc http.HandlerFunc)
	Options(path string, handlerFunc http.HandlerFunc)
	Head(path string, handlerFunc http.HandlerFunc)
	Connect(path string, handlerFunc http.HandlerFunc)
	Trace(path string, handlerFunc http.HandlerFunc)

	// Group creates a new router group with the given relative path.
	// The fn function registers routes within the group, each of which
	// is resolved relative to the group's path.
	// For example, a group registered at "/api" with a route registered
	// at "/users" handles requests for "/api/users".
	Group(relativePath string, fn func(r Router))
}

Router is the set of core routing methods implemented by App, using only the standard net/http.

type Validator added in v0.5.0

type Validator interface {
	// Validate validates the decoded request data.
	Validate(ctx context.Context) error
}

Validator defines the interface for validating a decoded request payload. Bind calls Validate automatically when the decoded value implements it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL