LaunchKit
← All posts
· 15 min read · by The LaunchKit team · 0 views

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
#rails #api

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.