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