Requests and Parameters
A controller reads one request object and one params hash. The hash is built from the request body, the query string, and the route's path captures, in that order — so a query value wins over a body field with the same name, and a path capture wins over both.
How params are built#
JSON, urlencoded, and multipart bodies all reach the controller as the same String-keyed hash. Nested fields keep their shape:
# POST /posts/42/comments?page=2, body: {"comment": {"body": "hello"}}
params["page"] # => "2" from the query string
params["comment"] # => {"body" => "hello"}
params["comment"]["body"] # => "hello"
params["post_id"] # => "42" from the routeA JSON object is flattened by field name and nested back the same way, so {"user": {"name": "Sue"}} and an urlencoded user[name]=Sue build the same tree. A JSON array has no slot in the body reader, so its elements are folded into the same [] key and the last one wins; send a nested object when every element must survive.
Request params are mutable and String-keyed. params[:id] is always nil; read params["id"].
Path captures always arrive as Strings, even for an integer column. Convert before the value meets the database:
@post = Post.find(params["id"].to_i)A route can carry a format extension. GET /posts.json matches the /posts route and sets params["format"] to "json"; the extension is not part of the path captures.
Read the request#
The request object exposes the pieces a controller usually needs:
request.request_method # "GET"
request.path # "/posts/42"
request.fullpath # "/posts/42?page=2"
request.host # "example.com"
request.url # "https://example.com/posts/42?page=2"
request.protocol # "https://"
request.remote_ip # "203.0.113.7"
request.user_agent
request.referer
request.get? # true for GET
request.head? # true for HEAD
request.xhr? # true when X-Requested-With is XMLHttpRequest
request.ssl? # true behind a TLS terminator that sets X-Forwarded-ProtoHeaders are a read-only, case-insensitive view. Both the wire name and the internal env key are accepted:
request.headers["Authorization"] # "Bearer …"
request.headers["HTTP_AUTHORIZATION"] # same value
request.headers.fetch("X-Request-Id", "none")
request.headers.to_h # all headers, lowercase names
request.headers.each_header { |name, value| ... }The raw body is an IO, because a parser may need to read it more than once:
request.body.rewind
request.body.read # the whole body
request.raw_post # the same text without touching the cursorDo not read the body in a before_action unless the action is done with it. Parsing already consumed it into params, and a second read from a rewound cursor sees the same bytes.
Shape input before it reaches a model#
ActionController::Params is the narrowing surface over the plain hash. Call it at the controller boundary, where a bad request is still cheap to reject:
class SignupForm
def initialize(raw)
@name = ActionController::Params.require_key(raw, "name")
@email = ActionController::Params.str(raw, "email", "")
end
endrequire_key raises ActionController::ParameterMissing when the key is absent or its value is a blank string. The dispatcher turns that exception into a 400 with {"error":"bad_request"}, so a missing field never reaches the model as nil. sub and str are the forgiving half: an absent key answers {} or the fallback you passed.
permit keeps a declared subset and drops everything else:
ActionController::Params.permit(params, [
:title,
:body,
{ author: [ :name, :email ] },
{ tags: [] }
])
# => { title: "…", body: "…", author: { "name" => "…" }, tags: [...] }The result is Symbol-keyed at the top level; nested hashes keep the keys the request carried. A Symbol filter keeps a scalar value when it is truthy. {} keeps a nested Hash as-is. An Array of Symbols keeps only those keys of a nested Hash, and maps each Hash element of an array through the same list. Unknown fields do not raise — they are dropped, which is the point of a permit list. provided asks the narrower question: did the request carry a usable scalar for this key? A blank string counts; nil and a nested value do not.
Uploads#
A multipart/form-data body puts text parts in params and file parts under the same nested key, as ActionDispatch::Http::UploadedFile:
file = params["avatar"] # the uploaded file
file.original_filename # "portrait.jpg"
file.content_type # "image/jpeg"
file.size # bytes
file.read # the bytes, in memoryA file input nobody filled has an empty filename and is dropped, so params["avatar"] is nil. Uploaded bytes stay in memory until your storage code writes them; there is no disk-backed upload path. A body over 8 MiB is refused before it is read with a 413; a body that stops arriving within 30 seconds gets a 408, and a body that ends early or does not parse is a 400.
Limits#
The request parser enforces its limits before the action runs:
| Limit | Value | Response |
|---|---|---|
| Request body | 8 MiB | 413 |
| Whole-body read | 30 seconds | 408 |
| Nesting depth | 100 levels | 400 |
| Malformed %-escape, invalid UTF-8, mixed array/hash name | — | 400 |
A JSON body that is not an object, or that does not parse, is also a 400. Those answers are JSON, like every other route response.
When a job or another caller needs the current request outside a controller instance, ActionController::Current.request and .controller hold it for the duration of the action.
Authentication reads the same Authorization header. Authentication covers that path.