-
Notifications
You must be signed in to change notification settings - Fork 0
Web Framework
lagodev/web is a native HTTP framework with Laravel-style ergonomics.
It needs no router (no Gin, no Fiber, no Echo) — the entire pipeline
lives in lagodev itself. Use it standalone, or call lagodev's ORM from
the framework you already have (see Framework-Integration).
Available since v0.8.0. Handlers return
(any, error)— the framework converts that into JSON responses automatically.
package main
import "github.com/devituz/lagodev/web"
func main() {
app := web.New()
app.Get("/", func(c *web.Context) (any, error) {
return map[string]string{"hello": "world"}, nil
})
app.MustRun(":8080")
}That's a JSON server in twelve lines: graceful shutdown, panic recovery, request logging, and a route table printed on startup are all baked in.
Every handler — including those registered via Resource() — returns
(any, error). The framework translates those two values into an HTTP
response automatically:
err |
any |
Status was set? | Result |
|---|---|---|---|
orm.ErrNotFound |
(ignored) | — |
404 + {"error": "..."}
|
*web.ValidationError |
(ignored) | — |
422 + {"error","errors"}
|
| Other non-nil | (ignored) | — |
500 + {"error": "..."}
|
nil |
nil |
no |
204 No Content |
nil |
nil |
yes | the status you set, no body |
nil |
non-nil | no |
200 + JSON |
nil |
non-nil | yes | the status you set + JSON |
Override the status with c.Created(v) (sugar for the 201 case) or
c.Status(N) for anything else. Production builds also gain a
generic error response when APP_ENV=production so handler errors
don't leak DB internals.
app := web.New()
// Verbs
app.Get( "/users", listUsers)
app.Post( "/users", createUser)
app.Put( "/users/{id}", replaceUser)
app.Patch( "/users/{id}", updateUser)
app.Delete("/users/{id}", deleteUser)
app.Options("/users", handleOptions)
app.Head( "/users", handleHead)
app.Any( "/whatever", handler) // every verb above
// Resource: registers all five REST endpoints in one call.
app.Resource("posts", &PostController{Service: postSvc})
// Groups: shared prefix + middleware. Composes recursively.
app.Group("/api/v1", func(g *web.Router) {
g.Use(web.Auth()) // applies only inside /api/v1
g.Resource("orders", &OrderController{Service: orderSvc})
g.Group("/admin", func(a *web.Router) {
a.Use(adminOnly)
a.Resource("users", &AdminUserController{})
})
})
app.MustRun(":8080")Routes are registered on the standard library's http.ServeMux —
{name} placeholders work, and the values come out via
c.Param("name"). No extra DSL.
app.Get("/files/{path...}", func(c *web.Context) (any, error) {
return c.Param("path"), nil // matches /files/a/b/c → "a/b/c"
})app.Resource("posts", ctrl) registers six routes (five verbs — PUT
and PATCH share Update):
GET /posts → ctrl.Index
GET /posts/{id} → ctrl.Show
POST /posts → ctrl.Store
PUT /posts/{id} → ctrl.Update
PATCH /posts/{id} → ctrl.Update
DELETE /posts/{id} → ctrl.Destroy
ctrl must implement web.ResourceController:
type ResourceController interface {
Index(c *Context) (any, error)
Show(c *Context) (any, error)
Store(c *Context) (any, error)
Update(c *Context) (any, error)
Destroy(c *Context) (any, error)
}lago make:controller PostController --model=Post generates exactly
this shape — see CLI-Reference.
id := c.ParamUint("id") // /posts/{id} → 0 on missing/invalid
sub := c.Param("sub") // string version
intp := c.ParamInt("id") // int variant
q := c.Query("search") // ?search=foo
page := c.QueryInt("page", 1) // with default
host := c.QueryDefault("host", "localhost")var p Post
if err := c.Bind(&p); err != nil {
return nil, err // 400 is auto-returned by Bind on JSON errors
}c.Bind:
- Caps the body at 1 MiB by default via
http.MaxBytesReader— override per-app withweb.BodyLimit(n)middleware. - Calls
DisallowUnknownFields()on the JSON decoder so a{"ghost":1}payload is rejected with 400 instead of silently dropped.
c.MustBind(&p) returns a bool for early-return style.
For validation, pair it with struct tags and c.BindAndValidate:
type CreateUser struct {
Name string `json:"name" validate:"required,min=2,max=64"`
Email string `json:"email" validate:"required,email"`
}
func (h *Handler) Store(c *web.Context) (any, error) {
var in CreateUser
if err := c.BindAndValidate(&in); err != nil {
return nil, err // → 422 with {"errors": {"email": "..."}}
}
// ... use in
return c.Created(in), nil
}Built-in rules: required, min=N, max=N, gt=N, lt=N, email,
url, oneof=a b c, alpha, alphanumeric, uuid, numeric,
integer, ip.
c.JSON(200, payload)
c.String(200, "hello")
c.NoContent()
c.Created(v) // sets 201; return (c.Created(v), nil)
c.BadRequest("invalid foo")
c.NotFound("post not found")
c.Unauthorized("")
c.Forbidden("")
c.InternalError(err)
c.UnprocessableEntity(ve) // accepts *ValidationError
c.Status(202) // status only, body from the return valueEvery helper is idempotent — calling c.NoContent() after the body
has already been written is a no-op. The (any, error) contract is
designed around this so you can mix-and-match return-style and
direct-write style without double-write bugs.
If you constructed the app with web.WithDatabase(conn), every
Context carries the connection:
func (c *PostController) Index(ctx *web.Context) (any, error) {
var out []Post
return out, orm.Query[Post](ctx.DB).
OrderBy("id", "desc").
Get(ctx.Ctx(), &out)
}ctx.Ctx() returns the standard context.Context — pass it into
every ORM / external call so deadlines and cancellations propagate.
// In middleware:
c.Set("user", currentUser)
// In a downstream handler:
if u, ok := c.Get("user"); ok { ... }c.SetCookie defaults to HttpOnly, Secure, SameSite=Lax —
production-safe out of the box. Options override:
c.SetCookie("session", id) // safe defaults
c.SetCookie("csrf", token,
web.CookieReadable(), // turn off HttpOnly (CSRF echo)
web.CookieSameSite(http.SameSiteStrictMode))
c.Cookie("session") // read; "" if missing
c.ClearCookie("session") // expires immediatelyIn local-HTTP development pass web.CookieInsecure() to allow
non-HTTPS cookies (or set APP_ENV=local and toggle in your own
helper).
A middleware is a function func(next Handler) Handler. The first
one registered is the outermost — it sees the request first and the
response last.
app.Use(
web.RequestID(),
web.SecurityHeaders(),
web.BodyLimit(1<<20),
web.RateLimit(60, time.Minute),
web.CORS("https://app.example.com"),
)| Function | Purpose |
|---|---|
web.Logger(l) |
Auto-applied; logs method path status duration
|
web.Recovery(l) |
Auto-applied; converts panics into 500 JSON |
web.RequestID() |
Generate / echo X-Request-ID; stored in context |
web.SecurityHeaders() |
CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy, nosniff |
web.BodyLimit(n) |
Wraps body with http.MaxBytesReader (DoS guard) |
web.RateLimit(n, window) |
Per-IP fixed-window limiter with Retry-After
|
web.Throttle(...) |
Alias of RateLimit (Laravel name) |
web.CSRF() |
Double-submit cookie with constant-time compare |
web.CORS(origins...) |
Strict-by-default CORS; rejects wildcard + credentials |
web.CORSWithConfig(cfg) |
Full CORS config (credentials, max-age, exposed headers) |
web.Auth() |
Bearer-token skeleton; 401 on missing |
web.AuthJWT(authMgr) |
JWT verification using auth.Manager
|
web.New() installs Logger and Recovery automatically. Everything
else opts in via app.Use(...).
app := web.New(
web.WithDatabase(conn),
web.WithAddr(":8080"),
)
app.Use(
web.RequestID(),
web.SecurityHeaders(),
web.BodyLimit(1<<20), // 1 MiB
web.RateLimit(60, time.Minute), // per IP
web.CORS("https://app.example.com"), // strict allow-list
)
api := app.Group("/api", func(g *web.Router) {
g.Use(web.CSRF(), web.AuthJWT(authMgr))
g.Resource("posts", &PostController{Conn: conn})
})See SECURITY.md for the per-layer catalogue.
func TraceMiddleware() web.Middleware {
return func(next web.Handler) web.Handler {
return func(c *web.Context) (any, error) {
start := time.Now()
value, err := next(c)
log.Printf("trace %s %s %s", c.Request.Method, c.Request.URL.Path, time.Since(start))
return value, err
}
}
}
app.Use(TraceMiddleware())Middleware can short-circuit the chain by writing a response directly
and returning (nil, nil) — the framework respects what's already been
written and doesn't double up.
web.New accepts functional options:
| Option | Effect |
|---|---|
WithDatabase(conn) |
Connection becomes ctx.DB for every handler |
WithManager(mgr) |
mgr.Close() called on graceful shutdown |
WithMigrations(reg) |
Apply migrations at startup; nil uses default registry |
WithAddr(":8080") |
Listen address override |
WithShutdownTimeout(d) |
Maximum time to drain in-flight requests (default 10s) |
app := web.New(
web.WithDatabase(conn),
web.WithManager(mgr),
web.WithMigrations(nil),
web.WithAddr(cfg.Addr),
web.WithShutdownTimeout(30*time.Second),
)
routes.Register(app)
app.MustRun()app.Run() blocks until an OS signal arrives; on SIGINT / SIGTERM
it stops accepting new connections and waits up to ShutdownTimeout
for running requests to finish, then closes the DB manager.
app.MustRun() is the same but calls log.Fatal on a startup error
— convenient in main.
web.App.Run() sets sensible HTTP server timeouts so slowloris-style
clients can't hold sockets open:
ReadHeaderTimeout = 10sReadTimeout = 30sWriteTimeout = 30sIdleTimeout = 120sMaxHeaderBytes = 1 MiB
You don't need to override these in normal apps.
- You're embedding the lagodev ORM in an existing Gin/Fiber/Echo app —
use the ORM directly and ignore
web/entirely. See Framework-Integration for examples. - You need WebSockets — the
webpackage itself doesn't ship a WS handler, but theadapters/websocketmodule provides a Hub + channel/room model on top ofcoder/websocketand works as a stdlibhttp.Handlerso it drops into any framework. - You need server-side rendered HTML templates —
webis JSON-first today; use the standard library'shtml/templatefrom a handler if you need server rendering.
-
CLI-Reference for
lago make:controllerand friends -
ORM for the
Query[T]calls used inside controllers -
Framework-Integration for non-
webintegration patterns
lagodev on GitHub · MIT-licensed — see LICENSE.