Chapter 7

Middleware

One handler type. Middleware is a handler that decides whether the rest of the chain runs. Registration order, guards, and what the builder installs.

Iris has exactly one handler type, func(ctx iris.Context), and middleware is nothing more than a handler that decides whether the rest of the chain runs. That single decision (call ctx.Next() or stop) powers authentication guards, request logging, panic recovery and everything in between. This chapter explains the handler chain, the registration methods and their exact scopes, how to write your own middleware, and the ordering rules that determine what runs when. The catalog of ready-made middleware packages lives in Chapter 12; this chapter is about the machinery they all share.

Background

Every non-trivial web service repeats certain work on every request: write an access log line, check credentials, attach an ID for tracing, reject oversized bodies, recover from crashes. These are cross-cutting concerns: work that belongs to no single endpoint but applies across all of them. Copying that work into every handler multiplies it by the number of endpoints and guarantees the copies drift apart; the login check that was fixed in nine handlers and forgotten in the tenth is a classic security hole.

Middleware is the pipeline answer to that problem. Instead of each handler doing everything, the framework assembles a chain of small functions around every request; each one does its piece and passes control onward. Think of the layers of an onion: the request travels inward through logging, authentication and limits before reaching the handler at the core, and the response travels back out through the same layers in reverse, which is why a timing middleware can note the clock on the way in and compute the duration on the way out. You write each concern once, register it once, and every route beneath it is covered.

The chain has a second power: any layer can stop the request. An authentication guard that finds no valid token writes a 401 and simply does not pass control on: the handler never runs, and neither does any code that assumed a valid user. Ordering follows from the same logic: the cheap protective layers (rate limits, body caps) belong outside the expensive ones.

One distinction in this chapter deserves a plain definition up front. Routing is the step where the framework matches a request’s method and path against the registered routes to pick a handler; the component doing the matching is called the mux (short for multiplexer). “Before routing” middleware (UseRouter) runs before that lookup, so it sees every request, including those that match nothing and will end as a 404. “Per route” middleware (Use and friends) is baked into each matched route’s chain, so it runs only when routing found a handler. Practically: access logs and request IDs belong before routing, because a request that matches nothing still deserves a log line; a guard that loads the current user belongs on the routes that need a user.

Handlers and the Chain

Every request runs through a chain of handlers assembled for its route. Each handler receives the same iris.Context and controls the chain with three moves:

Go
func example(ctx iris.Context) {
    // 1. Work before the rest of the chain.

    ctx.Next() // 2. Run the remaining handlers.

    // 3. Work after the chain below has completed
    //    (post-processing: timing, logging, metrics).
}

Not calling ctx.Next() ends the chain quietly: the following handlers simply never run. ctx.StopExecution() goes further: it marks the context as stopped, so any later Next call anywhere in the chain becomes a no-op. ctx.IsStopped() reports that state. The StopWith* shortcuts combine stopping with a response:

  • ctx.StopWithStatus(code): status code only.
  • ctx.StopWithText(code, text): plain text body.
  • ctx.StopWithJSON(code, value): JSON body.
  • ctx.StopWithError(code, err): records the error and writes its message, or only the status when the error is private.
  • ctx.StopWithPlainError(code, err): records the error without writing a body, leaving rendering to an error handler such as the rest/errors pipeline (Chapter 9).

A “middleware” is a handler registered before others: by position in the route line, or through one of the registration methods below. There is no separate middleware interface to implement.

The Registration Family

Where you register a handler determines which requests it sees. The methods, with their exact scopes as implemented in core/router/router_builder.go:

CallRuns for
app.UseRouter(h)Every request, before routing, including 404/405
r.UseRouter(h) (child)Every request under the child’s path prefix or subdomain, before routing
app.Use(h) (root)Every matched route, plus the 404 and 405 chains
r.Use(h) (child)Matched routes of that child and its children only
app.UseRelevant(h)Matched routes only, never 404/405
app.UseOnce(h)Like root Use, but insert-or-replace
app.UseRelevantOnce(h)Like UseRelevant, but insert-or-replace

One caveat on the 405 rows: the no-method chain only runs when you set Router.FireMethodNotAllowed, which defaults to false. Until you do, a wrong-method request goes to the 404 chain instead.

UseRouter: before routing

UseRouter handlers run before the mux looks at the path. They fire for every request (matched, not found, or method not allowed), which makes them the right place for access logging, request IDs, CORS, compression and rate limiting. Because they run first, they also hold veto power: a UseRouter handler that writes a response without calling ctx.Next() prevents routing from ever happening.

CORS (Cross-Origin Resource Sharing) shows why “every request” matters. It is the browser mechanism that decides whether a page on one site may call your API on another; before certain cross-site requests the browser sends an automatic OPTIONS “preflight” request asking for permission. A preflight often matches no registered route, so a CORS handler attached per route would never see it. Registered with UseRouter, it answers the preflight before routing can 404 it.

Called on a child router, UseRouter still registers at the top level but wraps the handler in a prefix (and subdomain) check, so it fires only for requests under that child’s path, still before routing.

Use: root versus child

The root-versus-child difference in Use is the one that surprises people. On the root application, Use handlers join every route’s chain and are prepended to the no-route (404) and no-method (405) chains, so they observe requests that match nothing at all. On a child router, Use delegates to UseRelevant: the handlers join only the routes registered under that child.

Go
package main

import (
    "github.com/kataras/iris/v14"
)

func main() {
    app := iris.New()

    app.UseRouter(func(ctx iris.Context) {
        // Before routing: fires for "/", "/admin/panel"
        // and any 404 or 405 request.
        ctx.Next()
    })

    app.Use(func(ctx iris.Context) {
        // Root Use: all matched routes, plus the 404/405 chains.
        ctx.Next()
    })

    admin := app.Router("/admin")
    admin.Use(func(ctx iris.Context) {
        // Child Use: only routes registered under /admin.
        ctx.Next()
    })
    admin.Get("/panel", func(ctx iris.Context) {
        ctx.Text("panel")
    })

    app.Get("/", func(ctx iris.Context) {
        ctx.Text("index")
    })

    app.Listen(":8080")
}

When you want root middleware that skips 404s, say so explicitly with app.UseRelevant(h). Note that UseRelevant and UseRelevantOnce are methods of the application; the iris.Router interface that child groups and Routing(r iris.Router) controllers receive exposes Use, UseOnce and UseRouter. A child’s Use is already matched-only.

UseOnce and UseRelevantOnce: upsert

UseOnce and UseRelevantOnce insert a handler or, if a handler with the same identity is already registered, replace it in place. Identity is the function’s resolved name, so re-running setup code does not stack duplicates: the second registration replaces the first. The same rule means two closures produced by the same factory function share a name and therefore replace each other; register factory-produced middleware with plain Use when you need several instances.

WrapRouter: below everything

For integrations that need raw net/http semantics, app.WrapRouter wraps the entire router:

Go
app.WrapRouter(func(w http.ResponseWriter, r *http.Request,
    router http.HandlerFunc) {
    // Runs before the Iris context even exists.
    router(w, r) // call the wrapped router (or don't).
})

Wrappers registered with WrapRouter execute last-registered-first; app.AddRouterWrapper is the first-registered-first variant. Prefer UseRouter unless a third-party package hands you an http.Handler wrapper you cannot avoid.

Guards and Per-Route Middleware

Handlers listed before the final one in a route registration act as middleware for that route alone:

Go
app.Get("/reports", requireAuth, reportsHandler)

In controller-style APIs this becomes the guard idiom: guards are methods on the API struct, so they see the same injected dependencies as the handlers. A group guard registered with r.Use protects every route of the controller; a positional guard between path and handler protects individual routes. From the Bookshelf application of the framework’s TUTORIAL.md:

Go
// BooksAPI serves /shelves/{shelf_id:uuid}/books.
func (api *BooksAPI) Routing(r iris.Router) {
    // Group guard: every book route needs an existing shelf.
    r.Use(api.requireShelf)

    r.Get("/", api.list)
    // Positional guard: writes additionally need an unlocked shelf.
    r.Post("/", api.requireUnlocked, api.create)
    r.Put("/{id:uuid}", api.requireUnlocked, api.update)
    r.Delete("/{id:uuid}", api.requireUnlocked, api.delete)
}

// requireUnlocked rejects writes against a locked shelf. The shelf was
// loaded and cached by requireShelf, the group guard above.
func (api *BooksAPI) requireUnlocked(ctx iris.Context) {
    shelf, _ := ctx.ValueAs[library.Shelf](shelfContextKey)
    if shelf.Locked {
        errors.PermissionDenied.Detail(ctx, "shelf is locked",
            "unlock the shelf before modifying its books")
        return // the errors senders stop the request.
    }
    ctx.Next()
}

The contract is plain: call ctx.Next() to continue, or write a response (the rest/errors senders stop the request for you) to end it. The full controller, including the requireShelf guard that loads and caches the shelf, is developed in Chapter 4.

Writing Custom Middleware

A timing middleware

Go closures make middleware configuration natural. A closure is a function value that keeps access to the variables of the scope where it was created, so a returned handler can still read the arguments of the function that built it, long after that function returned.

Configurable middleware is a factory function returning a handler; the closure captures the configuration:

Go
package main

import (
    "time"

    "github.com/kataras/iris/v14"
)

// SlowRequestLogger warns about requests slower than "threshold".
func SlowRequestLogger(threshold time.Duration) iris.Handler {
    return func(ctx iris.Context) {
        start := time.Now()
        ctx.Next()

        if d := time.Since(start); d > threshold {
            ctx.Application().Logger().Warnf(
                "slow request: %s %s took %s",
                ctx.Method(), ctx.Path(), d)
        }
    }
}

func main() {
    app := iris.New()
    app.UseRouter(SlowRequestLogger(500 * time.Millisecond))

    app.Get("/", func(ctx iris.Context) {
        ctx.Text("ok")
    })

    app.Listen(":8080")
}

One caveat for post-processing: by the time ctx.Next() returns, the response body has usually been written, and headers cannot change after the first write. Code after Next should log, record metrics or update state. Set response headers before calling Next.

Request IDs with middleware/requestid

Correlating a response with its server-side logs is the classic middleware job, and Iris ships it as middleware/requestid:

Go
package main

import (
    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/requestid"
)

func main() {
    app := iris.New()

    // Assign an ID to every request, even 404s.
    app.UseRouter(requestid.New())

    app.Get("/", func(ctx iris.Context) {
        ctx.Text("request id: " + requestid.Get(ctx))
    })

    app.Listen(":8080")
}

The default generator reuses a client-supplied X-Request-Id header or generates a UUID, sets the response header and stores the ID on the context (ctx.ID() returns it, requestid.Get(ctx) as a string).

Accepting the client’s ID is convenient for tracing a call through several services, but it also means the value lands in your logs exactly as the client wrote it. Log injection is the abuse of that trust: input crafted with newlines or control characters to forge or corrupt log entries. When clients must not be able to choose or forge IDs, pass the server-side generator:

Go
app.UseRouter(requestid.New(requestid.ServerOnlyGenerator))

Conditional Middleware

iris.NewConditionalHandler combines a filter with one or more handlers into a single registerable handler. The filter is an iris.Filter, func(iris.Context) bool. When it returns true, the given handlers are spliced into the current request’s chain; when it returns false, the chain simply continues without them. The wrapped handlers must behave like middleware and call ctx.Next().

Go
package main

import (
    "github.com/kataras/iris/v14"
)

func auditLog(ctx iris.Context) {
    ctx.Application().Logger().Infof("audit: %s %s",
        ctx.Method(), ctx.Path())
    ctx.Next()
}

func main() {
    app := iris.New()

    // Audit only mutating requests.
    app.Use(iris.NewConditionalHandler(func(ctx iris.Context) bool {
        return ctx.Method() != iris.MethodGet
    }, auditLog))

    app.Get("/items", func(ctx iris.Context) {
        ctx.Text("read")
    })
    app.Post("/items", func(ctx iris.Context) {
        ctx.Text("write")
    })

    app.Listen(":8080")
}

For coarser conditions you rarely need it: prefer attaching middleware to the child router or subdomain whose requests it concerns, and let the routing structure do the filtering.

Error-Returning Middleware

Middleware that performs I/O (token validation, database lookups) reads better when it can return an error instead of hand-writing a response. errors.FuncMiddleware from rest/errors converts a func(ctx iris.Context) error into a standard handler: a nil return calls ctx.Next(), a non-nil return flows through the central error pipeline (mapping, logging, JSON rendering) and stops the request.

Go
package main

import (
    "fmt"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/rest/errors"
)

// Accounts is the store the guard consults; in production it is a
// database.
type Accounts struct{ active map[string]bool }

func (a *Accounts) Active(ctx iris.Context, id string) (bool, error) {
    return a.active[id], nil
}

var accounts = &Accounts{active: map[string]bool{"acme": true}}

// requireActiveSubscription returns an error instead of writing a
// response; FuncMiddleware below turns it into an ordinary handler.
func requireActiveSubscription(ctx iris.Context) error {
    account := ctx.Params().GetString("account")

    active, err := accounts.Active(ctx, account)
    if err != nil {
        return err // the store is down: the central map makes it a 500.
    }

    if !active {
        return errors.FailedPrecondition.Wrap(
            fmt.Errorf("account %q has no active subscription", account))
    }

    return nil // continue the chain.
}

func main() {
    app := iris.New()

    data := app.Router("/{account:string}",
        errors.FuncMiddleware(requireActiveSubscription))
    data.Get("/data", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"data": "value"})
    })

    app.Listen(":8080")
}

The check is a business rule, which is what this shape is for: it does I/O, it can fail two ways, and only your code knows it. Authentication is not in that class. A shared secret presented by a machine caller is apikey.New from middleware/apikey, covered in Chapter 12, which reads the header, compares in constant time, renders the 401 and names the caller for you. Reading X-API-Key yourself in a middleware like the one above is the version to stop writing.

errors.FuncHandler is the terminal-handler counterpart (no Next on success), and errors.NewRouter wraps a whole router so every route and middleware registration accepts error-returning functions; see Chapter 4 for the wrapper and Chapter 9 for the error map that gives returned errors their status codes.

What the Builder Already Registers

Applications assembled with iris.NewBuilder come with a middleware baseline, installed once when the chain reaches RouterMiddlewares/Middlewares. Do not register these again:

  • The options handler for your Errors(...) options, via UseRouter, so every sender and HandleError call uses your map, logger and renderer.
  • errors.Handler, via UseRouter after compression, and as the 404 and 405 handler when the application is built. A bare failure status, an unknown path and (with FireMethodNotAllowed) a wrong method render as consistent JSON. To answer 404 or 405 yourself, register HandleNoRoute/HandleNoMethod from a Configurator passed to NewBuilder; the builder fills the two slots only when they are empty.
  • Panic recovery: recovery.WithErrorHandler(errors.Internal.LogErr) followed by recovery.Handler, skipped if a handler named iris.recovery or iris.errors.recover is already present.
  • The path-parameter type error handler: a malformed {id:uuid} renders 400 INVALID_ARGUMENT instead of a bare 404.
  • The context error handler for failed writes such as ctx.JSON errors.
  • A Server: Iris response header, plus X-Developer when configured.
  • compress.Safe when Compression(true) was selected, skipped if a handler named iris.compression exists.
  • The rate-limit exceed handler, bound to the JSON error renderer.

Your own additions slot into the same two steps: RouterMiddlewares(...) registers through UseRouter and Middlewares(...) through Use, both accepting dependency-injected factories as well as plain handlers; details in Chapter 3.

A panic is Go’s runtime failure mode: a nil pointer dereference, an out-of-range index or an explicit panic() call abruptly unwinds the goroutine (the lightweight thread) serving the request. Left unrecovered, the client gets a dropped connection with no response and the failure surfaces only as a stack trace in the server log. Recovery middleware wraps the chain in Go’s recover() mechanism, so one broken code path yields a logged error and a clean 500 instead.

With iris.New() none of this exists, and recovery is the one piece you should always add yourself:

Go
package main

import (
    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/recovery"
)

func main() {
    app := iris.New()
    app.UseRouter(recovery.Handler)

    app.Get("/", func(ctx iris.Context) {
        panic("boom") // recovered: logged, 500 returned.
    })

    app.Listen(":8080")
}

To customize what a recovered panic returns, register a per-request error handler before recovery.Handler:

Go
app.UseRouter(recovery.WithErrorHandler(
    func(ctx iris.Context, err error) {
        // Logs the panic and answers the canonical INTERNAL body; the
        // panic value itself never reaches the client.
        errors.Internal.LogErr(ctx, err)
    }), recovery.Handler)

Ordering Rules

Three rules govern execution order, all verified by the router’s own test suite (core/router/router_handlers_order_test.go):

1. Layers run outermost-first. For a matched route:

Middleware order and scope Router wrappers run first, then the one global UseRouter chain, then the mux, which either branches to the 404 and 405 chains or enters the matched route's frozen chain; code written after ctx.Next() runs on the way back out, innermost first. request WHAT IT SEES WrapRouter / AddRouterWrapper net/http level, before a Context exists every request, including static files. WrapRouter: last registered runs first. UseRouter handlers one global list; a child router's entries are the same list wrapped in a path test every request, before any route match: 404 and 405 included mux match subdomain, method, path 404 and 405 chains root app.Use handlers, then HandleNoRoute or HandleNoMethod. 405 runs only with Router. FireMethodNotAllowed: true route found FROZEN INTO THE ROUTE WHEN IT WAS REGISTERED macro evaluator typed path parameters, handler 0 that route root app.Use handlers app.Use outside the builder all routes, and 404/405 child r.Use / UseRelevant handlers r.Use delegates to UseRelevant matched routes of that child positional handlers app.Get(path, guard, handler), left to right that route main handler that route code after ctx.Next() runs on the way out, innermost first Only root app.Use reaches the 404 and 405 chains. A child router's Use delegates to UseRelevant, so it never does. Everything below the mux is frozen per route; UseRouter and the wrappers belong to the router.
Router wrappers run first, then the one global UseRouter chain, then the mux, which either branches to the 404 and 405 chains or enters the matched route's frozen chain; code written after ctx.Next() runs on the way back out, innermost first.

2. Within a layer, registration order wins. Two Use calls run in the order they were made; handlers listed in one call run left to right. Code placed after ctx.Next() runs in reverse order on the way out, like deferred calls: the first middleware’s post-processing executes last, so a timing middleware registered first measures everything beneath it.

3. A route’s chain is assembled at registration time. When Get, Post or Handle runs, the router snapshots the Use/UseRelevant handlers registered so far and bakes them into the route. Middleware registered afterwards does not reach earlier routes, so register middleware first, routes second. The builder’s compiler-enforced step order (Middlewares before API) exists precisely to make this mistake impossible; with iris.New() the discipline is yours.

UseRouter and WrapRouter are the exception to rule 3: they belong to the router itself rather than to individual routes, and take effect for all requests once the application is built.

Summary

  • A middleware is an ordinary iris.Handler that calls ctx.Next() to continue or stops the chain: quietly by returning, or firmly with ctx.StopExecution() and the StopWith* shortcuts.
  • Scope is chosen by registration method: UseRouter runs before routing for every request; root Use covers matched routes plus the 404/405 chains; child Use (and UseRelevant) covers matched routes only; UseOnce/UseRelevantOnce insert-or-replace by handler name.
  • Positional handlers guard single routes; guard methods on API structs share the controller’s injected dependencies.
  • iris.NewConditionalHandler gates middleware on a per-request filter; errors.FuncMiddleware lets middleware return errors into the central error pipeline.
  • Builder-assembled applications already include recovery, error rendering, default headers and optional compression. Register only what you add on top. With iris.New(), start with app.UseRouter(recovery.Handler).
  • Layers run outermost-first, registration order wins within a layer, and route chains are frozen at route registration: middleware first, routes second.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.