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.25+ (the dependency floor; Mint itself needs the Go
1.22+ net/http routing patterns).
- 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 or multipart/form-data) |
m.File |
First uploaded file from a multipart form |
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 |
context.Context |
r.Context() — no need to take the request just for the context |
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. The same startup checks also reject:
Query[T]/Form[T]/Header[T]/Cookie[T]whereTis not a struct (or pointer to struct);- two
JSON[T]parameters, orJSON[T]combined withForm[T]/File(the body can only be consumed once —Form+Filetogether is fine); - a custom writer interface that
*mint.ResponseWritercannot satisfy (it provideshttp.ResponseWriterplusFlush,Hijack,Push,Unwrap,Status,Written).
| 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 (closed after, if io.Closer) |
http.Handler |
Delegated to |
any type implementing Responder |
Calls Respond(w) |
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: "invalid_json_syntax"/"invalid_json_type"(or"json_decode_error"for failures from a customWithJSONUnmarshalfunction). - Validation tags are honored — for struct bodies. Non-struct bodies (slices, maps, scalars) decode fine and simply skip validation.
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 parsing URL-encoded
or multipart form data.
Multipart file fields can be bound by schema/form tag to m.FilePart,
*m.FilePart, []m.FilePart, or []*m.FilePart:
type Upload struct {
Title string `schema:"title"`
File m.FilePart `schema:"file"`
}
func upload(f m.Form[Upload]) error {
name := filepath.Base(f.Value.File.Filename)
return f.Value.File.Save(filepath.Join("./uploads", name))
}FilePart.Save(path) streams the upload to disk, creates parent directories,
removes a partial target on failure, and can only be called once. Use
FilePart.Open() if you need to stream the file yourself.
File fields are also found through untagged embedded structs (value or pointer embeds), mirroring how the schema decoder promotes value fields.
func upload(file m.File) error {
name := filepath.Base(file.Value.Filename)
return file.Save(filepath.Join("./uploads", name))
}File extracts one uploaded file from a multipart/form-data request —
the first file of the alphabetically-first field name (multipart field
order is not preserved). Use Form[T] with FilePart fields when you need
to bind a specific form field name or accept multiple files.
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, uintptr, float32, float64, bool (and named types
based on them).
⚠️ 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).
{name...} wildcards bind like any other placeholder (use
Path[string]); an empty remainder — e.g. GET /files/ against
/files/{p...} — is a legitimate match and yields "". This applies to
Path[string] only: a non-string wildcard binding cannot represent an
empty match and still returns 400.
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" / "does not exist" / "doesn't exist" → 404
- "unauthorized" → 401
- "forbidden" → 403
- "timeout" → 408
- "conflict" / "already exists" → 409
- "bad request" / "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.
The rule is applied against the final status code: with
m.Err[T](code, err), the Result.Code you pass decides whether the
message is shown (4xx) or hidden and logged (5xx).
If you want exact control: return
*HTTPError. Mint will respect bothErrandMessageregardless of code (Result.Codeonly overrides the status).
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. Hijack and Push return
http.ErrNotSupported if the underlying server doesn't support them;
Flush (which has no return value) is a safe no-op.
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. |
WithMultipartMemory(n) |
32 << 20 (32 MiB) in-memory budget for multipart parsing |
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.