Skip to content

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.

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

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

go
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
	}
}

Maintained by Passakon Puttasuwan & Dev Core Team.