Every handler receives an iris.Context, and half of its surface exists to
answer one question: what did the client send? This chapter covers that
half: request metadata, typed path and query accessors, body decoding with
the recommended ctx.BindJSON path and the lower-level ctx.Read* family,
pagination input, forms and file uploads, cookies, body size limits, and the
application-level validation hook. Writing responses is the subject of
Chapter 6.
Background
An HTTP request is a small, structured text message. Its first line names
a method and a path: the method says what kind of action the client
intends (GET reads, POST creates, PUT replaces, DELETE removes),
and the path says which resource the action targets. Headers come next,
then optionally a body. Data can arrive in four distinct places, and each
place has a job:
- The path identifies the resource. In
/users/42, the42is a path parameter (part of the URL itself), so it is always present and always text until you convert it. - The query string is everything after the
?. In/search?q=iris&page=2,qandpageare query parameters: they refine a request (filters, sorting, page numbers) without changing which resource it targets. - Headers are key-value metadata about the request: who the client
is (
User-Agent), what credentials it carries (Authorization), what format the body is in (Content-Type). - The body is the payload itself: the JSON document to store, the
form fields, the uploaded file. Only some methods carry one; a
GETusually does not.
The Content-Type header tells the server how to interpret the body’s
bytes, using a media type (also called a MIME type) such as
application/json or text/html. JSON is the dominant one for APIs: a
plain-text format defined by RFC 8259, with exactly six shapes: objects
({}), arrays ([]), strings, numbers, booleans and null.
Because it is plain text, every language can produce and read it, which
is why it became the common tongue between web services.
HTML forms have their own two media types. A simple form posts
application/x-www-form-urlencoded: field names and values joined into
one name=value&... string, essentially a query string moved into the
body. A form with a file input posts multipart/form-data: the body is
split into parts separated by a boundary marker, each part carrying its
own headers, so text fields and raw file bytes travel in one request.
One rule shapes everything in this chapter: never trust the client.
Every piece of a request (path, query, headers, body, cookies, even the
declared Content-Type) is chosen by the sender, and the sender is not
always a well-behaved browser. Anyone can send any bytes with a one-line
curl command, so client-side checks (a form’s required attribute, a
JavaScript validator) are conveniences, not guarantees. Server-side
validation is the real gate, and it is what this chapter’s reading
helpers build in: ctx.BindJSON decodes and validates before your
logic runs, the typed accessors refuse malformed numbers, and body
limits stop a client from streaming gigabytes at your decoder. The
problem that disappears from your code is manual string parsing and
defensive if ladders: handlers receive typed, validated values or
never run at all.
Request Metadata
The basics (method, path, host) are one call away:
package main
import "github.com/kataras/iris/v14"
func main() {
app := iris.New()
app.Get("/info", info)
app.Listen(":8080")
}
func info(ctx iris.Context) {
ctx.JSON(iris.Map{
"method": ctx.Method(), // "GET"
"path": ctx.Path(), // "/info"
"uri": ctx.FullRequestURI(), // "http://localhost:8080/info"
"scheme": ctx.Scheme(), // "http://"
"host": ctx.Host(), // "localhost:8080"
"hostname": ctx.Hostname(), // "localhost"
"subdomain": ctx.Subdomain(), // "" unless e.g. api.example.com
"ip": ctx.RemoteAddr(),
})
}When you need the raw *http.Request, ctx.Request() returns it.
The client address
ctx.RemoteAddr() returns the client IP. By default it parses only the
transport-level Request.RemoteAddr: proxy headers such as
X-Forwarded-For are ignored, because any client can forge them. Enable
them only when your server sits behind a proxy you control:
app := iris.New(iris.Options{
Context: iris.ContextOptions{
RemoteAddrHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
},
})With RemoteAddrHeaders set, RemoteAddr scans the listed headers in
order and returns the first public IP; candidates inside the private
ranges listed in RemoteAddrPrivateSubnets are skipped unless you also
set RemoteAddrHeadersForce. The related ctx.Addr() returns the first
candidate even when it belongs to a private network. Never base security
decisions solely on a client-reported address.
Request headers
ctx.Header(name) reads a request header. The response counterpart
is ctx.ResponseHeader(name), covered in
Chapter 6:
auth := ctx.Header("Authorization")
agent := ctx.Header("User-Agent")One deliberate quirk: when the client repeats a header, ctx.Header
returns all values joined by ", " instead of only the first (which is
what http.Header.Get would do). Code that compares the result against a
secret therefore rejects a request carrying two copies of the header
rather than silently honoring the first one.
Headers also bind to a struct through the header field tag:
type ClientInfo struct {
UserAgent string `header:"User-Agent"`
RequestID string `header:"X-Request-ID"`
}
var info ClientInfo
if err := ctx.ReadHeader(&info); err != nil {
errors.InvalidArgument.Err(ctx, err)
return
}Content type of the request
ctx.ContentType() parses the request’s Content-Type header into a
structured value; ctx.MediaType() returns just the media type string,
and ctx.Charset() and ctx.ContentLength() complete the picture:
ct := ctx.ContentType() // ContentType{MediaType, Charset, Boundary, ...}
mt := ctx.MediaType() // e.g. "application/json"
cs := ctx.Charset() // e.g. "utf-8", "" when absent
n := ctx.ContentLength() // int64, 0 when the header is missingThese are request-side methods. The response equivalents are
ctx.ResponseContentType() and ctx.ResponseMediaType().
Path and Query Parameters
Path parameters
Dynamic route segments ({id:int64}, {slug}; see
Chapter 4 for the macro types) are read
through ctx.Params(). Accessors return the value directly with a
type-specific default on a missing key or failed conversion; there are
no (value, error) tuples:
app.Get("/users/{id:int64}/posts/{slug}", func(ctx iris.Context) {
id := ctx.Params().GetInt64("id") // int64, -1 on failure
slug := ctx.Params().GetString("slug") // string, "" on failure
// ...
})| Accessor | Failure default |
|---|---|
GetString | "" |
GetInt, GetInt64 | -1 |
GetUint, GetUint64 | 0 |
GetFloat64 | -1 |
GetBool | false |
GetUUID | zero uuid.UUID (equal to uuid.Nil()) |
GetDuration | 0 |
GetTime | zero time.Time |
GetIP | zero netip.Addr (IsValid() false) |
When the default is ambiguous (is -1 a real value or a miss?), use
Lookup, which reports presence, or Exists:
if raw, ok := ctx.Params().Lookup("id"); ok {
_ = raw // the stored value, typed by the route macro.
}A request whose typed parameter does not match the macro (say
/users/abc against {id:int64}, or a malformed {id:uuid}) never
reaches your handler; applications built with iris.NewBuilder render a
400 INVALID_ARGUMENT (“invalid path parameter”) response automatically.
The context has a generic method that writes the error response for you
(404 when the parameter is missing, 400 when it is not of type T), so
the handler only handles the happy path:
app.Get("/bookmarks/{id:int64}", func(ctx iris.Context) {
id, ok := ctx.BindParam[int64]("id")
if !ok {
return // error response already sent.
}
// ...
})To bind all path parameters at once, ctx.ReadParams(&dst) decodes into
a struct using the param field tag; ctx.BindParams[S]() does the
same and writes the error response itself, returning (S, bool).
T is the type the macro stored, not the type you would like: {id:int64}
stores an int64, and {id:uuid} stores a parsed uuid.UUID, so a uuid
parameter is read as ctx.BindParam[uuid.UUID]("id") or
ctx.Params().GetUUID("id"). Asking for string there answers 400,
because the assertion fails. ctx.Params().GetString("id") still works
and returns the canonical lowercase text, whatever spelling the client sent.
Query parameters
ctx.Query() returns a read-only, sorted view of the URL query with the
same typed accessors and the same failure defaults as Params():
// GET /search?q=iris&page=2&active=true&tags=go&tags=web
q := ctx.Query().GetString("q") // "iris"
page := ctx.Query().GetInt("page") // 2, -1 if missing or invalid
active := ctx.Query().GetBool("active") // true, false if missing
price := ctx.Query().GetFloat64("max") // -1 if missing
exists := ctx.Query().Exists("q") // true
val, ok := ctx.Query().Lookup("q") // "iris", trueA few getters parse the text into a richer type and return its zero
value on a miss. GetDuration reads ?ttl=1h30m as 90 minutes,
GetTime reads an RFC 3339 timestamp such as ?since=2026-10-10T09:00:00Z,
and GetIP turns ?addr=10.0.0.7 into a netip.Addr whose IsValid()
is false when the value is not an address:
ttl := ctx.Query().GetDuration("ttl") // time.Duration, 0 if missing or invalid
since := ctx.Query().GetTime("since") // time.Time, zero if missing or invalid
addr := ctx.Query().GetIP("addr") // netip.Addr
ref := ctx.Query().GetUUID("ref") // uuid.UUID, uuid.Nil() if missing or invalidRepeated keys are available in two forms:
tags := ctx.Query().GetSlice("tags") // ["go", "web"]: the original
// []string from the query map.
cols := ctx.Query().GetStringSlice("columns") // splits the joined value by
// the configured separator.GetStringSlice splits on ContextOptions.URLParamSeparator (default
","), so ?columns=a,b,c and ?columns=a&columns=b&columns=c both
produce three elements; GetSlice returns the raw multi-value list as
the client sent it. There is no URLParam method family in v14. Use
the Query() accessors or ReadQuery.
Struct binding uses the url tag, and ReadURL combines path parameters
with the query in one call:
type SearchFilter struct {
UserID int `param:"id"` // from the path.
Keyword string `url:"q"` // from the query.
Tags []string `url:"tags"`
Page int `url:"page"`
}
app.Get("/users/{id:int}/search", func(ctx iris.Context) {
var filter SearchFilter
if err := ctx.ReadURL(&filter); err != nil {
errors.InvalidArgument.Err(ctx, err)
return
}
ctx.JSON(filter)
})ctx.ReadQuery(&dst) does the same for the query alone.
Strict query readers
The accessors above are lenient by design: GetInt("page") answers -1
for a missing key and for ?page=banana, and a filter built on them
silently returns the wrong rows instead of telling the client it sent
nonsense. Three readers make that a 400 instead:
// GET /reports?from=2026-01-01&limit=50&detailed=yes
from, ok := ctx.QueryDate("from", iris.QueryRequired())
if !ok {
return // 400 already written: missing, or not a date.
}
limit, ok := ctx.QueryInt("limit", iris.QueryDefault("25"))
if !ok {
return
}
detailed, ok := ctx.QueryBool("detailed")
if !ok {
return
}The second result is the ok of the Bind* family: false means the
response was already written and the handler returns. A present but
unparsable value is always a 400 naming the key and the shape expected,
never a silent zero. An absent value is the zero value unless an option
says otherwise.
| Option | Effect |
|---|---|
iris.QueryRequired() | An absent or blank value is a 400 naming the key |
iris.QueryDefault(raw) | The raw string to parse when the key is absent, stated in the client’s own wire form ("2020-01-02", "25", "true"). It wins over QueryRequired |
iris.QueryZeroValues(v...) | The placeholders older clients send for “unset” ("0", "zero", "0000-00-00", "null"), each read exactly like an absent value |
QueryDate parses types.SimpleDate (2006-01-02, or 2006-1-2) and
QueryBool accepts what strconv.ParseBool accepts plus yes, no,
on and off, case-insensitively. Nothing is special-cased below the
options: ?from=null is a 400 unless QueryZeroValues("null") declared
it, because what a raw value means is the endpoint’s decision and not the
parser’s. An unparsable QueryDefault is a programmer error and panics on
the first request rather than rendering as the client’s fault.
An endpoint that answers to more than one spelling of the same parameter states them once, separated by a space, and each is tried in the order given until one carries a value:
date, ok := ctx.QueryDate("date day from") // ?day=2026-01-02 answers here
if !ok {
return
}A URL query key cannot contain a space, so a key without one is a single
key and reads exactly as it always did. QueryInt and QueryBool take
the same. When no key carried a value the 400 names all of them; when one
carried an unparsable value it names only that one, since the client did
not send the others.
Binding JSON Requests
For JSON APIs the recommended entry point is the generic context method
ctx.BindJSON[T](). It decodes the body, runs the payload’s own
Validate() error method when the type implements it (the
iris.Validatable interface), and on any failure writes a structured
4xx response and returns false. The handler simply returns. You never
call Validate by hand.
The rest/errors/validation package provides small field builders for
those Validate methods:
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors/validation"
)
type CreateUserInput struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
// Implementing Validate makes ctx.BindJSON run it after decoding.
func (in CreateUserInput) Validate() error {
return validation.Join(
validation.String("name", in.Name).NotEmpty().Length(2, 100),
validation.String("email", in.Email).Email(),
validation.Number("age", in.Age).Positive(),
)
}
func createUser(ctx iris.Context) {
input, ok := ctx.BindJSON[CreateUserInput]()
if !ok {
return // 400 already written: malformed JSON or failed Validate.
}
// input is decoded AND valid here.
ctx.Created(input.Name, nil) // see Chapter 6 for responses.
}
func main() {
app := iris.New()
app.Post("/users", createUser)
app.Listen(":8080")
}A failed Validate renders each field error individually:
{
"http_error_code": {"canonical_name": "INVALID_ARGUMENT", "status": 400},
"message": "validation failure",
"detail": "fields were invalid",
"validation": [
{"field": "name", "value": "",
"reason": "must not be empty, must be between 2 and 100 characters"},
{"field": "age", "value": 0, "reason": "must be positive"}
]
}The builders cover strings (NotEmpty, Length, Email, Fullname),
numbers (Positive, Negative, NonZero, InRange) and slices, and
compose with plain functions through Func for custom rules.
validation.Join drops entries that passed, and returns nil when
everything did.
Optional bodies and variants
ctx.BindJSONOptional[T]() behaves like BindJSON except that an empty
request body is a success: it returns the zero value of T with true
and skips validation. Use it when the body is a nice-to-have, such as an
optional filter object.
Two more variants accept a body that may be either a single object or an
array of objects: ctx.BindJSONOneOrMany[T]() and
ctx.BindJSONList[T]() (which normalizes a single object
into a one-element slice). Every element is validated.
Partial updates and the column list
A PATCH sends the body and the list of fields it means to change. The
list travels in the columns query key, and reading it apart from the
body invites the bug the whole family exists to prevent: a typo matches no
column, the update changes nothing, and the client is told it succeeded.
ctx.BindUpdate[T]() reads both and validates them against each other:
func updateUser(ctx iris.Context) {
id, ok := ctx.BindParam[int64]("id")
if !ok {
return
}
input, columns, ok := ctx.BindUpdate[UpdateUserInput]()
if !ok {
return // 400 already written: the body, or an unknown column.
}
updated, err := svc.UpdateUser(ctx, id, input, columns...)
ctx.NoContent(updated, err) // 204, or 304 when nothing changed.
}The body half is BindJSON exactly: the same decoder, the same
RequestValidator hook, the same Validate call. The columns arrive
either as a repeated key (?columns=name&columns=email) or as one
comma-separated value (?columns=name,email); both spellings are
accepted, values are trimmed, empties dropped and duplicates removed,
keeping the client’s order. No columns at all means a full update and
yields a nil slice.
Every column is checked against the JSON member names of T, the json
tag name or the Go field name where there is no tag, and an unknown one
answers 400 naming the column and the names it could have been. The
comparison is exact where the body decoder is case-insensitive, because a
column name reaches an UPDATE statement: the endpoint must receive the
spelling it declared rather than a normalized guess. The names that come
back are still the client’s, so a store that builds SQL from them maps
them to its own columns and never interpolates them.
The Context Read Family
When the payload is not JSON, or when you want to handle the error
yourself instead of letting the helpers respond, the context offers a
Read* method per format. Each decodes into a pointer, using a struct
tag that matches the source:
| Method | Source | Struct tag |
|---|---|---|
ctx.ReadJSON(&v, opts...) | JSON body | json |
ctx.ReadXML(&v) | XML body | xml |
ctx.ReadYAML(&v) | YAML body | yaml |
ctx.ReadMsgPack(&v) | MessagePack body | — |
ctx.ReadProtobuf(msg) | Protobuf body (proto.Message) | — |
ctx.ReadForm(&v) | URL-encoded or multipart form | form |
ctx.ReadQuery(&v) | URL query | url |
ctx.ReadParams(&v) | path parameters | param |
ctx.ReadHeader(&v) | request headers | header |
ctx.ReadURL(&v) | path parameters + query | param, url |
ctx.ReadBody(&v) | dispatched by content type | depends |
XML and YAML are, like JSON, human-readable text formats with different syntax. MessagePack and Protobuf are binary encodings: they trade readability for smaller, faster payloads and are common in service-to-service traffic rather than browser-facing APIs.
ctx.ReadJSON accepts optional iris.JSONReader options:
var data map[string]any
err := ctx.ReadJSON(&data, iris.JSONReader{
DisallowUnknownFields: true, // reject keys with no struct field.
})Decoding runs on the standard library’s encoding/json/v2 (Go 1.27).
Two of its stricter rules apply to every reader: a body with duplicate
object keys or invalid UTF-8 is rejected (the bind helpers render that
as a 400). One rule is deliberately relaxed: member names still match
struct fields case-insensitively, as deployed clients expect from the
old encoding/json; set JSONReader.CaseSensitive to restore v2’s
strict matching for a call. The UnmarshalOptions field takes raw
encoding/json/v2 options, appended after the defaults so yours win:
jsontext.AllowDuplicateNames(true), for example, accepts duplicate
members again.
For large arrays, ctx.ReadJSONStream decodes one object at a time
instead of buffering the whole body:
var users []User
err := ctx.ReadJSONStream(func(decode iris.DecodeFunc) error {
var u User
if err := decode(&u); err != nil {
return err
}
users = append(users, u)
return nil
})ReadBody: content-type dispatch
ctx.ReadBody(&v) picks the decoder for you. For GET requests it
reads from the URL query (falling back to the form). For other methods
it switches on the request’s media type: XML, YAML, url-encoded or
multipart forms, JSON, Protobuf and MessagePack are dispatched to the
matching Read* method; an unrecognized type falls back to the query
(when one exists) and finally to JSON.
var raw []byte
if err := ctx.ReadBody(&raw); err != nil { // raw body, no decoding.
errors.InvalidArgument.Err(ctx, err)
return
}The lowest level is ctx.Body(), which returns the body as []byte.
Like any HTTP body, it can be consumed once: a later ReadJSON on the
same request finds it empty.
Empty-input behavior: ReadForm, ReadQuery and ReadBody return a
nil error on empty input by default, leaving the struct at its zero
value. Set ContextOptions.FireEmptyFormError to make them return
iris.ErrEmptyForm instead. ReadForm errors on unknown client fields;
ignore that class with err != nil && !iris.IsErrPath(err).
Pagination Input
Returning an entire table from one endpoint does not scale: the response grows without bound and most clients display a screenful at a time. Pagination splits the list into fixed-size pages the client requests one by one; in database terms, the page number and size translate into how many rows the query fetches and how many it skips first.
List endpoints usually accept ?page=&size=&details= plus an optional
JSON filter body. ctx.BindPagination reads both at once:
type BookFilter struct {
Author string `json:"author"`
}
func listBooks(ctx iris.Context) {
opts, filter, ok := ctx.BindPagination[BookFilter]()
if !ok {
return // error response already sent.
}
// opts.GetLimit(), opts.GetOffset() feed the database query.
books, total, err := svc.Books(ctx, opts, filter)
ctx.OK(iris.NewPage(opts, &filter, books, total), err)
}iris.PageOptions carries Page, Size and Details; GetLimit
returns Size bounded by pagination.MaxPageSize (falling back to
pagination.DefaultPageSize, 100) and GetOffset derives the offset
from the page number. Set the two bounds once at startup, either
directly or through iris.Patches().Context().SetMaxPageSize(n) and
SetDefaultPageSize(n). An empty body yields the zero filter. The
filter is optional by design.
A filter body on a read raises the question of which method carries it.
GET has no defined body semantics, and POST says the request changes
something, which it does not. HTTP QUERY (RFC 10008) is the method made
for this: safe and idempotent like GET, with a body like POST, and its
response is cacheable. Iris registers it with app.Query, so one path
can answer both shapes:
app.Get("/books", listBooks) // ?page=&size= only.
app.Query("/books", listBooks) // ?page=&size= plus a filter body.Send the QUERY filter as JSON. On Go 1.27, net/http parses a
url-encoded body only for POST, PUT and PATCH, so a form-encoded QUERY
body never reaches ctx.Form*.
The page types themselves (PageOptions, Page[T], NewPage,
ListFilter, CountResponse[N]) live in core/pkg/pagination, a
package that imports only the standard library. iris and context
alias them, so iris.Page[T] and pagination.Page[T] are the same
type, and the client package decodes a list into that type too.
ctx.Paginated collapses the whole flow (read options and filter, run
a collector, respond with a iris.Page[T]) into one call:
import (
"context"
"github.com/kataras/iris/v14"
)
func listBooks(ctx iris.Context) {
ctx.Paginated(func(reqCtx context.Context,
opts iris.PageOptions, filter BookFilter) ([]Book, int64, error) {
return svc.Books(reqCtx, opts, filter)
})
}The response shape (current_page, page_size, total_pages,
total_items, has_next_page, filter, items) is covered in
Chapter 6.
The shared filter, and who chooses the sort
Most list filters carry the same four or five members before they carry
anything of their own. iris.ListFilter is that half, ready to embed:
type BookFilter struct {
iris.ListFilter
Author string `json:"author,omitzero" url:"author"`
}It fields Term, Names, Substring, Sort and SortDescending, and
carries both tag sets on purpose: the json names for a QUERY or
POST list body and the url names for a GET list route read
through ReadQuery. f.SortColumn(allowed...) returns the requested column or an error
wrapping iris.ErrSortNotAllowed, and f.SortOrder() returns "ASC" or
"DESC".
A sort column reaches an ORDER BY clause, so which columns a list offers
belongs to the endpoint and never to the request. Two options say so
declaratively, and both answer 400 before the collector runs:
func listBooks(ctx iris.Context) {
ctx.Paginated(func(reqCtx context.Context,
opts iris.PageOptions, filter BookFilter) ([]Book, int64, error) {
return svc.Books(reqCtx, opts, filter)
}, iris.AllowSort("title", "published_at"))
}iris.AllowSort(columns...) limits the column a client may name, and the
rejection names both the column and the allowed ones.
iris.DenySort(message) refuses any sort at all, for a list whose order
the query itself fixes. Both work on ctx.BindPagination too, and both
read the decoded filter through its SortRequest method (the
pagination.SortRequester interface), which ListFilter provides, so a
filter without one is simply unaffected.
Sorting is often a permission rather than a shape. A list a client may read but not order should answer 403, not 400, and any error code carries the rejection:
list, filter, ok := ctx.BindPagination[BookFilter](
errors.PermissionDenied.DenySort("sorting is not available on this plan"))
if !ok {
return
}errors.ErrorCodeName.DenySort, from rest/errors, is iris.DenySort
answered with that code and is otherwise identical.
A collector may take its filter by pointer, which is the shape a repository method already has, so it can be passed as a bare method value:
ctx.Paginated(svc.ListBooksPaginated) // (context.Context, iris.PageOptions, *BookFilter) ([]Book, int64, error)Such a collector never receives nil. A request with no body decodes to the
filter’s zero value, so the repository reads an empty filter and the sort
policy still has a real SortRequest to read.
Forms and File Uploads
Form values
HTML form submissions (application/x-www-form-urlencoded or
multipart/form-data) bind through the form tag:
type ContactForm struct {
Name string `form:"name"`
Email string `form:"email"`
Message string `form:"message"`
}
func contact(ctx iris.Context) {
var form ContactForm
if err := ctx.ReadForm(&form); err != nil && !iris.IsErrPath(err) {
errors.InvalidArgument.Err(ctx, err)
return
}
// ...
}Individual values are available through ctx.Form() (query + body
values) and ctx.PostForm() (body values only), which return the same
read-only entries view as ctx.Query(): GetString, GetInt,
GetSlice and friends all apply.
Multipart form parsing buffers up to ContextOptions.PostMaxMemory
in memory (default 32 MB, spillover goes to temporary files). Raise or
lower it through iris.Options{Context: iris.ContextOptions{...}}.
File uploads
An uploaded file’s name is client input like any other. A malicious
client can submit a name such as ../../etc/crontab, hoping the server
joins it to the upload directory and writes outside it, an attack known
as path traversal. Sanitizing the name before it touches the filesystem
is therefore not optional, and the helper below does it for you.
ctx.UploadFormFiles is the batteries-included path: it saves every
uploaded file into a destination directory, sanitizes file names against
an allowlist pattern and blocks path-traversal attempts (unsafe names
are silently skipped). The optional callback filters or renames files
before saving:
import (
"mime/multipart"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
)
func upload(ctx iris.Context) {
files, total, err := ctx.UploadFormFiles("./uploads",
func(ctx iris.Context, file *multipart.FileHeader) bool {
return file.Size <= 8*iris.MB // reject anything larger.
})
if err != nil {
errors.InvalidArgument.Err(ctx, err)
return
}
ctx.JSON(iris.Map{"uploaded": len(files), "bytes": total})
}For manual control, ctx.FormFile(key) returns the first uploaded file
for a form key and ctx.FormFiles(key, ...) all of them; pair either
with ctx.SaveFormFile(header, dest) to write to disk. When building
the destination path yourself, sanitize the client-supplied name first.
context.SafeFilename implements the same checks UploadFormFiles
uses: it strips every directory component, holds the result to an
allowlist of characters, rejects the reserved Windows device names
(NUL, COM1, CON.txt and the rest, which open a device rather than a
file), and resolves the destination directory before checking that the
final path is inside it. The upload endpoints are a natural place for the
body limits described below.
Cookies
A cookie is a small named value the server asks the browser to store; the browser then attaches it to every later request to the same site. That echo is what gives stateless HTTP a memory: it is how a login survives from one request to the next.
Two readers cover the request side:
// Just the value; "" when the cookie is missing.
session := ctx.CookieValue("session_id")
// The full *http.Cookie; http.ErrNoCookie when missing.
cookie, err := ctx.Cookie("session_id")
if err == nil {
_ = cookie.Value
}CookieValue URL-unescapes the value for you. Setting, upserting and
removing cookies happens on the response and is covered in
Chapter 6.
Body Size Limits
Unbounded request bodies are a denial-of-service vector: a client can
stream gigabytes into ReadJSON or hide them behind a compressed body.
Iris offers an application-wide cap and a per-route middleware.
Application-wide: ContextOptions.BodyMaxMemory limits the total
bytes read by Body, ReadJSON, ReadJSONStream and
ReadMultipartRelated. That cap therefore also covers everything built
on them, including ReadXML, ReadYAML, ReadBody and ctx.BindJSON:
app := iris.New(iris.Options{
Context: iris.ContextOptions{
BodyMaxMemory: 4 * iris.MB,
},
})The default 0 means no limit. Reading past the cap fails with
context.ErrRequestEntityTooLarge (package
github.com/kataras/iris/v14/context), which the rest/errors package
renders as a 413 TOO_LARGE response.
Per route or group: the bodylimit middleware applies the same
enforcement wherever it is registered, typically on upload routes:
import "github.com/kataras/iris/v14/middleware/bodylimit"
media := app.Router("/media", bodylimit.New(20*iris.MB))
media.Post("/upload", upload)It rejects requests up front when the declared Content-Length already
exceeds the limit, and wraps the body reader to catch chunked or
compressed bodies that only reveal their size while being read.
PostMaxMemory is a different knob: it bounds how much of a multipart
form is buffered in memory during parsing, not how large the body may
be. Use both where appropriate.
The RequestValidator Hook
The per-type Validate() error method with ctx.BindJSON is the
recommended validation path. For teams standardizing on a tag-based
validation library instead, the context exposes one application-level
hook: ContextOptions.RequestValidator, any value implementing
type Validator interface {
Struct(any) error
}It defaults to nil, so validate:"..." struct tags do nothing on their
own. When set, every decoding Read* method (ReadJSON, ReadXML,
ReadYAML, ReadMsgPack, ReadForm, ReadQuery, ReadParams,
ReadURL, ReadHeader, ReadBody) calls it after a successful decode
and returns its error. Wiring a third-party library is a three-line
adapter:
import (
"github.com/go-playground/validator/v10" // third-party.
"github.com/kataras/iris/v14"
)
type structValidator struct{ v *validator.Validate }
func (s structValidator) Struct(ptr any) error { return s.v.Struct(ptr) }
func main() {
app := iris.New(iris.Options{
Context: iris.ContextOptions{
RequestValidator: structValidator{validator.New()},
},
})
// ...
}The two mechanisms compose: ctx.BindJSON surfaces RequestValidator
failures through the same structured 400 responses it uses for a failed
Validate() method, so handlers keep the single if !ok { return }
guard either way.
Summary
ctx.Method(),ctx.Path(),ctx.Host()and friends expose request metadata;ctx.RemoteAddr()trusts proxy headers only whenRemoteAddrHeadersis configured.ctx.Header(name)reads a request header (repeated values joined by", ");ctx.ContentType()/ctx.MediaType()describe the request body’s declared type.ctx.Params()andctx.Query()share typed accessors with fixed failure defaults (GetString→"",GetInt→-1);LookupandExistsdistinguish missing from zero;GetSlicereturns raw multi-values.ctx.BindJSON[T]()is the recommended body reader: it decodes, runs the type’sValidate() errorautomatically, and writes structured 4xx responses.ctx.BindJSONOptional[T]()accepts an empty body.- The
ctx.Read*family covers JSON, XML, YAML, MessagePack, Protobuf, forms, query, params and headers;ctx.ReadBodydispatches by content type and fills*string/*[]bytewith the raw body. ctx.BindPaginationandctx.Paginatedturn?page=&size=plus an optional filter body into a complete list endpoint.ctx.UploadFormFilessaves multipart uploads with sanitized names;PostMaxMemorybounds form buffering,BodyMaxMemoryand thebodylimitmiddleware cap body size (413 on overflow).ContextOptions.RequestValidatoris the app-wide hook for tag-based validation libraries; without it,validatetags are inert.
Further Reading
- MDN: HTTP messages. The anatomy of requests and responses: start line, headers, body.
- MDN: MIME types:
how media types like
application/jsonandmultipart/form-datawork. - RFC 9110: HTTP Semantics: the authoritative definition of methods, headers and message bodies.
- RFC 8259: JSON: the complete JSON grammar; shorter than most tutorials about it.
- OWASP: Input Validation Cheat Sheet: server-side validation strategy, allowlists and common pitfalls.
- OWASP: File Upload Cheat Sheet: the risks behind upload endpoints and the checks that mitigate them.