server

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 12, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package server @notice Mounts a gqlgen handler on Fiber v3, correctly.

@dev "Correctly" is doing real work in that sentence. Every line of Mount exists because leaving it out fails silently: r.All rather than r.Post (or browser clients 405 on the CORS preflight, with nothing in the log), SetQueryCache (or every request re-parses and re-validates), extension.Introspection (or the playground's docs pane is blind).

This package cannot replace gqlgen codegen, and no package could. gqlgen generates code into your module: generated.NewExecutableSchema is a symbol only your own `go tool gqlgen generate` run produces, from your own schema.graphqls. So you still own gqlgen.yml, schema.graphqls, graph/resolver.go and the codegen step — see docs/gqlgen-contract.md, because getting it wrong produces runtime panics that `go build` does not catch.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CORS added in v0.4.0

func CORS(c CORSConfig) func(http.Handler) http.Handler

CORS @notice Cross-origin access as portable net/http middleware, for Config.HTTPMiddleware.

@dev Sets Vary: Origin on every response it touches, and that line is the whole reason to prefer this over four hand-written ones. The allowed origin is echoed from the request, so the response varies by a request header; without Vary, any shared cache — a CDN, a corporate proxy, the browser's own store — serves the first caller's Access-Control-Allow-Origin to the second, and the symptom is a CORS failure that reproduces for one user and no one else.

Methods are fixed at GET, POST and OPTIONS: those are the only ones luima's routes answer, and a consumer serving others is not serving them from here.

There is deliberately no Credentials knob. Access-Control-Allow-Credentials with a wildcard origin is the classic misconfiguration, and the case it serves — cookie-authenticated cross-origin requests — needs an auth layer luima does not ship. Leaving the field out makes the combination unrepresentable, which is stronger than validating it. Use rs/cors in HTTPMiddleware if you need it; that needs no Fiber import either.

One wart, documented rather than fixed. HTTPMiddleware runs inside the adaptor and Fiber's timeout middleware wraps outside it, so a request that hits RequestTimeout is answered by that middleware and its 408 carries no CORS headers — the browser then reports a CORS error rather than a timeout. Wrapping the timeout path to fix it would move CORS outside the adaptor and cost the portability that is the point of this being net/http middleware.

@param c the origins and headers to allow @return func(http.Handler) http.Handler middleware, outermost-first in HTTPMiddleware

func Mount

func Mount(r fiber.Router, cfg Config)

Mount @notice Registers the same routes on a router you already have — an existing app, or a group.

@dev cfg.Fiber is ignored here: the app already exists, so its configuration is not this function's to set.

@param r a *fiber.App or the result of app.Group(prefix) @param cfg the server configuration; only Schema is required

func New

func New(cfg Config) *fiber.App

New @notice Builds a *fiber.App with the GraphQL endpoint and playground mounted.

@dev The only place in luima that owns a fiber.Config, and therefore the only place that can bound the transport. Passing cfg.Fiber through untouched is what made a zero Config the pathological one — see ReadTimeout.

@param cfg the server configuration; only Schema is required @return *fiber.App an app ready for Listen, with cfg.Fiber applied

func RateLimit added in v0.4.0

func RateLimit(n int, per time.Duration, key func(*http.Request) string) func(http.Handler) http.Handler

RateLimit @notice Fixed-window limiter as portable net/http middleware, for Config.HTTPMiddleware. Over the limit is 429 with Retry-After.

@dev Fixed window, not sliding, and the difference is a real ceiling rather than a rounding error: a caller who spends n at the end of one window and n at the start of the next lands 2n requests inside one window's width. That is the price of holding one integer per key instead of a timestamp ring, and for the thing this exists to stop — an unbounded { users { id } } in a loop — a factor of two does not change the answer. Reach for a token bucket when it does.

The counters are dropped wholesale at each window rollover, and that sweep is the memory bound: the map holds one entry per distinct key seen in the current window and can never accumulate across windows. A per-key map with no eviction is an unbounded allocation driven by an unauthenticated header — a memory exhaustion bug inside the feature that exists to prevent one. There is no goroutine; the rollover is checked on the request path.

This is per process. Two replicas behind a load balancer enforce 2n; a shared store is the upgrade, and it belongs in the consumer's middleware rather than here.

luima already documents the hole this plugs: crud.List notes that an unbounded list field is reachable by anyone who can send { users { id } }, and that ComplexityLimit cannot see it because row count is not an input to the complexity calculation.

@param n requests allowed per window @param per the window @param key what to bucket on; nil means r.RemoteAddr. Read a header here when you are behind a proxy — RemoteAddr is then the proxy's address, one bucket for every caller, and a limiter that limits nothing. Trust that header only from a proxy you control, or a caller sets it. @return func(http.Handler) http.Handler middleware, outermost-first in HTTPMiddleware

func Run added in v0.4.0

func Run(ctx context.Context, addr string, cfg Config) error

Run @notice Builds the server, listens on addr, and blocks until ctx is done — then drains in-flight requests and returns. The whole server in one call, naming no Fiber type.

@dev Fiber's own graceful path (ListenConfig.GracefulContext) is not used, for two reasons. It throws the shutdown error away — gracefulShutdown hands it to the OnPostShutdown hook and returns (fiber/listen.go:606-623) while Listen returns nil regardless, so a server that force-closed live connections because the drain timed out would be indistinguishable from a clean exit and the process would exit 0. And it starts its watcher before the listener exists (:252-256), which is the same race this function has to close by hand below.

The listener is created here rather than by Listen so that a bind failure is returned synchronously — and so that ln.Close below has something to close. One consequence: net.Listen with "tcp" is dual-stack, where Fiber's ListenConfig defaults to tcp4 (fiber/listen.go:167). That is deliberate. Run is shaped like net/http.ListenAndServe and should bind like it.

The startup banner is suppressed. Fiber prints it to stdout by default, and a library must not choose the caller's logging any more than db.Connect may call log.Fatal. Log your own line before calling Run.

@param ctx cancel it to begin the drain; SIGTERM via signal.NotifyContext is the usual source @param addr the listen address, e.g. ":8080" @param cfg the server configuration; only Schema is required @return error a bind failure, a shutdown that exceeded the drain window, or nil

Types

type CORSConfig added in v0.4.0

type CORSConfig struct {
	// Origins @notice Exact origins, e.g. "https://app.example.com". A single "*" allows any.
	//
	// @dev Matched exactly and echoed back one at a time, because the header takes one origin or
	// the literal "*" and nothing else — a comma-separated list is not a value browsers parse.
	Origins []string

	// Headers @notice Request headers to allow, added to Content-Type and Authorization.
	Headers []string

	// MaxAge @notice How long a browser may cache the preflight. Default 10m. Negative sends 0,
	// which tells the browser not to cache it at all.
	MaxAge time.Duration
}

CORSConfig @notice Which browser origins may read the GraphQL response.

type Config

type Config struct {
	// Schema @notice Your generated executable schema, e.g.
	//   generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{DB: db}})
	Schema graphql.ExecutableSchema

	// Endpoint @notice The GraphQL path. Default "/graphql".
	Endpoint string

	// Playground @notice The GraphiQL path. Default "/".
	//
	// @dev Fiber matches it exactly, where net/http's Handle("/", ...) was a prefix match — an
	// unknown path is a plain 404 here.
	Playground string

	// DisablePlayground @notice Turns GraphiQL off entirely. Do this in production.
	//
	// @dev A separate bool rather than Playground == "": the zero Config must be the good
	// configuration, so "" has to mean "unset", which leaves no spelling for "off".
	DisablePlayground bool

	// PlaygroundTitle @notice The browser tab title. Default "graphql".
	PlaygroundTitle string

	// DisableIntrospection @notice Turns schema introspection off. Do this in production.
	//
	// @dev Zero value is "on", as with the playground — introspection is what makes the docs
	// pane work, so the zero Config has to keep it.
	//
	// This is defence in depth and a smaller attack surface; it is not confidentiality, and it
	// does not hide your field names. gqlparser's validator appends "Did you mean ...?" to
	// validation errors (gqlparser/validator/core/helpers.go), and PresentError passes
	// validation errors through by design, so a caller who guesses "nam" still learns there is
	// a "name". Do not treat it as a substitute for authorization.
	DisableIntrospection bool

	// RequestTimeout @notice Deadline for the whole request, propagated into the resolver
	// context. Default 15s. Negative disables it.
	//
	// @dev Zero means "unset", as with QueryCache. This is the only bound a query gets:
	// go-pg sets no ReadTimeout or WriteTimeout default, pg.ParseURL rejects statement_timeout
	// in the DSN, and nothing else in the stack imposes one. What makes a context deadline
	// sufficient is that go-pg honours it three ways — the pool wait selects on ctx.Done()
	// (internal/pool/pool.go waitTurn), the socket deadline is ctx.Deadline() verbatim when no
	// ReadTimeout is set (internal/pool/conn.go deadline), and ctx.Done() triggers a real
	// Postgres CancelRequest against the running backend (base.go withConn).
	//
	// 15s is deliberately under go-pg's own 30s PoolTimeout default (options.go init): below
	// it, a saturated pool sheds deterministic 408s; at or above it, the failure mode is a coin
	// flip between ErrPoolTimeout and the timeout. That CancelRequest is best-effort — it dials
	// a second connection and only logs a failure — so a server-authoritative bound still wants
	// statement_timeout via pg.Options.OnConnect. See SECURITY.md.
	RequestTimeout time.Duration

	// ReadTimeout @notice How long a client may take to send a whole request. Default 10s.
	// Negative disables it.
	//
	// @dev Zero means "unset", as with RequestTimeout — and here the convention is load-bearing
	// rather than tidy. Fiber fills in its own defaults for BodyLimit and Concurrency but assigns
	// this one straight through (app.server.ReadTimeout = app.config.ReadTimeout), and fasthttp
	// reads zero as no deadline at all. Without this field a zero Config lets one client hold a
	// connection slot forever by dribbling a request body a byte at a time, against a default
	// Concurrency of 262144 — and Shutdown cannot reclaim that slot either, because it does not
	// close keep-alive connections.
	//
	// There is deliberately no IdleTimeout field: fasthttp's idleTimeout() returns ReadTimeout
	// whenever IdleTimeout is zero, so this bounds the keep-alive wait too and a third field
	// would only let the two be set apart, which nothing has asked for.
	//
	// Set Fiber.ReadTimeout instead and that one wins — luima only ever fills a zero.
	// TestTimeoutPrecedence pins that.
	//
	// Applied by New, not by Mount. Mount is handed a router that already exists, so its
	// timeouts are not this function's to set, for the same reason cfg.Fiber is ignored there.
	ReadTimeout time.Duration

	// WriteTimeout @notice How long the server may take to write a response. Default 30s.
	// Negative disables it.
	//
	// @dev Cannot truncate a slow resolver, and not merely because RequestTimeout's 15s default
	// fires first. fasthttp sets the write deadline *after* the handler returns, immediately
	// before writing the response (serveConn's SetWriteDeadline, server.go) — so the two never
	// overlap at all. That is where it differs from net/http, whose WriteTimeout starts when the
	// request is read and does cover handler execution; a reader porting that intuition across
	// will raise this field to make room for a long query and change nothing.
	//
	// What it bounds is a slow *reader*: a client that sends a valid query and then stops
	// draining the socket, which no resolver deadline can see because by then the resolver has
	// already returned. Raise it for a large response over a slow link. Raise RequestTimeout for
	// a slow query.
	//
	// Fiber.WriteTimeout wins over it, and New applies it, as with ReadTimeout.
	WriteTimeout time.Duration

	// QueryCache @notice The parsed-query LRU size. Default 1000. Negative disables it.
	//
	// @dev Zero means "unset", not "off", because a zero-valued Config must not be the
	// pathological one: handler.New starts at graphql.NoCache, and with no cache every
	// request re-parses and re-validates the whole query document. There is no good reason to
	// pass a negative here.
	QueryCache int

	// ComplexityLimit @notice Caps query complexity. Default 1000. Negative disables it.
	//
	// @dev Zero means "unset", as with QueryCache.
	ComplexityLimit int

	// MaxDepth @notice Caps operation nesting depth. Default 15. Negative disables it.
	//
	// @dev Zero means "unset", as with QueryCache and ComplexityLimit. ComplexityLimit does not
	// cover this: a 40-level query costs about 40 against a limit of 1000, so a cyclic schema —
	// User.friends: [User!]! — passes it and multiplies into a resolver call per node per level.
	// gqlgen ships no depth limiter of its own.
	//
	// The walk resolves fragment spreads out of doc.Fragments. It has to: a spread node carries
	// no SelectionSet, so a walker that visits only Doc.Operations reads every fragment as a
	// leaf and a 40-deep document passes at depth 1 — measured, and a two-line change to the
	// attacking query. An inline fragment is a type condition and does not count as a level.
	//
	// 15 is chosen against the deepest document a default install serves, which is the
	// playground's own introspection query: it measures 13, so the default clears it by two.
	// TestMaxDepthAdmitsIntrospection pins that. If your schema legitimately nests deeper than
	// this, raise it — the number is a starting point, not a finding about your schema.
	MaxDepth int

	// ErrorPresenter @notice The error contract. Default [luimaerr.PresentError].
	ErrorPresenter graphql.ErrorPresenterFunc

	// HTTPMiddleware @notice net/http middleware wrapped around the gqlgen handler, outermost
	// first.
	//
	// @dev The one layer that has all three of: the real *http.Request with its cookies
	// parsed, an http.ResponseWriter whose headers survive back through the adaptor —
	// Set-Cookie included, because fasthttp's Header.Add routes it into the dedicated cookie
	// list rather than the generic map — and r.WithContext, which every resolver sees as its
	// own ctx, typed. Fiber middleware on the mounted group has none of the net/http shapes,
	// so anything written as func(http.Handler) http.Handler — request logging, tracing,
	// tenancy, rate limiting, a session layer — mounts here unchanged and stays portable to
	// chi, echo or plain net/http.
	//
	// The chain runs inside withFiberContext, so each middleware already sees the request
	// context that the resolvers will see: the RequestTimeout deadline is set, c.SetContext
	// values are attached, and whatever the middleware adds rides the same context down.
	// TestHTTPMiddleware pins all three properties.
	HTTPMiddleware []func(http.Handler) http.Handler

	// Configure @notice Runs against the built gqlgen server immediately before it is mounted.
	//
	// @dev One escape hatch rather than a field per knob: srv.Use, AroundOperations,
	// SetRecoverFunc, SetParserTokenLimit and SetDisableSuggestion are all reachable through
	// it, and a new gqlgen extension point needs no change here. Without it, srv is a local
	// that never escapes Mount, and a consumer wanting any of those has to abandon Mount —
	// which is the whole library.
	//
	// It runs after every default setter — query cache, introspection, complexity, depth,
	// presenter — so it can override them rather than be silently overridden by them.
	//
	// AddTransport is the exception, and it is the one that was broken: luima's own transports
	// are registered after this runs, so a transport registered here outranks them. Before
	// 0.3.0 they were registered first, and since gqlgen selects the first transport whose
	// Supports matches, a transport added here could never be selected — see the comment on
	// the AddTransport calls in Mount.
	Configure func(*handler.Server)

	// Fiber @notice Passed through to fiber.New, with ReadTimeout and WriteTimeout filled in
	// where this leaves them zero. Ignored by Mount.
	//
	// @dev A field set here wins over the promoted one: this is a whole fiber.Config the caller
	// built deliberately, so a non-zero value in it is an explicit answer and luima does not
	// overrule it. See ReadTimeout for why a zero one cannot simply be passed through.
	//
	// Fiber's BodyLimit defaults to 4 MB where net/http had none — irrelevant to
	// queries, and the thing to raise here the day you add the multipart transport. Read the
	// CSRF cost before you add that transport: multipart/form-data is a "simple" request, so no
	// preflight protects it and a cross-site HTML form can execute a mutation with the caller's
	// cookies attached (measured — gotcha #37).
	//
	// Setting Fiber.ErrorHandler does nothing useful for resolver errors: the adaptor always
	// returns nil, and so does the timeout middleware on both its normal and its timed-out
	// path, so no resolver error ever becomes a Fiber error.
	//
	// ErrorPresenter is the only contract for errors a *resolver* returns. Transport-level
	// failures — a malformed JSON body, an unsupported content type — are written by gqlgen's
	// transport before any executor exists, so they never reach the presenter and are not
	// redacted; a malformed body is echoed back in the message. Nothing sensitive of the
	// server's is in that path, but the claim that everything goes through the presenter is
	// not one to build on.
	Fiber fiber.Config

	// Health @notice Liveness path, e.g. "/healthz". Empty disables it.
	//
	// @dev Registered by Mount alongside the GraphQL routes, so it works on an app, on a group,
	// and on a router you built yourself. Without it, writing the smallest possible route is the
	// last thing that forces a consumer to name a Fiber type.
	//
	// It is a separate route, so HTTPMiddleware does not wrap it — deliberately. A rate limiter
	// that 429s the liveness probe takes the process out of the load balancer for being healthy
	// and busy, which is the opposite of what the probe is for.
	Health string

	// HealthCheck @notice What Health calls. Nil means the path answers 200 whenever the process
	// is up. A non-nil error is 503, and the error text is not sent to the client.
	//
	// @dev A function rather than a *pg.DB field, for three reasons that all point the same way:
	// this package does not import go-pg today and this would be the only reason to start, luima
	// would otherwise have to answer who closes the pool, and a real deployment checks more than
	// one thing. go-pg's Ping already has this exact signature (go-pg/base.go:508, promoted onto
	// *pg.DB), so the common case is:
	//
	//	HealthCheck: db.Ping
	//
	// The context passed in carries a 2s deadline of its own, and that is the point of the field
	// rather than a detail of it. A liveness probe against a wedged database must answer 503; a
	// probe that inherits the request timeout and hangs for 15s instead reads to every load
	// balancer as a slow server rather than a broken one, and that is the difference between
	// being rotated out and being left in.
	//
	// The check runs on its own goroutine, so a check that ignores the context it is handed still
	// answers 503 at the deadline rather than hanging the probe. That goroutine outlives the
	// response — there is no way to stop a function that does not watch its context — so a check
	// that blocks forever leaks one goroutine per probe. Watch the context.
	HealthCheck func(context.Context) error
}

Config @notice Assembles the server.

@dev Only Schema is required; every zero value has a working default, so Config{Schema: ...} is the good configuration rather than the pathological one.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL