Skip to content

Web Framework

devituz edited this page Jun 3, 2026 · 1 revision

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.

Hello world

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.

The (any, error) contract

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.

Routing — Laravel style

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")

Path parameters use Go 1.22 wildcards

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"
})

Resource controllers

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.

*web.Context reference

Path and query parameters

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")

Binding & validating the body

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 with web.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.

Writing responses

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 value

Every 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.

Database access

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.

Storing values on the context

// In middleware:
c.Set("user", currentUser)

// In a downstream handler:
if u, ok := c.Get("user"); ok { ... }

Cookies

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 immediately

In local-HTTP development pass web.CookieInsecure() to allow non-HTTPS cookies (or set APP_ENV=local and toggle in your own helper).

Middleware

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"),
)

Built-in middleware

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(...).

A recommended secure-by-default stack

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.

Writing your own

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.

Application lifecycle

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.

Server hardening

web.App.Run() sets sensible HTTP server timeouts so slowloris-style clients can't hold sockets open:

  • ReadHeaderTimeout = 10s
  • ReadTimeout = 30s
  • WriteTimeout = 30s
  • IdleTimeout = 120s
  • MaxHeaderBytes = 1 MiB

You don't need to override these in normal apps.

When to not use web

  • 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 web package itself doesn't ship a WS handler, but the adapters/websocket module provides a Hub + channel/room model on top of coder/websocket and works as a stdlib http.Handler so it drops into any framework.
  • You need server-side rendered HTML templates — web is JSON-first today; use the standard library's html/template from a handler if you need server rendering.

See also

Clone this wiki locally