Shaping what comes in
Lists, guards and uploads — the three places where the request carries something the endpoint must bound before it acts on it.
Paging, ordering and the allow-list
order_by becomes a real ORDER BY clause. Dropping anything that is not a column identifier is the floor; naming the columns you meant is what keeps a client from sorting by a field you never exposed. See Pagination.
package main
import (
"net/http"
"time"
core "gitlab.finema.co/finema/idin-core/v2"
"gitlab.finema.co/finema/idin-core/v2/repository"
"gitlab.finema.co/finema/idin-core/v2/utils"
)
// --- Example 4: list endpoints ----------------------------------------------
//
// GetPageOptions reads limit/page/q/order_by from the query string and clamps
// them, so no request can ask for the whole table. Page[T] marshals straight to
// JSON, which makes the happy path one call.
//
// The part worth reading twice is order_by: it becomes a real ORDER BY clause,
// handed to the driver as SQL.
// article is the model. It carries columns a client must never see, which is
// the reason articleResponse exists below.
type article struct {
ID string `gorm:"primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Status string `gorm:"column:status"`
Views int64 `gorm:"column:views"`
CreatedAt *time.Time `gorm:"column:created_at"`
AuthorEmail string `gorm:"column:author_email"` // internal
}
func (article) TableName() string { return "articles" }
type articleResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Status string `json:"status"`
Views int64 `json:"views"`
CreatedAt time.Time `json:"created_at"`
}
func toArticleResponse(a article) articleResponse {
return articleResponse{
ID: a.ID,
Title: a.Title,
Slug: a.Slug,
Status: a.Status,
Views: a.Views,
CreatedAt: utils.ToNonPointer(a.CreatedAt),
}
}
func mountArticleList(g *core.Group) {
g.GET("/articles", listArticles)
}
func listArticles(c core.IHTTPContext) error {
// Two defences, and they answer different questions.
//
// GetPageOptions already drops anything that is not a column identifier, so
// no client can smuggle a subquery or a second statement into the ORDER BY.
// That is the floor. It still leaves "any real column" sortable, including
// the ones this endpoint never meant to expose — sorting by author_email
// leaks its ordering, and an indexless column turns a list call into a table
// scan. The allow-list is what narrows it to the columns you chose.
opts := c.GetPageOptionsWithAllowed("created_at", "title", "views")
// An endpoint that wants a tighter ceiling than PageLimitMax says so itself:
// the framework's cap is high on purpose, because clamping silently returns
// a short page with no error and nothing in the logs.
if opts.Limit > 100 {
opts.Limit = 100
}
if c.DB() == nil {
// No SQL connection configured. NewPage builds the same envelope around
// items the caller produced itself — a search index, a fixture, a list
// stitched from several sources — so the response shape does not depend
// on where the rows came from.
items, total := pageInMemory(sampleArticles(), opts)
return c.JSON(http.StatusOK, core.MapPage(core.NewPage(items, total, opts), toArticleResponse))
}
// Order on the chain is applied before the request's, so this ordering is
// the endpoint's and order_by only breaks its ties. Page through something
// unique, or two rows sharing a created_at can appear on both page 1 and
// page 2 — or on neither.
page, err := repository.New[article](c).
Where("status = ?", statusPublished).
Order("id asc").
Pagination(opts)
if err != nil {
return err
}
// MapPage keeps the metadata and rewrites only the items. Declaring the
// response type is what makes the compiler — rather than the next reviewer —
// responsible for keeping author_email out of the response.
return c.JSON(http.StatusOK, core.MapPage(page, toArticleResponse))
}
// pageInMemory slices a fixture the way the database would. opts arrives
// normalised (limit and page are already at least 1), so the arithmetic needs
// no guards of its own.
func pageInMemory(all []article, opts *core.PageOptions) ([]article, int64) {
from := int((opts.Page - 1) * opts.Limit)
if from > len(all) {
from = len(all)
}
to := from + int(opts.Limit)
if to > len(all) {
to = len(all)
}
return all[from:to], int64(len(all))
}
// sampleArticles is a function, not a package-level slice: a shared slice is
// mutable state that any handler could write through.
func sampleArticles() []article {
base := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC)
return []article{
{ID: "1", Title: "Why v2", Slug: "why-v2", Status: statusPublished, Views: 128,
CreatedAt: utils.ToPointer(base), AuthorEmail: "[email protected]"},
{ID: "2", Title: "Binding requests", Slug: "binding-requests", Status: statusPublished, Views: 64,
CreatedAt: utils.ToPointer(base.AddDate(0, 0, 1)), AuthorEmail: "[email protected]"},
{ID: "3", Title: "Draft notes", Slug: "draft-notes", Status: statusDraft, Views: 0,
CreatedAt: utils.ToPointer(base.AddDate(0, 0, 2)), AuthorEmail: "[email protected]"},
}
}Middleware, and where to attach it
Middleware runs on *echo.Context, before the framework context exists — so one that needs a capability takes the App when it is built. Server, group or route: the choice is between the guard that cannot be forgotten and the line that can be read on its own.
package main
import (
"net/http"
"time"
"github.com/labstack/echo/v5"
core "gitlab.finema.co/finema/idin-core/v2"
)
// --- Example 5: middleware, and where to attach it --------------------------
//
// Middleware runs on *echo.Context — echo v5 made Context a struct, so the
// signature is a pointer — and it runs *before* WithHTTPContext has built the
// IHTTPContext. There is therefore no c.DB(), no c.Log(), no repository here:
// a middleware that needs a capability takes the App when it is constructed.
//
// That is a feature. Middleware is for cross-cutting concerns (auth, tracing,
// limits); business logic hidden in it is logic nobody finds when reading the
// handler that it changes.
// tenantContextKey is echo's own per-request store, which is what middleware
// and handlers share. It is not ctx.SetData — that lives on the IContext, which
// does not exist yet when this runs.
const tenantContextKey = "example.tenant_id"
// requireTenant rejects a request with no tenant header.
//
// Returning a core.IError straight from middleware is enough: the framework's
// error handler renders it exactly as it renders a handler's, so the rejection
// is logged, traced and shaped like every other response instead of being an
// echo.HTTPError in a different format.
func requireTenant() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ec *echo.Context) error {
tenant := ec.Request().Header.Get("X-Tenant-Id")
if tenant == "" {
return core.New(http.StatusBadRequest, "TENANT_REQUIRED",
"the X-Tenant-Id header is required")
}
ec.Set(tenantContextKey, tenant)
return next(ec)
}
}
}
// auditWrites is the middleware that does need a capability. Taking *core.App
// at construction — rather than reaching for a package-level variable — is what
// keeps two Apps in one process (every parallel test builds its own) from
// sharing one logger and one set of connections.
func auditWrites(app *core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ec *echo.Context) error {
if ec.Request().Method == http.MethodGet {
return next(ec)
}
started := time.Now()
err := next(ec)
// Build the context from the request's, so this line shares the
// trace and the request id of everything the handler logged.
ctx := app.NewContext(ec.Request().Context())
ctx.Log().Info("write attempted",
"method", ec.Request().Method,
"path", ec.Request().URL.Path,
"tenant", tenantOfEcho(ec),
"took_ms", time.Since(started).Milliseconds(),
"failed", err != nil,
)
return err
}
}
}
func tenantOfEcho(ec *echo.Context) string {
tenant, _ := ec.Get(tenantContextKey).(string)
return tenant
}
// tenantOf reads the same value from the handler side.
func tenantOf(c core.IHTTPContext) string {
tenant, _ := c.Get(tenantContextKey).(string)
return tenant
}
func mountMiddleware(e *core.Server, app *core.App) {
// Order is the order they are added, and it is not cosmetic: auditWrites
// names the tenant, so requireTenant has to have resolved it first. The
// general rule is that anything a later middleware reads must be produced
// by an earlier one — auth before any guard that inspects the user, body
// limits before anything that reads a body.
admin := e.Group("/admin", requireTenant(), auditWrites(app))
admin.GET("/stats", tenantStats)
// A third place to attach: one route. A group protects every route added to
// it later, which is safer; a per-route line is readable on its own, which
// makes an audit a grep rather than a walk up the file. Use the group when
// forgetting is the bigger risk, the line when clarity is.
//
// core.BodyLimit is ordinary middleware, so a route can set a limit larger
// *or smaller* than the server's — the value is read when the body is read,
// and by then the innermost one has set it.
e.POST("/webhooks/billing", handleWebhook, core.BodyLimit(64<<10))
}
func tenantStats(c core.IHTTPContext) error {
return c.JSON(http.StatusOK, map[string]any{
"tenant": tenantOf(c),
"articles": len(sampleArticles()),
})
}
func handleWebhook(c core.IHTTPContext) error {
payload := map[string]any{}
if err := c.BindOnly(&payload); err != nil {
return err
}
// Answer fast and do the work elsewhere: a sender that times out retries,
// and a webhook processed inline is processed twice. Any goroutine started
// here must carry a context of its own (ctx.WithContext(bg)), or it is
// cancelled the moment this response is written.
c.Log().Info("webhook received", "keys", len(payload))
return c.NoContent(http.StatusAccepted)
}Files in, files out
Files are not bound into the struct because multipart parts are streams. The filename and content type come from the client and are evidence of nothing; the size limit, the sniffed type and the generated key are what make the route safe.
package main
import (
"errors"
"io"
"net/http"
"time"
core "gitlab.finema.co/finema/idin-core/v2"
"gitlab.finema.co/finema/idin-core/v2/errmsgs"
"gitlab.finema.co/finema/idin-core/v2/utils"
"gitlab.finema.co/finema/idin-core/v2/valid"
)
// --- Example 7: files in, files out -----------------------------------------
//
// Multipart parts are streams, which is why files are not bound into the
// request struct: a struct field would mean reading the whole upload into
// memory before the handler could decide it did not want it.
const (
maxUploadBytes = 10 << 20
uploadPrefix = "uploads/"
)
// uploadRequest is the text half of the form. Same tags, same Valid, same
// BindWithValidate as a JSON endpoint — only c.FormFile is extra.
type uploadRequest struct {
Title *string `form:"title"`
Kind *string `form:"kind"`
}
func (r *uploadRequest) Valid(ctx core.IContext) core.IError {
v := valid.New(ctx)
v.Str("title", r.Title).Trim().Required().Length(1, 120)
v.Str("kind", r.Kind).Required().In("id_card", "passport")
return v.Error()
}
func mountFiles(e *core.Server) {
// The route's own limit, because the server's is sized for JSON. It has to
// clear maxUploadBytes with room to spare: the body is the file *plus* the
// multipart envelope and the text fields, and the limit counts all of it.
files := e.Group("/files", core.BodyLimit(maxUploadBytes+(1<<20)))
files.POST("", uploadDocument)
files.GET("/:id", downloadDocument)
}
func uploadDocument(c core.IHTTPContext) error {
req := &uploadRequest{}
if err := c.BindWithValidate(req); err != nil {
return err
}
store := c.Storage()
// Storage fails loudly rather than degrading — a dropped upload is a file
// the caller believes it saved and nobody can get back. Checking first turns
// a per-request 500 into an answer that says which deployment is at fault.
if !store.Enabled() {
return core.New(http.StatusServiceUnavailable, "STORAGE_UNAVAILABLE",
"file storage is not configured on this deployment")
}
fh, fErr := c.FormFile("file")
if fErr != nil {
// A missing attachment is the client's mistake, not a 500.
return errmsgs.BadRequest.WithMessage("the file part is required")
}
// The declared size rejects an oversized upload without opening anything.
// It is not the guard — a client writes this header — but it is free, and
// core.BodyLimit on the group is what actually stops the bytes.
if fh.Size > maxUploadBytes {
return core.Newf(http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE",
"the file must not exceed %d bytes", maxUploadBytes)
}
src, oErr := fh.Open()
if oErr != nil {
return c.NewError(oErr, errmsgs.InternalServerError)
}
defer src.Close()
// fh.Filename and the part's Content-Type are whatever the client typed.
// Neither is evidence: the name may be "../../etc/passwd" and the type may
// say image/png over a script. Sniff the real type from the first bytes,
// and build the key from an id we generated so the name never becomes path.
head := make([]byte, 512)
n, _ := src.Read(head)
mime := http.DetectContentType(head[:n])
ext, ok := extensionFor(mime)
if !ok {
return core.Newf(http.StatusUnsupportedMediaType, "UNSUPPORTED_FILE_TYPE",
"%s files are not accepted", mime)
}
// multipart.File is a Seeker, so the sniffed bytes are read again rather
// than buffered and re-joined.
if _, sErr := src.Seek(0, io.SeekStart); sErr != nil {
return c.NewError(sErr, errmsgs.InternalServerError)
}
key := uploadPrefix + utils.NewUUID() + ext
// Put streams and uploads in parts, so a 200 MB file is not 200 MB of this
// process. The handle is bound to the request: a client that hangs up
// cancels the upload it started instead of paying for all of it.
if err := store.Put(key, src, core.StoragePutOptions{
ContentType: mime,
Metadata: map[string]string{"kind": deref(req.Kind), "title": deref(req.Title)},
}); err != nil {
return c.NewError(err, errmsgs.InternalServerError)
}
return c.JSON(http.StatusCreated, map[string]any{
"id": key[len(uploadPrefix):],
"size": fh.Size,
"content_type": mime,
})
}
func downloadDocument(c core.IHTTPContext) error {
store := c.Storage()
if !store.Enabled() {
return core.New(http.StatusServiceUnavailable, "STORAGE_UNAVAILABLE",
"file storage is not configured on this deployment")
}
key := uploadPrefix + c.Param("id")
info, sErr := store.Stat(key)
if sErr != nil {
if errors.Is(sErr, core.ErrObjectNotFound) {
return errmsgs.NotFoundCustomError("file")
}
return c.NewError(sErr, errmsgs.InternalServerError)
}
// A presigned link is the better default once the files are large or many:
// the bytes travel from the bucket to the client and never occupy a worker
// of this process. It needs a client that can reach the bucket directly,
// which is exactly what the streaming branch below is for when it cannot.
if c.QueryParam("link") != "" {
url, pErr := store.PresignGet(key, 5*time.Minute,
core.StoragePresignOptions{Attachment: c.Param("id")})
if pErr != nil {
return c.NewError(pErr, errmsgs.InternalServerError)
}
return c.Redirect(http.StatusFound, url)
}
body, gErr := store.Get(key)
if gErr != nil {
return c.NewError(gErr, errmsgs.InternalServerError)
}
defer body.Close()
// Stream, never GetBytes: an object read into memory is its whole size per
// concurrent download, and the one request that ends the process is the one
// nobody sized for.
return c.Stream(http.StatusOK, info.ContentType, body)
}
// extensionFor is the allow-list, keyed by the sniffed type rather than the
// claimed one. A switch and not a package-level map: the set is fixed at
// compile time and nothing should be able to add to it at runtime.
func extensionFor(mime string) (string, bool) {
switch mime {
case "image/jpeg":
return ".jpg", true
case "image/png":
return ".png", true
case "application/pdf":
return ".pdf", true
default:
return "", false
}
}