iris.NewBuilder() is the recommended way to assemble an Iris v14
application. It is a fluent chain of configuration steps whose order the
compiler enforces: each step returns the interface of the next, so CORS
cannot come after logging and controllers cannot be registered before
services. Along the way the builder installs the production plumbing you
would otherwise wire by hand: recovery, structured error handling,
compression, access logging, a health endpoint, dependency injection and
graceful shutdown. This chapter walks through every step, backed by what
the builder actually does in iris_builder.go, and ends with a complete
application you can run and test.
Background
What “wiring” an application means. Every server program has two phases: a short setup phase and a long serving phase. In the setup phase you read configuration (the values that differ per deployment: addresses, credentials, allowed origins), construct your services and their dependencies, register routes and middleware, and decide what must happen at shutdown. Wiring is that setup phase, and it is where most production bugs are born, not because any single step is hard, but because the steps depend on each other and their order matters.
Cross-cutting concerns. Some behavior belongs to every request rather than to any one handler: logging what happened, recovering from crashes, compressing responses, checking which websites may call you, formatting errors consistently. These are called cross-cutting concerns because they cut across all your routes. Handled naively, each one is a line you must remember in every handler; handled as middleware wired once at startup, they disappear from your handlers entirely.
Startup and shutdown order. Resources start in dependency order (the database connection opens before the service that queries it) and must stop in a sensible order too: stop accepting new requests, let in-flight ones finish, then close what startup opened. Forgetting the shutdown half leaks connections and loses buffered logs. A good wiring layer pairs every “open” with its “close” at the moment you write it.
The builder pattern. A builder is an object you configure through a
chain of method calls and finish with one final call (here Build())
that produces the assembled product. The chain reads top to bottom like
a checklist, which is the point: the whole application’s wiring is one
visible expression instead of registrations scattered across files.
Iris adds a stricter twist: each step returns a different type that
offers only the next legal steps, so skipping a step or calling them
out of order is not a bug you find under traffic. It is code that does
not compile. The class of mistake this removes is exactly the class
that is hardest to test for: middleware registered after the routes it
should protect, recovery forgotten, shutdown code never registered.
Why the Builder
A production HTTP API needs more than routes: panics must become logged 500 responses instead of dropped connections, errors need one consistent JSON shape, responses should compress, requests should be logged, shutdown must close what startup opened, and middleware must run in the right order. None of that is hard individually; getting the order right every time is. The builder encodes the order once, in the framework, and gives you named steps for the decisions that are actually yours: which origins to allow, what to log, how to map your errors, which services to inject.
The result is that a whole application reads as a single expression:
iris.NewBuilder().
Prefix("/api").
AllowOrigin("https://example.com").
Compression(true).
LogRequests(true).
Health(true, "production", "team@example.com").
Errors(errors.NewOptions().
MapErrors(errors.NotFound, ErrNoteNotFound)).
Deferrables(func() { log.Println("bye") }).
Services(store, NewNoteService).
RouterMiddlewares().
Middlewares().
API("/notes", NewNotesAPI).
Build().
Listen(":8080")iris.NewBuilder(configurators ...iris.Configurator) creates the
underlying application (any configurators you pass apply first) and
registers the *iris.Application itself as a dependency, so controllers
and custom components can receive it through injection.
The Step Chain
Each builder method returns a narrower interface that exposes only the next legal steps:
Three of the stages allow repetition. Errors and Deferrables return
ServiceBuilder again, so you may call them in either order or several
times before Services advances the chain. RouterMiddlewares returns
MiddlewareBuilder, so it can be called repeatedly before Middlewares
advances. API returns APIBuilder, so you mount as many groups as you
need before the terminal Build.
The order is not decorative. Because each step returns a different type, skipping a stage is a compile error, not a runtime surprise. This is also why real applications contain the two seemingly empty calls:
Services(store).
RouterMiddlewares(). // nothing custom, still required to move on
Middlewares(). // nothing custom, still required to move on
API("/notes", NewNotesAPI)Services returns MiddlewareBuilder, and the only way out of
MiddlewareBuilder is Middlewares(), so every chain passes through it
even with no arguments. The calls are not no-ops either: the first of
them triggers the builder’s one-time middleware setup described in
RouterMiddlewares and Middlewares. Strictly speaking
RouterMiddlewares() could be omitted, because Middlewares() invokes
it internally, but writing both keeps the two middleware layers visible
at the call site.
One consequence of the changing return types: you cannot hold the chain in a single variable and reassign it across steps. Compute conditional values first, then build in one uninterrupted expression:
origin := "https://example.com"
if env == "development" {
origin = "*"
}
app := iris.NewBuilder().
Prefix("/api").
AllowOrigin(origin).
Compression(env == "production").
LogRequests(env != "production").
Health(true, env, "team@example.com").
Services().
Middlewares().
Build()Step Reference
Prefix and NoPrefix
Prefix("/api") creates a child router with app.Router("/api") and
points every later step at it: CORS, access logging, your middlewares
and all API() groups mount under that prefix. Use it for versioning
(/api/v1) or to keep the API apart from static content. NoPrefix()
targets the application root instead; it is the same as Prefix("").
The one deliberate exception is the health endpoint, which always registers at the root (see below).
AllowOrigin
CORS (Cross-Origin Resource Sharing) is the browser’s permission
system for cross-site calls: JavaScript served from https://app.com
may not call an API on a different origin unless that API’s responses
explicitly allow it in headers. For some requests the browser first
sends a preflight (an automatic OPTIONS request that asks “may I?”)
and proceeds only when the answer permits. The middleware writes those
headers and answers preflights for you; your only decision is which
origins to allow.
AllowOrigin configures the CORS middleware. The argument is trimmed
and lowercased, then routed:
"none": no CORS middleware is installed at all."*": allow every origin (public, credential-free APIs only)."https://app.com": exactly one origin."https://app.com,https://admin.app.com": a comma-separated list.
When enabled, the builder registers
cors.New().HandleErrorFunc(errors.FailedPrecondition.Err). AllowOrigin(originLine).Handler() with UseRouter, so it runs before
routing and handles preflight requests for every path under the prefix.
Wiring HandleErrorFunc to the errors package means a disallowed origin
is rejected with the same structured JSON error body as the rest of your
API, instead of an anonymous plain-text response.
Cors
AllowOrigin covers the common case in one string. Everything else the
middleware can do (credentials, exposed headers, max age, the origin
extractor, a different error handler) needs the CORS value itself, which
is what the alternative step at this stage takes:
iris.NewBuilder().
Prefix("/api").
Cors(cors.New().
HandleErrorFunc(errors.FailedPrecondition.Err).
AllowOrigins("https://app.com", "https://*.admin.com").
ExposeHeaders("X-Total-Count").
AllowCredentials()).
Compression(true).
// ...The two steps are the same call underneath: AllowOrigin(line) is the
shorthand for
Cors(cors.New().HandleErrorFunc(errors.FailedPrecondition.Err).AllowOrigin(line)).
Both register through UseRouter at the same position in the chain, so
preflights are answered before routing either way. Pass nil and no
middleware is installed, the same as AllowOrigin("none").
Your value is used exactly as given, with no defaults merged into it.
That one difference matters: cors.New() on its own carries the
middleware’s default error handler, so a Cors step that omits
HandleErrorFunc rejects disallowed origins with a bare 403 rather than
the structured error body AllowOrigin installs. Set it yourself when
you want the two to behave alike.
AllowCredentials is worth a note. A credentialed cross-origin request
(one carrying cookies or an Authorization header) is only permitted
when the response says Access-Control-Allow-Credentials: true, and
browsers refuse that header when the allowed origin is the wildcard *.
The middleware follows the browser rule: with a wildcard origin it
suppresses the credentials header unless you called AllowCredentials
explicitly. In practice a browser always sends Origin and the
middleware echoes that origin back rather than *, so credentials are
advertised normally; the wildcard case arises for requests that carry no
Origin header at all, where AllowCredentials is the way to opt in.
Compression
Compression shrinks response bodies before they cross the network (JSON and HTML typically compress to a fraction of their size), trading a little CPU for less bandwidth and faster responses on slow links. Client and server agree on the algorithm through headers, which is the negotiation described below.
Compression(true) only records a flag; the actual
middleware/compress handler is installed later, during the one-time
middleware setup, and only if a handler named iris.compression is not
already registered. The middleware negotiates the response encoding from
the client’s Accept-Encoding header (gzip, deflate, br, snappy, s2,
zstd)
and also decompresses request bodies that arrive with a
Content-Encoding header, which is the “read” half of the parameter’s
enableReadWriteCompression name. Pass false while debugging if you
want to read raw response bodies on the wire.
LogRequests and LogRequestsWith
LogRequests(true) is a shortcut for LogRequestsWith(ac) with the
framework’s tuned middleware/accesslog configuration:
- JSON lines, indented, with human-readable timestamps, written to
access_log.jsonand mirrored to stdout. - Per request: latency, status code, method, path, client IP, request and response bodies, and their byte sizes.
- Panics are logged too (
PanicLog), with multi-line errors preserved. - Synchronous writes (
Async = false), so nothing is lost on crash.
The handler registers with UseRouter on the builder’s router, and its
Close method is added as an application closer, so buffered output is
flushed on shutdown. LogRequests(false) installs nothing.
When the defaults do not fit, build your own access log and pass it at
the same position; a nil value is ignored:
ac := accesslog.File("access_log.json")
ac.RequestBody = false // do not log request payloads
ac.AddFields(func(ctx iris.Context, f *accesslog.Fields) {
f.Set("tenant", ctx.Header("X-Tenant-ID"))
})
// ... .Compression(true).LogRequestsWith(ac). ...Either way, add access_log.json to .gitignore.
Health and NoHealth
A health endpoint is a URL whose only job is to answer “is this process alive, and which build is it?”. Monitoring systems poll it and alert, restart or reroute traffic when it stops answering.
Health(true, env, developer) registers GET /health at the
application root, intentionally outside the prefix: load balancers,
Kubernetes probes and uptime monitors ask for /health, not
/api/v1/health. The handler comes from middleware/modrevision and
responds with plain text containing the server name and environment
(Iris Server (production)), the build revision and build time when the
binary carries module build info, the developer line, detected cloud
container information, and a final OK.
The developer string does double duty: the builder remembers it and,
during the one-time middleware setup, installs a handler that sets a
Server: Iris header on every response plus an X-Developer header
when the string is non-empty. That makes it trivial to tell from any
response which team owns a deployment. NoHealth() skips the endpoint
and leaves the developer empty, so no X-Developer header is emitted.
Errors
Errors(*errors.Options) accepts the central error map from
github.com/kataras/iris/v14/rest/errors. The builder stores the
options and, during the one-time middleware setup, registers
opts.Handler() with UseRouter; the handler attaches your options to
each request context, so every error helper in the framework consults
your map when it renders a response.
errors.NewOptions() starts from useful defaults, sending
database/sql.ErrNoRows to NOT_FOUND and the kataras/jwt token
errors to UNAUTHENTICATED. You extend it with your own mappings:
Errors(errors.NewOptions().
MapErrors(errors.NotFound, ErrNoteNotFound).
MapErrors(errors.PermissionDenied, ErrArchiveLocked).
MapErrorFunc(errors.Internal, func(err error) error {
return errors.Private(err) // hide details from clients
}))MapErrors matches with errors.Is semantics, MapErrorFunc and the
context-aware MapErrorFuncCtx transform errors, and MapErrorTypes
matches by type. The default rendering is a structured JSON object;
chain SetRenderer(errors.Problem) for RFC 7807 Problem Details
output. Chapter 9 covers the error system in
full.
Deferrables
Deferrables(closers ...any) registers shutdown work. Every argument is
forwarded to app.AddCloser, which accepts exactly these shapes and
panics at registration time on anything else:
func()func() error(the error is preserved and joined into the shutdown error witherrors.Join)func(context.Context)func(context.Context) errorio.Closer(anything withClose() error)- the context-aware
Closerfromcore/server/closer(CloseWithContext(context.Context) error)
Closers run in registration order when the application shuts down through
app.Shutdown(ctx). The built-in interrupt handler drains in-flight
requests but does not run them by itself, so register the shutdown
explicitly if you want closers on Ctrl+C:
iris.RegisterOnInterrupt(app.Shutdown)Because the server’s own shutdown is prepended to the closer list, it
releases Listen while the remaining closers are still running. Keep
main alive until they finish (for example with a channel the last
closer closes) if their work must complete before the process exits.
Deferrables(
func() { worker.Stop() },
func() error { return cache.Flush() },
logFile, // io.Closer
)Services and the Initializer Hook
Services(deps ...any) feeds the dependency injection container that
later constructs your middlewares and controllers. Each argument is
routed by its shape:
| Argument shape | What the builder does |
|---|---|
nil | Skipped. |
func() or func() error | Added as a shutdown closer only; a function with no outputs cannot be a constructor. |
Value with Close() or Close() error | Registered as a dependency and as a shutdown closer. |
Value implementing iris.Worker | Registered as a dependency, started after the initializers; at shutdown it is stopped and waited for before its own Close, in registration order with the other closers. |
iris.Configurator | Applied to the application immediately; not a dependency. |
| Anything else | Registered as a dependency: values by their type, functions as constructors. |
Constructor functions are called lazily with their parameters resolved
from previously registered dependencies, which is why order matters
inside a Services call:
Services(
store, // *NoteStore value (has Close: also a closer)
NewNoteService, // func(*NoteStore) *NoteService
)If registration fails, Services panics with the underlying error. That
is deliberate: a broken dependency graph should stop the process at
startup, not surface as confusing behavior under traffic.
Services also get a startup hook. Any registered singleton (a value you
passed or a constructor’s output) that implements iris.Initializer
runs exactly once inside Build(), after dependency injection
completes, in registration order:
type Initializer interface {
Init(ctx context.Context) error
}Use it for cache warm-up, connection pinging or background pool setup.
An Init error aborts the build, making the interface the startup
counterpart of Deferrables.
Long-running work has its own interface. A scheduler, a queue consumer or
a cache refresher passed to Services as an iris.Worker runs inside the
same chain, instead of in a goroutine started next to it:
type Worker interface {
Run(ctx context.Context) error
Stop(ctx context.Context) error
}Run starts in its own goroutine after every Initializer has returned,
with a context that is cancelled at shutdown. An error it returns before
that is logged with the worker’s type name and nothing else: a failed
cache refresher should not take down a server that can still answer every
request which does not need it.
Stop runs once, in registration order with the closers, and Shutdown then waits for Run
to return, so a worker is really finished when Shutdown reports success
rather than still draining while the process exits from under it. A value
that is both a Worker and a closer is stopped, waited for, and only then
closed, because closing the connection a running Run is still reading
from is the crash that ordering avoids.
Both halves are bounded. They get the Shutdown context when that one
carries a deadline, and a ten second budget when it does not, so a hanging
Stop cannot hold the process open. That budget is per worker, not per
shutdown, because the closers run one after another: pass a deadline to
app.Shutdown to bound the whole chain instead.
A Worker is a dependency like any other value, so the same scheduler can
be injected into a controller that wants to trigger it. Only a Worker passed to
Services as a value is started; one returned by a constructor is injected
but never run, so hand the scheduler itself to Services, not its constructor.
RouterMiddlewares and Middlewares
Two of the built-ins are installed by NewBuilder itself, before the
first step runs, because Prefix copies the parent’s context options and
macros into the child router the moment it is called:
- the macro (path parameter) error handler,
errors.DefaultPathParameterTypeErrorHandler, so a request like/notes/abcagainst{id:int}renders400 INVALID_ARGUMENT(“invalid path parameter”) instead of a bare 404, and - the context error handler,
errors.DefaultContextErrorHandler, so failures inside response writers such asctx.JSONrender structured errors.
These two methods separate the middleware layers, and the first call to
either performs the builder’s one-time setup, guarded by a sync.Once.
That setup installs the pre-routing chain, in execution order:
- Your
Errorsoptions handler, if you provided one. It goes first so everything after it, the CORS rejection included, is written through the collector, renderer and id generator you configured. - The access log, when
LogRequestsasked for one. It logs whenctx.Nextreturns rather than from adefer, so it has to sit outside recovery: otherwise a panicking request is logged nowhere. - The recovery middleware with
errors.Internal.LogErras its error handler: a panic becomes a logged, structured 500 response. Skipped when a handler namediris.recoveryoriris.errors.recoveris already registered. - CORS, below recovery so a panic inside its error handler is caught, and below the access log so a rejection is recorded.
- The
Server: Irisheader on every response, plusX-DeveloperwhenHealthreceived a developer string. compress.Safe, whenCompression(true)was chosen.Saferather thanHandler: an unbounded decompressor turns a one-megabyte gzip body into a gigabyte of memory.errors.Handlerfromrest/errors, the last of the built-in router middleware and after compression, so what it writes is compressed like any other body. A failure status that reaches the client with nothing written, from a route or from any middleware registered after this point, answers the JSON envelope of Chapter 9 instead of an empty body:ctx.StopWithStatus(403),ctx.NotFound(), the 401 of thejwtmiddleware.- The rate-limit exceed handler, which is a binding rather than a
middleware in the chain: responses from
middleware/raterender as the errors package’s JSON instead of empty 429s.
When the application is built, the builder also registers
errors.Handler as the 404 and the 405 handler, so an unknown path
answers NOT_FOUND as JSON and not the text Not Found. It fills those
two slots only when they are empty: a HandleNoRoute or
HandleNoMethod of your own, registered from a Configurator passed to
NewBuilder, wins. Two rejections are answered before errors.Handler
runs and keep their bare status: one from a handler a Configurator
registered with UseRouter, and the 415 of the compression middleware
for a request body that is not what its Content-Encoding says.
One consequence of that order is worth stating plainly: a panic inside
the access log or inside your own Errors options handler is above
recovery, so it is not recovered. Both are framework-adjacent code you
register once; keep them simple.
After the built-ins, your own entries register:
RouterMiddlewares(...)usesUseRouter: the handlers run before routing, for every request that reaches the builder’s router, including requests that end in 404. Use it for request IDs, security headers, metrics and global rate limiting.Middlewares(...)usesUse: the handlers run only for matched routes. Use it for authentication and anything that assumes a real route.
Both accept plain iris.Handler values as well as constructor
functions whose parameters resolve from registered services:
RouterMiddlewares(func(ctx iris.Context) {
ctx.SetHeader("X-Frame-Options", "DENY")
ctx.Next()
}).
Middlewares(func(auth *AuthService) iris.Handler {
return func(ctx iris.Context) {
user, err := auth.UserFromRequest(ctx)
if err != nil {
errors.Unauthenticated.Err(ctx, err)
return
}
ctx.Values().Set("user", user)
ctx.Next()
}
})Middlewares calls RouterMiddlewares() internally before doing its
own work, so the built-ins install exactly once regardless of which
method you call first or how many times.
API
API(prefixPath string, controllers ...any) mounts a route group under
the builder’s prefix by delegating to router.Router(prefixPath, ...).
A controller is anything with a Routing(r iris.Router) method; you
usually pass its constructor and let injection supply the dependencies,
as the complete example below shows. Plain handlers listed before a
controller act as middleware for that group only:
API("/notes", NewNotesAPI).
API("/admin", basicauth.Default(map[string]string{
"admin": adminPassword,
}), NewAdminAPI).Call API as many times as you have groups. Controllers, grouping and
the helper controllers (iris.Middleware, iris.Dependencies and
friends) are the subject of Chapter 4.
Build
Build() finalizes the application: it runs the iris.Initializer
hooks of your services (once), then compiles the router and middleware
stack. On any misconfiguration it panics; like the Services panic,
this is intentional so that a broken application never starts serving.
It returns the *iris.Application, which you can serve or test:
app := builder.Build()
// Plain HTTP.
app.Listen(":8080")
// Or with explicit server options (TLS shown; see Chapter 19).
app.Run(iris.ServerOptions{
Addr: ":443",
CertFile: "cert.pem",
KeyFile: "key.pem",
})A Complete Application
The following program is a small but production-shaped notes API. It is a single file here for readability; the layering (config, domain, storage, controllers, wiring) is exactly what you would split into packages as the application grows. The code compiles and runs as shown.
package main
import (
"context"
"fmt"
"log"
"os"
"sync"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/basicauth"
"github.com/kataras/iris/v14/rest/errors"
)
// Config carries the deployment-specific values. iris.BaseConfiguration
// brings Host, Port, AllowOrigin, EnableCompression, RequestLog and the
// framework options, so cfg.Addr() feeds Listen and there is no Addr field
// of our own to keep in step. Environment variables keep the example short;
// Chapter 11 covers configuration in depth, including loading this same
// struct from a YAML document with iris.LoadConfiguration.
type Config struct {
iris.BaseConfiguration `yaml:",inline"`
Env string `yaml:"Env"`
Developer string `yaml:"Developer"`
AdminUser string `yaml:"AdminUser"`
AdminPass string `yaml:"-"`
}
func loadConfig() (Config, error) {
cfg := Config{
BaseConfiguration: iris.BaseConfiguration{
Port: 8080,
AllowOrigin: "none",
EnableCompression: true,
},
Env: "development",
Developer: "notes-team",
AdminUser: "admin",
AdminPass: os.Getenv("ADMIN_PASSWORD"),
}
// HOST, PORT, HTTP_PLATFORM_PORT and ALLOW_ORIGIN override the
// defaults above. iris.LoadConfiguration calls this for you when the
// configuration arrives as a YAML document.
cfg.BindEnv()
if cfg.AdminPass == "" {
return cfg, fmt.Errorf("ADMIN_PASSWORD is required")
}
return cfg, nil
}
// ErrNoteNotFound is a sentinel error; the builder's Errors step maps
// it to a 404 response.
var ErrNoteNotFound = fmt.Errorf("note not found")
type Note struct {
ID int `json:"id"`
Text string `json:"text"`
}
// NoteInput is the request payload. Implementing Validate makes
// ctx.BindJSON run it automatically after decoding.
type NoteInput struct {
Text string `json:"text"`
}
func (in NoteInput) Validate() error {
if in.Text == "" {
return fmt.Errorf("text: must not be empty")
}
return nil
}The storage type demonstrates two builder behaviors at once: it has a
Close method, so Services registers it as a shutdown closer
automatically, and it implements iris.Initializer, so Init runs once
inside Build().
type NoteStore struct {
mu sync.Mutex
seq int
notes []Note
}
func NewNoteStore() *NoteStore { return &NoteStore{} }
func (s *NoteStore) Init(ctx context.Context) error {
log.Println("notes: store ready") // ping a real database here.
return nil
}
func (s *NoteStore) Close() error {
log.Println("notes: store closed")
return nil
}
func (s *NoteStore) List(ctx context.Context) ([]Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Note(nil), s.notes...), nil
}
func (s *NoteStore) Create(ctx context.Context, in NoteInput) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
note := Note{ID: s.seq, Text: in.Text}
s.notes = append(s.notes, note)
return note, nil
}
func (s *NoteStore) Get(ctx context.Context, id int) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, note := range s.notes {
if note.ID == id {
return note, nil
}
}
return Note{}, fmt.Errorf("%w: %d", ErrNoteNotFound, id)
}Two controllers: the public notes API and an admin API mounted behind
basic authentication. (Basic authentication is the simplest HTTP login
scheme: the client repeats a username and password in the
Authorization header of every request. Served over TLS, it is a
reasonable fit for small internal admin endpoints.)
type NotesAPI struct {
store *NoteStore
}
func NewNotesAPI(store *NoteStore) *NotesAPI {
return &NotesAPI{store: store}
}
func (api *NotesAPI) Routing(r iris.Router) {
r.Get("/", api.list)
r.Post("/", api.create)
r.Get("/{id:int}", api.get)
}
func (api *NotesAPI) list(ctx iris.Context) {
notes, err := api.store.List(ctx)
ctx.List(notes, err) // an empty store renders [], never null.
}
func (api *NotesAPI) create(ctx iris.Context) {
input, ok := ctx.BindJSON[NoteInput]()
if !ok {
return // 400 already written: bad JSON or failed Validate.
}
note, err := api.store.Create(ctx, input)
ctx.Created(note, err)
}
func (api *NotesAPI) get(ctx iris.Context) {
note, err := api.store.Get(ctx, ctx.Params().GetInt("id"))
ctx.OK(note, err) // ErrNoteNotFound renders as 404.
}
type AdminAPI struct {
store *NoteStore
}
func NewAdminAPI(store *NoteStore) *AdminAPI {
return &AdminAPI{store: store}
}
func (api *AdminAPI) Routing(r iris.Router) {
r.Get("/stats", api.stats)
}
func (api *AdminAPI) stats(ctx iris.Context) {
notes, err := api.store.List(ctx)
if err != nil {
errors.HandleError(ctx, err)
return
}
ctx.JSON(iris.Map{"notes": len(notes)})
}Finally the wiring. newApp takes its storage as a parameter, so tests
can build the identical application around any store they like:
// newApp assembles the application. Storage arrives as a parameter:
// main passes the real store, tests pass whatever they need.
func newApp(cfg Config, store *NoteStore) *iris.Application {
return iris.NewBuilder(cfg.Iris).
Prefix("/api").
AllowOrigin(cfg.AllowOrigin).
Compression(cfg.EnableCompression).
LogRequests(cfg.Env == "development").
Health(true, cfg.Env, cfg.Developer).
Errors(errors.NewOptions().
MapErrors(errors.NotFound, ErrNoteNotFound)).
Deferrables(func() { log.Println("notes: goodbye") }).
Services(store).
RouterMiddlewares().
Middlewares().
API("/notes", NewNotesAPI).
API("/admin", basicauth.Default(map[string]string{
cfg.AdminUser: cfg.AdminPass,
}), NewAdminAPI).
Build()
}
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatal(err)
}
app := newApp(cfg, NewNoteStore())
app.Listen(cfg.Addr())
}Run it and exercise the behaviors the builder gave you for free:
ADMIN_PASSWORD=change-me go run .
# 201 {"id":1,"text":"hello"}; an empty text renders
# 400 INVALID_ARGUMENT because ctx.BindJSON runs NoteInput.Validate.
curl -X POST localhost:8080/api/notes \
-H 'Content-Type: application/json' -d '{"text":"hello"}'
# 404 NOT_FOUND: the central error map at work.
curl localhost:8080/api/notes/42
# 400 INVALID_ARGUMENT: malformed {id:int} path parameter.
curl localhost:8080/api/notes/abc
# 401, then 200 {"notes":1}.
curl localhost:8080/api/admin/stats
curl -u admin:change-me localhost:8080/api/admin/stats
# 200 OK: health lives at the root, outside /api.
curl localhost:8080/healthTesting the Built Application
Build() returns a fully assembled *iris.Application, and
httptest.New drives it in memory: real router, real middleware, real
error map, no network. Because newApp accepts its dependencies, a test
constructs the exact production application around a test store:
package main
import (
"testing"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/httptest"
)
func testConfig() Config {
return Config{
BaseConfiguration: iris.BaseConfiguration{AllowOrigin: "none"},
Env: "test",
Developer: "tests",
AdminUser: "admin",
AdminPass: "test-password",
}
}
func TestNotesAPI(t *testing.T) {
app := newApp(testConfig(), NewNoteStore())
e := httptest.New(t, app)
e.POST("/api/notes").WithJSON(NoteInput{Text: "hello"}).
Expect().Status(httptest.StatusCreated).
JSON().Object().HasValue("id", 1)
e.GET("/api/notes/42").Expect().Status(httptest.StatusNotFound)
e.GET("/api/admin/stats").WithBasicAuth("admin", "test-password").
Expect().Status(httptest.StatusOK).
JSON().Object().HasValue("notes", 1)
}Chapter 16 expands on this pattern.
When to Use iris.New Instead
iris.New() creates a bare application: no recovery, no error
handling, nothing installed for you. It remains the right tool for
small programs and for setups where you want to control every
registration yourself:
app := iris.New()
app.UseRouter(recovery.Handler)
app.Get("/", func(ctx iris.Context) {
ctx.JSON(iris.Map{"message": "hello"})
})
app.Listen(":8080")For quick prototypes there is also iris.Default(), which is itself
implemented as a builder chain, a good indication of how idiomatic the
builder is:
iris.NewBuilder().
NoPrefix().
AllowOrigin("*").
Compression(true).
LogRequests(false).
NoHealth().
Services().
RouterMiddlewares(requestid.New()).
Middlewares()plus debug-level logging. Note the missing Build(): iris.Default()
hands back an application that has not been built, so routes registered
after the call are still served, exactly as with iris.New(). For
anything you intend to deploy, start from iris.NewBuilder().
Summary
iris.NewBuilder()assembles a production-ready application in one expression; each step returns the next step’s interface, so the order is checked by the compiler.- The chain is
Prefix/NoPrefix,AllowOrigin("none"disables) orCorsfor a fully configured CORS value,Compression,LogRequests/LogRequestsWith,Health/NoHealth(alwaysGET /healthat the root), thenErrors,DeferrablesandServices, thenRouterMiddlewares/Middlewares, thenAPIgroups andBuild. - The first middleware call installs the built-ins once: path parameter
error handling, your error options, recovery, the context error
handler,
Server/X-Developerheaders, compression,errors.Handlerand the rate-limit exceed handler. An unknown path, a wrong method and a failure status with no body all answer therest/errorsJSON envelope. EmptyRouterMiddlewares().Middlewares()calls exist because the type chain must pass through them. Servicesroutes arguments by shape: zero-output functions become closers, values withClosebecome dependency plus closer, configurators apply immediately,nilis skipped, and registration failures panic at startup.- Services implementing
iris.InitializerrunInit(ctx)once insideBuild(), after injection, in registration order; an error aborts the build.Deferrablesis the mirror image at shutdown. Build()panics on misconfiguration by design, and returns the*iris.ApplicationforListen,Runorhttptest.
Further Reading
- MDN: Cross-Origin Resource Sharing (CORS): how browsers police cross-site calls and what a preflight request is.
- MDN: Compression in HTTP:
how clients and servers negotiate response encoding with
Accept-EncodingandContent-Encoding. - MDN: HTTP authentication: the Basic scheme used by the admin API example, and its relatives.
- RFC 7807: Problem Details for HTTP APIs:
the standard error-body format the
errors.Problemrenderer emits. - context (pkg.go.dev): the standard
package behind
Init(ctx),Shutdown(ctx)and cancellation throughout the book.