An Iris route maps an HTTP method and a URL path to a chain of handlers.
This chapter covers the registration API, typed path parameters, grouping
with child routers, the constructor-based controller pattern that scales
from a single file to a full service, error-returning handlers through
rest/errors, subdomains, and route naming and introspection.
Background
The shape of a URL. A URL such as
https://example.com/api/v1/users/42?page=2&sort=name splits into
parts the router cares about differently. The scheme (https) and
host (example.com) select the server. The path
(/api/v1/users/42) is a list of segments separated by slashes and
names the resource being acted on. The query string (everything after
the ?, here page=2&sort=name) is a set of optional key-value
pairs, conventionally used for filtering, paging and sorting rather
than for identity. Routing decides on method and path only; the query
string never chooses the handler, it only tunes what the handler does.
Static and dynamic segments. /users/42 and /users/97 should
run the same code, so a route pattern mixes fixed segments (users)
with placeholders ({id}) that match whatever appears in that
position. The placeholder’s value is handed to your handler as a
parameter.
What typed parameters buy you. Without them, every handler opens
with the same ritual: pull the raw string out of the path, convert it
to a number or date, check the conversion error, answer 400 or 404,
and only then do the real work. A typed parameter such as {id:int}
moves that ritual into the route table: the router validates and
converts before any handler of yours runs, so a handler that runs can
trust its input completely. Multiplied across every parameter of every route,
the win is a whole class of “forgot to check” bugs that can no longer
be written, and validation rules that sit in one visible place (the
route) instead of scattered through function bodies.
What grouping and controllers organize. Real APIs have shared
structure: everything under /api/v1 shares that prefix, everything
under /admin requires an administrator, every route about books
needs the book service. A group captures the path-level sharing once
(a common prefix plus common middleware), so individual routes stay one
line each. A controller goes a step further and organizes the code:
one struct owns a family of related handlers together with the
dependencies they share, and declares its routes in one method. Groups
organize paths; controllers organize code around those paths.
Route Registration
Every router (the application itself or any child created with
Router) exposes one function per HTTP method. A handler is a
func(ctx iris.Context) (the iris.Handler type); when you pass more
than one, they run in order and the last is the main handler.
package main
import "github.com/kataras/iris/v14"
func main() {
app := iris.New()
app.Get("/", index) // GET /
app.Post("/users", createUser) // POST /users
app.Put("/users/{id:int}", updateUser) // PUT /users/42
app.Delete("/users/{id:int}", deleteUser)
// One handler for several methods, space or comma separated.
app.Handle("GET POST", "/contact", contact)
// Every HTTP method.
app.Any("/ping", ping)
app.Listen(":8080")
}
func index(ctx iris.Context) {
ctx.JSON(iris.Map{"message": "hello"})
}The method functions (Get, Post, Put, Delete, Patch, Head,
Options, Connect, Trace, Query) return a *Route, so registration
can be chained with route configuration such as SetName. Handle and
Any create one route per method and return a *RouteRegistry holding
all of them.
Query is HTTP QUERY (RFC 10008): a safe, idempotent read that carries a
body, the natural method for a search or a filtered list.
iris.MethodQuery is its name ("QUERY") wherever a method string is
expected, as in app.Handle("POST QUERY", "/list", list), and Any
includes it. When the router redirects a request to its trailing-slash
twin, POST, PUT, PATCH and QUERY get a 307 so the body survives the
redirect.
There is no optional-parameter syntax. When a path segment may be absent, register two routes that share the same handler:
app.Get("/search", search)
app.Get("/search/{term}", search)Path Parameters
A dynamic path segment is written as {name} or {name:type}. The type
is a macro: it validates the raw segment before your handler runs and
converts it to a Go value. Without a type, {name} means {name:string}.
One type in the table deserves a definition: a UUID (universally
unique identifier) is a 128-bit identifier written as 36 hexadecimal
characters, such as 9f86d081-8292-4c4c-812f-0a1b2c3d4e5f. Because a
freshly generated UUID is unique for all practical purposes, services
use them as record IDs that any machine can mint without coordinating
with a central counter, which is why they appear in this book’s
examples wherever a database ID crosses a URL.
Built-in Parameter Types
| Type | Syntax | Go type | Matches |
|---|---|---|---|
string | {name} or {name:string} | string | any single path segment (default) |
int | {id:int} | int | integers (alias: number) |
int8..int64 | {n:int64} | int8..int64 | sized integers (int64 alias: long) |
uint..uint64 | {n:uint64} | uint..uint64 | unsigned integers |
bool | {v:bool} | bool | strconv.ParseBool values: 1, 0, t, f, true, false, … (alias: boolean) |
alphabetical | {name:alphabetical} | string | letters and spaces only |
file | {name:file} | string | letters, digits, underscore, dash, dot; no spaces |
path | {rest:path} | string | multi-segment wildcard; must be the last segment |
uuid | {id:uuid} | uuid.UUID | any RFC 9562 UUID, whatever its version (alias kept for compatibility: uuidv4) |
mail | {addr:mail} | string | e-mail address, syntax check only |
email | {addr:email} | string | e-mail address, syntax plus MX domain lookup |
date | {d:date} | time.Time | date in YYYY/MM/DD format |
weekday | {day:weekday} | time.Weekday | day name or 0-6 |
Constraints
Constraints follow the type, separated by spaces. A trailing else N
sets the HTTP status code the router responds with when validation fails.
| Constraint | Applies to | Example | Meaning |
|---|---|---|---|
min(n) | string, numbers | {name:string min(3)} | minimum length (string) or value (numbers) |
max(n) | string, numbers | {name:string max(20)} | maximum length (string) or value (numbers) |
range(a,b) | numbers | {age:int range(1,120)} | value between a and b, inclusive |
regexp(expr) | string | {code:string regexp(^[A-Z]{3}$)} | regular expression match |
prefix(s) | string | {name:string prefix(user_)} | must start with s |
suffix(s) | string | {name:string suffix(.pdf)} | must end with s |
contains(s) | string | {name:string contains(admin)} | must contain s |
eq(v) | string | {state:string eq(active)} | exact equality |
eqor(v1,v2,...) | string | {flag:string eqor(on,off)} | equals any listed value |
else N | all | {id:int else 400} | status code on failure |
The string macro is the master type: when another type has no function
of the requested name, the string version is used. It compares against
the value’s text, so {id:int prefix(4)} and {id:uuid prefix(a6)}
work as they read.
app.Get("/page/{page:int min(1) max(100) else 400}", pageHandler)
app.Get("/users/{name:string min(3) max(20) else 400}", profileHandler)
app.Get("/files/{filepath:path}", fileHandler)
app.Get("/shelves/{id:uuid}", shelfHandler)When a typed parameter fails validation and no else code is given, the
request never reaches your handler. Applications assembled with
iris.NewBuilder (whose RouterMiddlewares step installs the macro
error handler) render a 400 INVALID_ARGUMENT response with the message
“invalid path parameter”.
Where that check happens is worth being precise about, because it is not
where most readers assume. The router’s trie matches shape: segment
count and static segments. /orders/abc does match the route
/orders/{id:int}. The type check is a handler that route registration
prepends to the route’s own chain, so it runs as that route’s handler
zero: after any UseRouter middleware, and before the root app.Use
middleware that would otherwise see the request.
Accessing Parameters
Typed accessors return the value directly, with no (value, error) pairs.
Each has a fixed failure default:
func handler(ctx iris.Context) {
name := ctx.Params().GetString("name") // "" if missing
id := ctx.Params().GetInt("id") // -1 on failure
uid := ctx.Params().GetUint64("id") // 0 on failure
active := ctx.Params().GetBool("active") // false on failure
when := ctx.Params().GetDate("d") // zero time.Time on failure
sid := ctx.Params().GetUUID("sid") // zero uuid.UUID on failure
if v, ok := ctx.Params().Lookup("name"); ok {
_ = v // the evaluated value as any
}
}Custom Macros
Add functions to an existing type or register a new type. Both must happen before the server starts:
// A new constraint on the string type:
// usage: /status/{status:string equals(active)}
app.Macros().Get("string").RegisterFunc("equals",
func(expected string) func(string) bool {
return func(paramValue string) bool {
return paramValue == expected
}
})
// A completely new parameter type:
// usage: /events/{when:datetime}
app.Macros().Register("datetime", "", time.Time{}, false, false,
func(paramValue string) (any, bool) {
t, err := time.Parse("2006-01-02T15:04:05", paramValue)
if err != nil {
return nil, false
}
return t, true
})Route Groups
Router(relativePath, ...) creates a child router that inherits its
parent’s prefix and middleware. Groups nest to any depth, and any
iris.Handler passed alongside the path becomes middleware of the
group’s matched routes.
package main
import "github.com/kataras/iris/v14"
func main() {
app := iris.New()
api := app.Router("/api")
api.Get("/status", status) // GET /api/status
v1 := api.Router("/v1")
v1.Get("/users", listUsers) // GET /api/v1/users
// Closure form: configure the child router in place.
app.Router("/reports", func(r iris.Router) {
r.Use(requireAuth)
r.Get("/", listReports)
r.Get("/{id:int}", getReport)
})
// Handlers passed to Router become the group's middleware.
admin := app.Router("/admin", requireAuth, requireAdmin)
admin.Get("/stats", stats)
app.Listen(":8080")
}Three registration points control where middleware runs; they matter most on groups:
UseRouter(handlers...)runs before the routing decision itself: on every request, including not-found (404) and method-not-allowed (405).Use(handlers...)on the root application also covers 404 and 405 responses; on a child router it runs only for that group’s matched routes.- Handlers listed per route run last, in the order given, before the main handler.
The middleware contract is plain: call ctx.Next() to continue the
chain, or write a response and return to stop it. The built-in
middleware catalogue is covered in
Chapter 12.
Controllers
The primary way to organize routes in Iris is a small convention rather
than a base class: a controller is a plain struct with a constructor
that declares its dependencies and a Routing(r iris.Router) method
that declares its routes. The router calls Routing once at startup.
// api/shelves.go
package api
import (
"github.com/kataras/iris/v14"
"bookshelf/library"
)
// ShelvesAPI groups the /shelves handlers around their dependencies.
type ShelvesAPI struct {
svc *library.Service
}
// NewShelvesAPI is the controller's dependency declaration: the DI
// container calls it with the registered *library.Service.
func NewShelvesAPI(svc *library.Service) *ShelvesAPI {
return &ShelvesAPI{svc: svc}
}
// Routing is the controller's route table.
func (api *ShelvesAPI) Routing(r iris.Router) {
r.Get("/", api.list)
r.Post("/", api.create)
r.Get("/{id:uuid}", api.get)
r.Delete("/{id:uuid}", api.delete)
// A shelf's books live in their own nested controller; its
// constructor is resolved by the container exactly like this one.
r.Router("/{shelf_id:uuid}/books", NewBooksAPI)
}
func (api *ShelvesAPI) list(ctx iris.Context) {
shelves, err := api.svc.Shelves(ctx)
ctx.OK(shelves, err)
}
func (api *ShelvesAPI) get(ctx iris.Context) {
id := ctx.Params().GetUUID("id")
shelf, err := api.svc.Shelf(ctx, id)
ctx.OK(shelf, err)
}The ctx.OK(value, err) helpers render the value or hand the
error to the central error pipeline; they are covered in
Chapter 6 and
Chapter 9.
Registering Controllers
Pass the constructor to Router. Its parameters are resolved from the
dependencies you registered:
app := iris.New()
app.RegisterDependency(library.NewMemStore(), library.NewService)
app.Router("/shelves", api.NewShelvesAPI)In a builder-assembled application the API step does the same after
Services has populated the container. This is the recommended setup,
shown in Chapter 3:
iris.NewBuilder().
// ... Services(repo, library.NewService), middleware steps ...
API("/shelves", api.NewShelvesAPI).
Build().
Listen(":8080")Accepted Controller Shapes
Router(path, c ...any) accepts, in any combination:
- a
func(r iris.Router)closure; - an
iris.Handler(or a slice of handlers), registered as the group’s middleware; - a pointer to a struct implementing
Routing(r iris.Router), whose exported fields are bound from the registered dependencies; - a constructor function whose parameters are resolved from the
dependencies and which returns such a struct, the
NewXxxAPIform used above; - the helpers
iris.Middleware(handlers...)andiris.Dependencies(deps...), which add group middleware and controller-only dependency overrides positionally:
app.Router("/api",
iris.Middleware(authMiddleware), // group middleware
iris.Dependencies(specialDep), // extra deps for what follows
api.NewShelvesAPI,
api.NewAdminAPI,
)Guards and Positional Middleware
A guard is middleware with a yes/no job: it checks one precondition (the record exists, the user is allowed to write) and either lets the chain continue or ends the request with the right error. Putting the check in a guard means the handlers behind it never restate it.
Because guards are methods on the controller struct, they see the same
injected dependencies as the handlers. Two idioms cover most cases: a
group guard registered with r.Use runs before every route of the
controller, and a positional guard placed between path and handler
protects individual routes.
// api/books.go
package api
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
"bookshelf/library"
)
// shelfContextKey caches the guard-loaded shelf on the request.
const shelfContextKey = "bookshelf.shelf"
// BooksAPI serves /shelves/{shelf_id:uuid}/books.
type BooksAPI struct {
svc *library.Service
}
func NewBooksAPI(svc *library.Service) *BooksAPI {
return &BooksAPI{svc: svc}
}
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 also 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)
}
// requireShelf loads the shelf once, replies 404 when it does not
// exist and caches it for the handlers that follow.
func (api *BooksAPI) requireShelf(ctx iris.Context) {
shelfID := ctx.Params().GetUUID("shelf_id")
shelf, err := api.svc.Shelf(ctx, shelfID)
if errors.HandleError(ctx, err) {
return // mapped 404, request stopped.
}
ctx.Values().Set(shelfContextKey, shelf)
ctx.Next()
}
// requireUnlocked rejects writes against a locked shelf.
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
}
ctx.Next()
}The handlers then read the cached shelf with
ctx.ValueAs[library.Shelf](shelfContextKey) instead of
loading it again. The full controller, with its list, create, update and
delete handlers, is developed step by step in the framework’s
TUTORIAL.md.
Error-Returning Handlers
Plain handlers return nothing; error handling happens inside them. The
rest/errors package offers an alternative: errors.NewRouter wraps
any iris.Router so that routes accept
func(ctx iris.Context) error. A non-nil return value flows through the
central error map: sql.ErrNoRows becomes 404 by default, and your
own mappings come from errors.NewOptions() as shown in
Chapter 9.
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
)
func main() {
app := iris.New()
r := errors.NewRouter(app.Router("/api"))
r.UseRouter(func(ctx iris.Context) error {
// Runs before routing, even for 404/405 requests.
// Return nil to continue; a non-nil error is rendered
// by the error pipeline and stops the request.
return nil
})
r.Get("/books/{id:uuid}", func(ctx iris.Context) error {
book, err := findBook(ctx.Params().GetString("id"))
if err != nil {
return err // mapped centrally.
}
return ctx.JSON(book)
})
app.Listen(":8080")
}The wrapper mirrors the core router: Get, Post, Put, Delete and
the rest register error-returning functions, Use and UseRouter
accept error-returning middleware, and Subdomain returns another
wrapped router. The embedded core router remains accessible for anything
else.
Subdomains
A subdomain is the label in front of your domain: in
admin.example.com it is admin. DNS usually points every subdomain
at the same server (a wildcard record), and the server tells them
apart by the Host header each request carries, which lets one Iris
application serve example.com and admin.example.com with entirely
different route tables.
Subdomain is available on every router and returns a child router
bound to a host label instead of a path prefix. Calling it on a child
prepends the label, so subdomains nest naturally.
admin := app.Subdomain("admin")
admin.Get("/", dashboard) // admin.example.com/
// Nested: registers "panel.admin."
panel := app.Subdomain("admin").Subdomain("panel")
panel.Get("/", panelIndex) // panel.admin.example.com/
// Wildcard: any subdomain not matched by a static one.
wild := app.WildcardSubdomain()
wild.Get("/", func(ctx iris.Context) {
ctx.Writef("subdomain: %s", ctx.Subdomain())
})WildcardSubdomain is a method of the root application. Matching
enforces dot boundaries: for a registered admin subdomain, requests to
admin and staff.admin match, while badmin does not.
Route Names and Introspection
Every route gets a unique-per-application name, defaulting to method +
subdomain + path (for example "GET/users/{id:uuid}"). Override it with
SetName, look routes up by name, and build concrete paths from a
route’s template:
route := app.Get("/users/{id:uuid}", getUser)
route.SetName("user.get")
r := app.Routes().GetByName("user.get")
path := r.ResolvePath("42") // "/users/42"None registers an offline route under the virtual method
iris.MethodNone; it is not reachable over HTTP but can be inspected
and toggled online by swapping its exported Method field. Before the
server starts, app.RemoveRoute(name) deletes a route by name and
reports whether it was found.
Registration order rarely matters, because routes are sorted at build
time: static paths sort before parameterized paths of equal depth, and
more specific parameter types (int, uuid, …) are evaluated before
the string and path fallbacks when several routes share a position.
Parameterized routes of equal priority keep their registration order.
Generic MVC Controllers
Two acronyms first. MVC (Model-View-Controller) is a long-standing way to organize applications: the model holds data and rules, the view renders output, and the controller connects requests to both. In an HTTP API, “controller” is the request-handling struct. CRUD names the four operations nearly every resource needs: create, read, update, delete.
For classic resource CRUD, the controller/mvc package generates the
route table for you: a controller implements a generic interface and
mvc.UseController mounts it, with With nesting further controllers
under the same path.
import "github.com/kataras/iris/v14/controller/mvc"
app.Router("/stores", mvc.UseController[*StoreController]().
With("/books", mvc.UseController[*BookController]()))This chapter’s Routing convention remains the general-purpose tool;
the generic MVC controllers and their extensions are covered in
Chapter 24.
Summary
- Method functions (
Get,Post, …) return a*Route;HandleandAnyregister one route per method and return a*RouteRegistry. - Path parameters are typed macros such as
{id:uuid}and{n:int min(1)}, validated before the handler runs, withelse Ncontrolling the failure status andctx.Params().GetXreading the values. Router("/prefix")groups routes; handlers passed to it become group middleware, and closures configure the child in place.- A controller is a struct with a
NewXxxAPIconstructor resolved by dependency injection and aRouting(r iris.Router)route table; controllers nest throughr.Router(path, NewChildAPI). - Guards are controller methods:
r.Usefor the whole group, a positional handler for single routes. errors.NewRouterwraps any router so handlers returnerrorinto the central error pipeline.SubdomainandWildcardSubdomainroute by host label; names, offline routes andResolvePathcover introspection needs.
Further Reading
- MDN: URIs. The anatomy of URLs: scheme, host, path, query and fragment.
- RFC 3986: Uniform Resource Identifier — Generic Syntax: the specification that defines path segments and query strings precisely.
- RFC 9110: HTTP Semantics: what each method means on a resource, the semantics your route tables encode.
- net/url (pkg.go.dev): how Go itself represents and parses URLs.
- uuid (pkg.go.dev): the standard library’s
UUID package (Go 1.27), used by this chapter’s examples and by the
:uuidroute macro. - RFC 9562: Universally Unique IDentifiers:
the UUID formats and versions the
:uuidmacro accepts.