Skip to content

Serving a request

The three things every endpoint does — come up on a port, turn a request into a value you can trust, and answer a failure in a way the caller can act on.

The server and its options

A server built with nil options is already safe to deploy — the middleware stack and the four deadlines are installed either way. What is worth overriding, and why WriteTimeout is deliberately not one of them. See HTTP → Server.

go
package main

import (
	"net/http"
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
)

// --- Example 1: the server, its options and its routes ----------------------
//
// NewHTTPServer returns a *core.Server that remembers the App, so a route takes
// its handler directly instead of every registration function threading `app`
// through purely to pass it on. It embeds *echo.Echo, so anything the framework
// does not wrap (Use, Static, Pre, …) is still one call away.
//
// A server built with nil options is already safe to deploy — the stack
// (request id, Sentry, access log, recover, CORS, body limit) and the deadlines
// are installed either way. Everything below is an override.

func newServer(app *core.App) *core.Server {
	return core.NewHTTPServer(app, &core.HTTPOptions{
		// CORS defaults to "*", which is right for a public read-only API and
		// wrong for one a browser sends credentials to. Naming the origins is
		// the whole of the fix and costs nothing to do on day one.
		AllowOrigins: []string{"https://app.example.com"},

		// 1 MB is generous for JSON. The routes that take files raise their own
		// ceiling (07_upload.go) — raising it here would hand the upload limit
		// to every endpoint that only ever receives a form, and the largest
		// request anybody sends is the memory this process uses.
		BodyLimit: 1 << 20,

		// Zero means "take the framework default", a negative value means "no
		// deadline at all" — an unset field cannot be told apart from an
		// explicit zero, so the two intentions need two spellings.
		//
		// ReadHeaderTimeout is what actually closes the slow-loris hole, which
		// is why it can be short while ReadTimeout stays at five minutes: a
		// legitimate upload over a phone connection takes minutes, headers
		// never do. They are two settings because they do two jobs.
		ReadHeaderTimeout: 10 * time.Second,
		IdleTimeout:       60 * time.Second,

		// WriteTimeout is deliberately left unset. It is an absolute deadline on
		// the whole response, so any value large enough for a slow download
		// protects nothing, and any value small enough to protect something cuts
		// an SSE stream in half. A service with neither can set one.
	})
}

// mountSystem registers what every service has, and returns the group the rest
// of the examples hang off.
func mountSystem(e *core.Server) *core.Group {
	// No group and no auth: a load balancer has to be able to ask.
	e.GET("/healthz", healthz)

	// A group is a path prefix plus middleware every route under it inherits,
	// and groups nest. The nested one carries the App too, so its routes take
	// plain handlers exactly like the server's.
	api := e.Group("/api")

	return api.Group("/v1")
}

func healthz(c core.IHTTPContext) error {
	// Report what this process can actually reach, not that it is running — a
	// handler that answers "ok" unconditionally proves only that the port is
	// open, which the load balancer already knew.
	return c.JSON(http.StatusOK, map[string]any{
		"service": c.ENV().Config().Service,
		"storage": c.Storage().Enabled(),
		"cache":   c.Cache().Enabled(),
		"db":      c.DB() != nil,
	})
}

// startServer is the HTTP-only starter: it blocks, drains in-flight requests on
// SIGTERM, and only then closes the App's pools — a pool closed while a request
// still holds it turns a clean deploy into a burst of 500s.
func startServer(e *core.Server, env core.IENV) {
	core.StartHTTPServer(e, env)
}

// startWithRunner is the same server in a process that also runs jobs, a
// scheduler or a subscriber. The ordering rule is the reason to switch: the
// scheduler must stop ticking *before* the drain starts, and Runner is where
// that sequence lives instead of in a hand-written shutdown nobody re-reads.
func startWithRunner(app *core.App, e *core.Server) error {
	return core.NewRunner(app,
		core.RunHTTP(e),
		// Keep the drain under the orchestrator's own grace period, or the
		// process is killed mid-drain and none of this ordering happens.
		core.WithDrainTimeout(20*time.Second),
	).Run()
}

Binding and validating in one call

One struct describes path, query and body; one Valid says what a good one looks like. The alternative — bind, then check by hand in the handler — is checking that can be forgotten, and the endpoint that forgets it is never the one under test.

go
package main

import (
	"net/http"
	"regexp"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/valid"
)

// The vocabulary the whole example service shares.
const (
	statusDraft     = "draft"
	statusPublished = "published"
)

var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)

// --- Example 2: binding and validating in one call --------------------------
//
// One struct describes the whole request — path, query and body — and one
// method says what a valid one looks like. BindWithValidate runs both.
//
// The alternative, Bind and then check by hand in the handler, is checking that
// can be forgotten; and the endpoint that forgets it is never the one anybody
// thought to test. Keeping the rules on the request type also means the same
// payload validates identically when a job or a consumer builds it.

// codeSlugTaken is a code this service raises itself. Registering the message
// next to the constant means using the code requires importing this file, so
// the message is guaranteed to exist by the time a violation renders it.
const codeSlugTaken = "SLUG_TAKEN"

func init() {
	valid.SetMessage(codeSlugTaken, "The {field} field is already used by another article")
}

// createArticleRequest binds JSON and form bodies with the same struct: the two
// content types differ in the tag, not in the handler or the rules.
//
// The fields are pointers so "absent" and "sent as empty" stay different
// questions. With a plain string a PATCH cannot say "clear this field", because
// a cleared value and an omitted one arrive identically.
type createArticleRequest struct {
	Title  *string  `json:"title" form:"title"`
	Slug   *string  `json:"slug" form:"slug"`
	Body   *string  `json:"body" form:"body"`
	Status *string  `json:"status" form:"status"`
	Tags   []string `json:"tags" form:"tags"`
}

// Valid implements core.IValidateContext. It receives the request's IContext,
// so a rule may reach the database — but only for facts about *this payload*
// (is the slug free?), never for business rules that depend on rows changing
// under it. Those belong in the service; see 03_errors.go.
func (r *createArticleRequest) Valid(ctx core.IContext) core.IError {
	v := valid.New(ctx)

	// Trim before the length rules, not after: " a " is a one-character title
	// and should fail Length, and the normalizer rewrites the bound value so the
	// handler below sees what was validated.
	v.Str("title", r.Title).Trim().Required().Length(3, 120)
	v.Str("slug", r.Slug).Trim().Lower().Required().Match(slugPattern)
	v.Str("body", r.Body).Required().Min(1)
	v.Str("status", r.Status).In(statusDraft, statusPublished)
	v.Arr("tags", r.Tags).Max(5)

	// Cross-field rules the typed builders cannot express: a condition that
	// reads two fields at once.
	v.When(deref(r.Status) == statusPublished, func(v *valid.Validator) {
		v.Must("body", "REQUIRED_WITH", deref(r.Body) != "")
	})

	return v.Error()
}

// updateArticleRequest mixes sources: the id comes from the path, the filter
// from the query string, the rest from the body. `json:"-"` on the path field
// stops a client from overriding it through the body — the two bind into the
// same struct, and the last writer would win.
type updateArticleRequest struct {
	ID     string  `param:"id" json:"-"`
	Notify bool    `query:"notify" json:"-"`
	Title  *string `json:"title"`
	Status *string `json:"status"`
}

func (r *updateArticleRequest) Valid(ctx core.IContext) core.IError {
	v := valid.New(ctx)
	v.Str("id", &r.ID).Required().UUID()
	v.Str("title", r.Title).Trim().Length(3, 120)
	v.Str("status", r.Status).In(statusDraft, statusPublished)

	return v.Error()
}

func mountArticleWrites(g *core.Group) {
	g.POST("/articles", createArticle)
	g.PUT("/articles/:id", updateArticle)
	g.POST("/articles/preview", previewArticle)
}

func createArticle(c core.IHTTPContext) error {
	req := &createArticleRequest{}
	if err := c.BindWithValidate(req); err != nil {
		// Return it, do not log it. The framework's error handler renders
		// {code, message, fields} with the right status and reports what
		// deserves reporting; logging here as well makes one incident look
		// like two in the search that finds it.
		return err
	}

	// A thin handler: bind, hand the validated request to the service, answer.
	// 201 and not 200 — the status is part of what the endpoint means.
	return c.JSON(http.StatusCreated, articleResponse{
		ID:     "0f7d0f5c-52b6-4c1b-9f5e-6f1c0f8f1a11",
		Title:  deref(req.Title),
		Status: firstNonEmpty(deref(req.Status), statusDraft),
	})
}

func updateArticle(c core.IHTTPContext) error {
	req := &updateArticleRequest{}
	if err := c.BindWithValidate(req); err != nil {
		return err
	}

	return c.JSON(http.StatusOK, articleResponse{
		ID:     req.ID,
		Title:  deref(req.Title),
		Status: firstNonEmpty(deref(req.Status), statusDraft),
	})
}

// previewArticle is what BindOnly is for: an endpoint that renders whatever it
// is given and has no notion of a valid article, so running the create rules
// here would reject drafts the editor is still writing.
//
// BindOnly is not "validation later" — it is "there is nothing to validate".
func previewArticle(c core.IHTTPContext) error {
	req := &createArticleRequest{}
	if err := c.BindOnly(req); err != nil {
		return err
	}

	return c.String(http.StatusOK, "# "+deref(req.Title)+"\n\n"+deref(req.Body))
}

// deref reads an optional field. Pointers cost this one helper and buy the
// difference between "not sent" and "sent empty" on every request type.
func deref(s *string) string {
	if s == nil {
		return ""
	}
	return *s
}

func firstNonEmpty(values ...string) string {
	for _, v := range values {
		if v != "" {
			return v
		}
	}
	return ""
}

Errors a client can act on

code is what a client writes an if against, which makes it as much of the API as the JSON schema — so the errors a service raises are declared once. Also: which failures need ctx.NewError, and why a row that belongs to someone else is a 404. See Service errors.

go
package main

import (
	"errors"
	"net/http"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/errmsgs"
)

// --- Example 3: errors a client can act on ----------------------------------
//
// Every layer returns core.IError. A handler returns it unchanged and the
// framework renders it:
//
//	HTTP/1.1 409 Conflict
//	{"code":"ARTICLE_LOCKED","message":"…","fields":{"locked_by":"u_42"}}
//
// `code` is the part a client writes an `if` against, which makes it as much of
// the API as the JSON schema. That is why the errors this service raises are
// declared once, in one place, instead of being spelled out at the call site
// where a typo is a silent behaviour change nothing catches.

var (
	// ErrArticleLocked is 409 rather than 400: nothing is wrong with what the
	// caller sent, the resource is simply in a state that refuses the change —
	// and the answer to a 409 is "retry later", not "fix your request".
	ErrArticleLocked = core.New(http.StatusConflict, "ARTICLE_LOCKED",
		"the article is being edited by someone else")

	// ErrQuotaReached carries the limit in Fields so the client can say what it
	// is, instead of hardcoding a number that changes with the price list.
	ErrQuotaReached = core.New(http.StatusForbidden, "ARTICLE_QUOTA_REACHED",
		"this plan does not allow more articles")
)

func mountArticleErrors(g *core.Group) {
	g.POST("/articles/:id/publish", publishArticle)
}

func publishArticle(c core.IHTTPContext) error {
	id := c.Param("id")

	if err := publish(c, id); err != nil {
		// errors.Is matches an *Error by code through any amount of wrapping,
		// so the handler can react to one specific failure without unwrapping
		// by hand or comparing strings.
		if errors.Is(err, ErrArticleLocked) {
			c.Log().Info("publish refused while locked", "article_id", id)
		}

		return err
	}

	return c.NoContent(http.StatusNoContent)
}

// publish is the service layer. It knows nothing about HTTP beyond the status
// its own errors already carry, which is what lets a job call it unchanged.
func publish(ctx core.IContext, id string) core.IError {
	found, err := loadArticle(ctx, id)
	if err != nil {
		// Wrap keeps the original status, code and fields and only adds where
		// the failure passed through — a 404 from the store is still a 404 at
		// the edge, and the message says which operation hit it.
		return core.Wrap(err, "publish article")
	}

	if found.LockedBy != "" {
		// The builders copy, so enriching a shared sentinel cannot corrupt it
		// for the next request. Never assign to one.
		return ErrArticleLocked.WithFields(map[string]any{"locked_by": found.LockedBy})
	}

	if err := countArticles(ctx); err != nil {
		// An infrastructure failure is the one case that needs ctx.NewError: it
		// reports to Sentry with this request's user, tags and breadcrumbs, and
		// answers the client with the generic message instead of the driver's.
		// Outside dev the cause never reaches the response.
		return ctx.NewError(err, errmsgs.DBError)
	}

	return nil
}

// lockedArticle is what the store returns — trimmed to what this file needs.
type lockedArticle struct {
	ID       string
	LockedBy string
}

// loadArticle stands in for the repository. Note what it returns for a missing
// row: NotFoundCustomError builds "ARTICLE_NOT_FOUND", a code specific enough
// for a client to branch on, from one call.
//
// A row that belongs to somebody else must answer 404 as well, never 403 — a
// 403 confirms the id exists, which is a fact the caller has no right to. Make
// ownership part of the WHERE clause and the two answers become identical for
// free, with no rule left for anyone to remember.
func loadArticle(_ core.IContext, id string) (*lockedArticle, core.IError) {
	switch id {
	case "0f7d0f5c-52b6-4c1b-9f5e-6f1c0f8f1a11":
		return &lockedArticle{ID: id}, nil
	case "11111111-1111-1111-1111-111111111111":
		return &lockedArticle{ID: id, LockedBy: "u_42"}, nil
	default:
		return nil, errmsgs.NotFoundCustomError("article")
	}
}

// countArticles stands in for a query that can fail for reasons the caller did
// nothing to cause.
func countArticles(ctx core.IContext) error {
	if ctx.DB() == nil {
		// Not an error worth reporting: this example runs without a database on
		// purpose. A real repository call would return the driver's error here,
		// and publish would hand it to ctx.NewError.
		return nil
	}

	return nil
}

// notFoundOrInternal is the shape most store lookups want at the edge: the
// caller's mistake stays theirs, everything else is reported as ours.
func notFoundOrInternal(ctx core.IContext, err error, resource string) core.IError {
	if errors.Is(err, errmsgs.NotFound) {
		return errmsgs.NotFoundCustomError(resource)
	}

	return ctx.NewError(err, errmsgs.InternalServerError)
}

Maintained by Passakon Puttasuwan & Dev Core Team.