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 ¶
- func CORS(c CORSConfig) func(http.Handler) http.Handler
- func Mount(r fiber.Router, cfg Config)
- func New(cfg Config) *fiber.App
- func RateLimit(n int, per time.Duration, key func(*http.Request) string) func(http.Handler) http.Handler
- func Run(ctx context.Context, addr string, cfg Config) error
- type CORSConfig
- type Config
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 ¶
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 ¶
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
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.