A small, type-safe Go web toolkit built on top of
net/http.
Mint is not a framework. It does not give you a router, a middleware chain, or a request context type. What it gives you is one thing: a way to write HTTP handlers as plain, type-safe Go functions.
func GetUser(id m.Path[int]) (User, error) { ... }
func CreateUser(body m.JSON[NewUser]) m.Result[User] { ... }
func Search(q m.Query[Filter]) []Result { ... }You keep http.ServeMux. You keep http.HandlerFunc. Mint is a thin adapter
in between that does parameter extraction, validation, and response
serialization for you — and gets out of the way for everything else.
go get github.com/cymoo/mintRequires Go 1.23+ (for the enhanced routing patterns in net/http).
- Hello, Mint
- Core idea:
H(handler) - Extracting input
- Returning output
- Errors
- Validation
- Streaming, SSE, hijack
- Configuration
- Pitfalls & limitations
- What Mint does not do
package main
import (
"net/http"
m "github.com/cymoo/mint"
)
func hello(name m.Query[struct {
Name string `schema:"name"`
}]) string {
if name.Value.Name == "" {
return "Hello, world!"
}
return "Hello, " + name.Value.Name + "!"
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", m.H(hello))
http.ListenAndServe(":8080", mux)
}That's it. No Engine, no Router, no app := mint.New().
m.H adapts any function whose parameters and return values Mint
understands into an http.HandlerFunc:
http.HandleFunc(pattern, m.H(yourFunc))
| Parameter type | What it gives you |
|---|---|
m.JSON[T] |
Decoded + validated JSON body |
m.Query[T] |
Decoded + validated query string |
m.Form[T] |
Decoded + validated form (application/x-www-form-urlencoded) |
m.Path[T] |
A single typed path parameter |
m.Header[T] |
Headers mapped onto a struct via header tags |
m.Cookie[T] |
Cookies mapped onto a struct via cookie tags |
http.ResponseWriter |
The raw writer (wrapped, see below) |
*http.Request |
The raw request |
any type implementing Extractor |
Your own extractor |
Mint panics at registration time if you use anything else as a parameter. Bad handlers blow up at startup, not at request time.
| Return | Behavior |
|---|---|
| none | 200, empty body |
T |
JSON-encoded (or special-cased below) |
error |
Error pipeline (see Errors) |
(T, error) |
Common case: data on success, error on failure |
m.Result[T] |
Full control: code, headers, body, or error |
m.StatusCode |
Empty body with this status |
m.HTML |
Content-Type: text/html |
[]byte |
Content-Type: application/octet-stream |
io.Reader |
Streamed verbatim |
http.Handler |
Delegated to |
any type implementing Responder |
Calls Respond(w, r) |
Any other concrete type is JSON-encoded. The first value of (T, error)
cannot be Result[U] — pick one style or the other.
All extractors are tiny generic wrappers. Their decoded value lives in
.Value.
type CreateUser struct {
Name string `json:"name" validate:"required,min=2"`
Email string `json:"email" validate:"required,email"`
}
func createUser(body m.JSON[CreateUser]) (User, error) {
return saveUser(body.Value), nil
}- Body is automatically size-limited (default 5 MiB; configurable).
- Empty body →
empty_bodyerror. - Malformed JSON → 400 with
Err: "json_decode_error". - Validation tags are honored.
type Filter struct {
Page int `schema:"page" validate:"gte=1"`
Limit int `schema:"limit" validate:"gte=1,lte=100"`
Q string `schema:"q"`
}
func search(f m.Query[Filter]) []Hit { ... }Query strings are decoded with gorilla/schema using schema tags.
type Login struct {
Username string `schema:"username" validate:"required"`
Password string `schema:"password" validate:"required,min=8"`
}
func login(f m.Form[Login]) error { ... }Same conventions as Query[T], but reads r.Form after ParseForm.
Path[T] extracts a single typed path parameter:
mux.HandleFunc("GET /users/{id}", m.H(func(id m.Path[int]) User { ... }))
mux.HandleFunc("GET /files/{name}", m.H(func(name m.Path[string]) File { ... }))Supported types: string, int, int8, int16, int32, int64,
uint–uint64, float32, float64, bool.
⚠️ Path binding is positional, not by name. Path params are matched to handler parameters in order. Formux.HandleFunc("GET /users/{uid}/posts/{pid}", m.H(handler)), the firstPath[T]parameter inhandlerreceives{uid}, the second receives{pid}— regardless of the Go variable names you choose.// pid gets {uid}, uid gets {pid}. Don't do this: func bad(pid m.Path[int], uid m.Path[int]) { ... } // Match argument order to URL order: func good(uid m.Path[int], pid m.Path[int]) { ... }
If you declare more Path[T] parameters than the route pattern provides,
Mint returns 400 at request time (and logs a one-time warning).
type AuthHeaders struct {
Token string `header:"Authorization" validate:"required"`
RequestID string `header:"X-Request-ID"`
}
func protected(h m.Header[AuthHeaders]) string {
return "hello, " + h.Value.Token
}Missing headers are zero values. Use validate:"required" if you need
them present.
type Session struct {
ID string `cookie:"session_id" validate:"required"`
Theme string `cookie:"theme"`
}
func me(s m.Cookie[Session]) User { ... }You can mix extractors with http.ResponseWriter and *http.Request
freely:
func download(id m.Path[int], w http.ResponseWriter, r *http.Request) error {
f, err := openFile(id.Value)
if err != nil { return err }
defer f.Close()
http.ServeContent(w, r, f.Name(), f.ModTime(), f)
return nil
}Any type that implements Extract(*http.Request) error (on a pointer
receiver) works:
type Authed struct{ User User }
func (a *Authed) Extract(r *http.Request) error {
u, err := userFromBearer(r.Header.Get("Authorization"))
if err != nil { return &m.HTTPError{Code: 401, Err: "unauthorized"} }
a.User = u
return nil
}
func me(a Authed) User { return a.User }The most common pattern is (T, error):
func getUser(id m.Path[int]) (User, error) {
u, ok := users[id.Value]
if !ok {
return User{}, &m.HTTPError{Code: 404, Err: "not_found"}
}
return u, nil
}When you need to control the status code or headers, use Result[T]:
func createUser(body m.JSON[NewUser]) m.Result[User] {
u := save(body.Value)
return m.Result[User]{
Code: 201,
Headers: http.Header{
"Location": {fmt.Sprintf("/users/%d", u.ID)},
},
Data: u,
}
}There are helpers m.OK[T](data) and m.Err[T](code, err) for trivial
cases.
func deleted() m.StatusCode { return 204 }
func page() m.HTML { return "<h1>Hi</h1>" }
func csv() []byte { return []byte("a,b,c\n") }
func bigFile() io.Reader { f, _ := os.Open("x"); return f }Mint converts errors into HTTP responses in this order of priority:
-
*HTTPError— used verbatim.return &m.HTTPError{Code: 404, Err: "not_found", Message: "user 42 not found"}
-
*ExtractError— emitted by built-in/custom extractors. Mint maps theTypeto a code (body_too_large→ 413, etc.). -
Anything else — Mint inspects the error message to guess a status:
- "not found" / "doesn't exist" → 404
- "unauthorized" / "auth" → 401
- "forbidden" → 403
- "timeout" → 408
- "conflict" / "exists" → 409
- "invalid" / "validation" → 400
- otherwise → 500
| Status range | Message in response body |
Logged? |
|---|---|---|
| 4xx | Yes — set to err.Error() |
No |
| 5xx | Hidden (empty) | Yes (mint: 5xx) |
This way internal errors don't leak to clients, but you still see them in logs.
If you want exact control: return
*HTTPError. Mint will respect bothErrandMessageregardless of code.
Validation is on by default and uses
go-playground/validator.
The framework wires up tag-name lookup for json, schema, header,
and cookie tags so error messages reference the input name, not
the Go field name.
type Body struct {
Email string `json:"email" validate:"required,email"`
}
// On bad input:
// 400 {"err":"validation_failed","message":"email: required validation failed"}Turn it off with m.Configure(m.WithValidation(false)) or use a custom
validator with m.WithValidator(...).
The ResponseWriter that handlers receive is a thin wrapper that still
forwards to the underlying http.ResponseWriter. It explicitly
implements:
http.Flusher— for SSE /text/event-streamhttp.Hijacker— for protocol upgrades (WebSocket, etc.)http.Pusher— for HTTP/2 push
Each delegates to the underlying writer, or returns
http.ErrNotSupported if the underlying server doesn't support it.
func sse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher, _ := w.(http.Flusher)
for i := 0; i < 5; i++ {
fmt.Fprintf(w, "data: tick %d\n\n", i)
flusher.Flush()
time.Sleep(time.Second)
}
}Configuration is global and thread-safe. Set it once at startup:
m.Initialize(
m.WithMaxRequestBodySize(10 << 20), // 10 MiB
m.WithLogger(myLogger),
)Initialize should be called at most once. Use Configure to override
later (e.g. in tests). Reset returns everything to defaults.
| Option | Default |
|---|---|
WithMaxRequestBodySize(n) |
5 << 20 (5 MiB). Pass 0 to disable. |
WithJSONEncode(fn) |
json.Encoder with SetEscapeHTML(false) |
WithJSONMarshal(fn) |
(unset; falls back to encoder) |
WithJSONUnmarshal(fn) |
json.Unmarshal |
WithSchemaDecoder(d) |
schema.Decoder with IgnoreUnknownKeys(true) |
WithValidation(bool) |
true |
WithValidator(v) |
auto-created when validation enabled |
WithLogger(l) |
log.Default() |
WithErrorHandler(fn) |
built-in (returns JSON HTTPError) |
A short, honest list of things that will bite you if you don't know them.
1. Path[T] binds positionally, not by name.
See the Path[T] section. The order of Path[T] parameters
in your function must match the left-to-right order of {name} in the
URL pattern. The Go variable names are ignored.
2. Request body is capped at 5 MiB by default.
JSON and form bodies above the limit get 413
{"err":"body_too_large"}. Configure with
m.WithMaxRequestBodySize(n). Set to 0 to disable (not recommended).
3. 5xx error messages are hidden from clients by default.
Generic errors with a 5xx-mapped status produce a body without
Message. The full error is logged. Return an *HTTPError if you
intentionally want the message in the response.
4. JSON output does not escape <, >, & by default.
Mint's default encoder calls json.Encoder.SetEscapeHTML(false). If you
embed user-controlled JSON inside an HTML page, escape it yourself.
Restore the standard behavior with m.WithJSONMarshal(json.Marshal).
5. Configuration is global.
Calling Configure from multiple goroutines is safe, but it affects
every request. Don't tweak it per-request.
6. Initialize should be called once.
A second call is ignored — Mint will log a warning telling you so. Use
Configure to layer further changes, or Reset (in tests).
7. ResponseWriter is wrapped.
Type-asserting to your own custom interface won't work. Use
http.NewResponseController(w) or the explicit methods Mint exposes
(Flush, Hijack, Push). Call Unwrap() if you need the raw writer.
8. Handler signatures are checked at registration, not compile time.
A bad signature passed to m.H panics when H runs. Register all
routes at startup so problems surface immediately.
By design:
- No router. Use
http.ServeMux(Go 1.22+ patterns) or anything else that produceshttp.HandlerFunc. - No middleware system. Use
func(http.Handler) http.Handlermiddleware — Mint's adapter is itself just anhttp.HandlerFunc. - No request context wrapper. Use
r.Context(). - No DI container, no app object, no global state beyond config.
If you want any of these, pick a heavier framework. If you want plain Go with less boilerplate, Mint is for you.
MIT — see LICENSE.