A Rails REST API, from rails new to the errors nobody shows you
The tutorial you are looking for ends at render json: @post and a green 201, and every interesting
thing about a Rails REST API happens after that: what the client gets when the id does not exist,
what happens to the admin: true somebody posted at you, what the same endpoint answers in
production that it did not answer in development. This page builds the five actions, then spends
most of its length on the four requests that do not go well.
Everything below ran on one machine on 2026-09-27: Ruby 4.0.5, Rails 8.1.3.1, Rack 3.2.7, Puma
8.0.2, PostgreSQL 17.7 on port 15432, Apple M2 Max with 12 cores. The application was generated with
rails new rails-rest-api --api -d postgresql and scaffolded with bin/rails generate scaffold Post
title:string body:text published:boolean. Fifteen integration tests hold the claims up and their
output is at the bottom.
The first POST returned 400, and the cause was in the JSON gem
Before any of the REST content, the wrong turn, because it was the first thing that happened in this build and nothing about it points at the cause. A freshly generated API application, a freshly migrated table, and this:
$ curl -i -X POST http://127.0.0.1:3017/posts \
-H 'Content-Type: application/json' \
--data-binary '{"post":{"title":"First","body":"hello","published":true}}'
HTTP/1.1 400 Bad Request
content-type: application/json; charset=UTF-8
The body was a 22 KB debug blob whose first useful line named
ActionDispatch::Http::Parameters::ParseError: Error occurred while parsing request parameters. The
JSON was valid. development.log printed the body back at me verbatim and then the real cause:
Error occurred while parsing request parameters.
Contents:
{"post":{"title":"First","body":"hello","published":true}}
Processing by PostsController#create as */*
Completed 400 Bad Request in 0ms
ActionDispatch::Http::Parameters::ParseError (Error occurred while parsing request parameters)
Caused by: ArgumentError (wrong number of arguments (given 2, expected 1))
The bare rescue at actionpack-8.1.3.1/lib/action_dispatch/http/parameters.rb:96, four lines
inside parse_formatted_parameters, catches everything the parser throws, so every failure in the
JSON parser arrives as one opaque ParseError and the real exception only shows up as a Caused by:
line in the log. The real exception was an arity error:
activesupport-8.1.3.1/lib/active_support/json/decoding.rb:25 is data = ::JSON.parse(json,
options), passing its options hash positionally, and json 3.0.2 signs parse as def parse(source,
on_load: nil, object_class: nil, array_class: nil, **options). Keywords only. A positional second
argument is an ArgumentError even when the hash is empty, which it is by default.
$ bin/rails runner 'p ActiveSupport::JSON.decode(%q({"a":1}))'
ArgumentError
"wrong number of arguments (given 2, expected 1)"
["json-3.0.2/lib/json/common.rb:296:in 'JSON.parse'",
"activesupport-8.1.3.1/lib/active_support/json/decoding.rb:25:in 'ActiveSupport::JSON.decode'", ...]
The condition for reproducing it is specific and worth stating: json 3.0.2 was installed on this
machine, nothing in a generated Gemfile constrains json, and Bundler took the newest it could see.
Ruby 4.0.5's own default json is 2.18.0, so a machine that has never installed json 3 will not hit
this. gem "json", "2.21.2" in the Gemfile fixed it, and the same POST answered 201 on the next
boot. Check your lockfile before you debug your controller: grep '^ json ' Gemfile.lock is a
faster first move than reading the backtrace.
The five actions resources :posts draws in an API application
resources :posts in an api_only application does not give you the seven RESTful actions the
guides recite. It gives you five, across six routes:
$ bin/rails routes -g posts
Prefix Verb URI Pattern Controller#Action
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
new and edit are gone because they exist to render forms, and the decision is one method at
actionpack-8.1.3.1/lib/action_dispatch/routing/mapper.rb:1308:
def default_actions(api_only)
if api_only
[:index, :create, :show, :update, :destroy]
else
[:index, :create, :new, :show, :update, :destroy, :edit]
end
end
PUT and PATCH both land on update, and Rails has no opinion about the difference between them.
Nothing in the generated action distinguishes a replacement from a partial modification: both call
@post.update(post_params), which is a partial modification. If your API contract says PUT
replaces the resource, you write that yourself, and a client that sends PUT expecting unlisted
fields to be nulled will be quietly wrong forever. The test in this build sends both verbs at the
same record and asserts both took effect.
What the generated controller actually contains
The API scaffold writes five files and no views:
invoke active_record
create db/migrate/20260927153155_create_posts.rb
create app/models/post.rb
invoke test_unit
create test/models/post_test.rb
create test/fixtures/posts.yml
invoke resource_route
route resources :posts
invoke scaffold_controller
create app/controllers/posts_controller.rb
invoke resource_route
invoke test_unit
create test/controllers/posts_controller_test.rb
The controller is worth reading rather than skimming, because three of its lines are newer than most
of the tutorials that describe them. This is app/controllers/posts_controller.rb exactly as
generated:
class PostsController < ApplicationController
before_action :set_post, only: %i[ show update destroy ]
# GET /posts
def index
@posts = Post.all
render json: @posts
end
# POST /posts
def create
@post = Post.new(post_params)
if @post.save
render json: @post, status: :created, location: @post
else
render json: @post.errors, status: :unprocessable_content
end
end
# DELETE /posts/1
def destroy
@post.destroy!
end
private
def set_post
@post = Post.find(params.expect(:id))
end
def post_params
params.expect(post: [ :title, :body, :published ])
end
end
destroy renders nothing on purpose. ActionController::BasicImplicitRender#default_render is head
:no_content, so a DELETE comes back as an empty 204, which is what the curl shows and what the
test asserts. location: @post produces a full absolute URL from the request host, location:
http://127.0.0.1:3017/posts/1, not a path. And status: :unprocessable_content comes from
ActionDispatch::Constants::UNPROCESSABLE_CONTENT, interpolated into the template at
railties-8.1.3.1/lib/rails/generators/rails/scaffold_controller/templates/api_controller.rb.tt:24.
:unprocessable_entity is a Rack deprecation, not an error
Copying status: :unprocessable_entity out of an older tutorial still returns 422 on Rack 3.2.7, and
prints this on the way:
warning: Status code :unprocessable_entity is deprecated and will be removed in a future version of
Rack. Please use :unprocessable_content instead.
A body with no root key still works, and that is ParamsWrapper
params.expect(post: [...]) requires a post key in the parameters, and a client that sends a flat
object never provided one. It works anyway:
$ curl -i -X POST http://127.0.0.1:3017/posts \
-H 'Content-Type: application/json' -d '{"title":"No root key here"}'
HTTP/1.1 201 Created
location: http://127.0.0.1:3017/posts/2
{"id":2,"title":"No root key here","body":null,"published":null,...}
ActionController::API::MODULES.include?(ActionController::ParamsWrapper) is true, and
wrap_parameters_by_default = true sits in the when "7.0" branch of
railties-8.1.3.1/lib/rails/application/configuration.rb:267, so a JSON body is re-wrapped
under a key derived from the controller name before your action sees it.
PostsController._wrapper_options.format is [:json], which is why the same trick does not happen
for a form-encoded body.
The cost is that your API now accepts two request shapes and documents one. Clients written against
the flat shape keep working until somebody sets wrap_parameters format: [], and clients written
against the wrapped shape are the ones your tests cover. Pick one, write it down, and know that
Rails will keep accepting the other.
params.expect drops unknown keys and tells nobody
Post an id and an admin flag at an endpoint that permits neither:
$ curl -i -X POST http://127.0.0.1:3017/posts -H 'Content-Type: application/json' \
-d '{"post":{"title":"Unpermitted","id":999,"admin":true}}'
HTTP/1.1 201 Created
location: http://127.0.0.1:3017/posts/3
{"id":3,"title":"Unpermitted","body":null,"published":null,...}
Both extra keys were dropped, which is the point of strong parameters and is correct. What is not
obvious is that development.log says nothing about it. The log records
Parameters: {"post" => {"title" => "Unpermitted", "id" => 999, "admin" => true}} and then an
INSERT of three columns, with no Unpermitted parameters line between them, even though
ActionController::Parameters.action_on_unpermitted_parameters is :log in this environment.
expect is why. Compare the two idioms on the same input, with a subscriber attached:
ActiveSupport::Notifications.subscribe("unpermitted_parameters.action_controller") { |*a| puts "NOTIFIED: #{a.last[:keys].inspect}" }
raw = { "post" => { "title" => "t", "id" => 9, "admin" => true } }
ActionController::Parameters.new(raw).expect(post: [:title, :body, :published]).to_h
ActionController::Parameters.new(raw).require(:post).permit(:title, :body, :published).to_h
-- expect
{"title" => "t"}
-- require+permit
NOTIFIED: ["id", "admin"]
{"title" => "t"}
The mechanism is one missing argument, in
actionpack-8.1.3.1/lib/action_controller/metal/strong_parameters.rb. permit at line 669 passes
on_unpermitted: self.class.action_on_unpermitted_parameters. expect at line 773 calls
permit_filters(filters) with no on_unpermitted: at all, so it defaults to nil, and
unpermitted_parameters! at line 1272 opens with return unless on_unpermitted. Setting
action_on_unpermitted_parameters = :raise changes nothing for expect: it still returned
{"title" => "t"} and raised nothing, where require(:post).permit(:title) raised
ActionController::UnpermittedParameters: found unpermitted parameter: :admin on the same input.
expect is still the right default, and the reason is the other half of the comparison. Send
{"post": ["title"]}, an array where the controller expects an object, and expect answers 400
while the old idiom raises NoMethodError: undefined method 'permit' for an instance of Array,
which is a 500 any client can trigger from the outside. Both behaviours are in the test file. What
you give up for that is the log line, and the log line is how you find out that a client has been
sending you a field you never implemented. If you want it back, set action_on_unpermitted_parameters
to :raise in the test environment only and keep using require(:post).permit(...) in the one
endpoint whose client you do not control.
In production every error is an empty body with a text/html header
Boot the same application with RAILS_ENV=production and ask it for a record that does not exist.
Development answered with a 404 carrying a JSON debug blob and a backtrace. Production answers this:
$ curl -i -H 'X-Forwarded-Proto: https' http://127.0.0.1:3018/posts/9999
HTTP/1.1 404 Not Found
content-type: text/html; charset=UTF-8
content-length: 0
Same for a missing param, same for a malformed body, same for an unrouted path: correct status,
text/html, zero bytes. rails new --api empties public/, which after generation holds
robots.txt and nothing else, so ActionDispatch::PublicExceptions has no file to serve and the
client gets a content type it cannot parse wrapped around nothing. There is no message to show a
user and no field to branch on.
The X-Forwarded-Proto: https header on those requests is not part of the error behaviour.
config/environments/production.rb sets config.force_ssl = true on line 28, so a plain HTTP
request to a local production boot is answered with a 301 before it reaches anything, and the header
stands in for the proxy that would normally terminate TLS.
Three of the four are exceptions raised inside the controller, so rescue_from reaches them. The
whole fix is one file:
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordNotFound do |error|
render json: { error: "not_found", detail: error.message }, status: :not_found
end
rescue_from ActionController::ParameterMissing do |error|
render json: { error: "bad_request", detail: error.message }, status: :bad_request
end
rescue_from ActionDispatch::Http::Parameters::ParseError do |error|
render json: { error: "malformed_json", detail: error.message }, status: :bad_request
end
end
The ParseError handler surprised me and is the one worth keeping. Parsing looks like middleware
work, but params is lazy: the parser runs the first time an action touches params, which is
inside the controller, which is inside the rescue_from chain. After that file, in production:
-- missing id
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
{"error":"not_found","detail":"Couldn't find Post with 'id'=\"9999\""}
-- empty body
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
{"error":"bad_request","detail":"param is missing or the value is empty or invalid: post"}
-- bad json
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
{"error":"malformed_json","detail":"Error occurred while parsing request parameters"}
-- unknown route
HTTP/1.1 404 Not Found
content-type: text/html; charset=UTF-8
Three fixed, one unchanged. GET /nope never reaches a controller, so no rescue_from anywhere can
see it, and the routing error becomes a bare 404 in the middleware. The catch-all is a route:
match "*unmatched", to: "errors#not_found", via: :all
class ErrorsController < ApplicationController
def not_found
render json: { error: "not_found", detail: "No route matches #{request.method} #{request.path}" },
status: :not_found
end
end
$ curl -i -H 'X-Forwarded-Proto: https' http://127.0.0.1:3018/nope
HTTP/1.1 404 Not Found
{"error":"not_found","detail":"No route matches GET /nope"}
$ curl -i -X POST -H 'X-Forwarded-Proto: https' http://127.0.0.1:3018/posts/1
HTTP/1.1 404 Not Found
{"error":"not_found","detail":"No route matches POST /posts/1"}
Two costs, and both are real. The catch-all goes last in config/routes.rb and swallows anything you
mount below it, including an engine somebody adds in month six. And error.message leaks your model
names to the outside: Couldn't find Post with 'id'="9999" tells a caller your table is called
posts. For an internal API that is a feature. For a public one, render a fixed string and put the
message in the log. The deeper behaviour of rescue_from itself, including the reverse declaration
order that makes a generic handler shadow a specific one, is in
Rails error handling.
Post.all in index is the line that decides your throughput
The generated index is render json: Post.all, with no limit, and it is the one line in the
scaffold that is wrong rather than merely unfinished. With 1000 rows in the table, in production
mode, against Puma 8.0.2 in single mode with 3 threads, ApacheBench at 500 requests and concurrency
4, on an idle M2 Max:
$ /usr/sbin/ab -n 500 -c 4 'http://127.0.0.1:3018/all_posts'
Document Length: 202183 bytes
Requests per second: 37.27 [#/sec] (mean)
Time per request: 107.339 [ms] (mean)
min mean[+/-sd] median max
Total: 24 107 20.9 101 194
$ /usr/sbin/ab -n 500 -c 4 'http://127.0.0.1:3018/posts?limit=25'
Document Length: 4963 bytes
Requests per second: 632.64 [#/sec] (mean)
Time per request: 6.323 [ms] (mean)
min mean[+/-sd] median max
Total: 3 6 2.4 6 19
Seventeen times the throughput, and the two endpoints run the same code with a different limit.
Both were warmed with 100 requests first, and the loopback interface is doing no real network work,
so the gap on a real deployment is wider than this, not narrower. The scaffold's version was serving
202 KB per request for a client that wanted the first screenful.
MAX_PAGE = 100
def index
limit = params.fetch(:limit, 25).to_i.clamp(1, MAX_PAGE)
posts = Post.order(:id).limit(limit).offset(params.fetch(:offset, 0).to_i)
render json: posts
end
clamp(1, MAX_PAGE) is the part that matters and the part people leave out. Without it, limit is
a number the caller chooses, and ?limit=1000000 is a denial of service anybody can send from a
browser address bar. order(:id) is not decoration either: an unordered limit/offset pair is
allowed to return the same row on two consecutive pages, because PostgreSQL makes no promise about
row order without an ORDER BY. Offset pagination degrades on large tables, since the database still
walks the skipped rows, and the replacement is a keyset cursor on id. At 1000 rows that trade does
not exist yet, which is why this page stops at offset.
There is no content negotiation
GET /posts/1 with Accept: application/xml returns JSON. So does GET /posts/1.xml:
-- accept xml
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
-- format xml in url
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
render json: sets the content type itself and never consults the request format, and
ActionController::API does not include ActionController::MimeResponds, so there is no
respond_to block available to consult it either. A REST API built this way speaks exactly one
format, which is almost always what you wanted, and is worth knowing before you promise a client an
XML endpoint. What render json: does with the object once it has it, including the as_json
options that ride through it and the ones that get ignored, is a separate chain covered in
render json, and the bytes it actually sends.
The call, and what would change it
Generate the scaffold, then change three things before you ship it: put a limit on index, put
rescue_from in ApplicationController, and add the catch-all route. That is about twenty lines and
it is the whole difference between a REST API that demos and one that a client library can talk to.
Do not reach for a serializer gem, a JSON:API adapter or a versioning scheme on day one; the version
namespace is cheap to add and the serializer question does not become real until a response has two
consumers.
What would change that: a client you do not control. The moment the caller is a mobile build you
cannot recall, the URL version stops being optional and the response shape stops being a projection
of your table. expect would also stop being the obvious default if Rails gave it an
on_unpermitted: argument, which would let a single endpoint keep the strict typing and get the log
line back.
What this page does not cover
Authentication, which is the next thing every one of these endpoints needs and is not a paragraph;
a bearer token on top of this controller stack is
Rails JWT API authentication.
The middleware --api removes and what each piece costs to put back is
API-only mode. CORS, which no request here
needed because nothing was a browser. Rate limiting. Nested resources, shallow: true, and
only:/except: on resources, none of which this build drew. Keyset pagination, Link headers
and any pagination envelope: the measurement above compares two page sizes, not two pagination
designs. And no gem was installed beyond pinning json, so nothing here is a claim about
active_model_serializers, alba, jsonapi-serializer, pagy or kaminari.
The fifteen integration tests behind the claims above:
$ bin/rails test test/integration/rest_api_test.rb
Running 15 tests in a single process (parallelization threshold is 50)
Run options: --seed 2359
# Running:
...............
Finished in 0.138277s, 108.4779 runs/s, 332.6656 assertions/s.
15 runs, 46 assertions, 0 failures, 0 errors, 0 skips
Comments
No comments yet. Be the first.