Build a Structured Search API with Rails' New HTTP QUERY Method

Note: HTTP QUERY support is coming in Rails 8.2. It’s merged to main (PR #57973) but not in a released gem yet. The latest release is 8.1.3, so the API can still shift before it ships. To try it, point your Gemfile at the main branch:

gem "rails", github: "rails/rails", branch: "main"

That pulls in all of main’s unreleased changes, not just this one. Don’t do it on an app you can’t afford to break.


Every search API I’ve built eventually hits the same wall. It starts as GET /products?category=audio, then grows facets, price ranges, multi-column sorts, and pagination, and soon the URL is a 600-character string of filters%5Bbrand%5D%5B%5D= that nobody can read. The usual escape hatch is POST /products/search, which works, but now a read-only search looks like a write: it isn’t safe, isn’t idempotent, and retries and caches treat it with suspicion.

HTTP finally has a verb for this. RFC 10008 defines QUERY: safe and idempotent like GET, but with the query in the request body. Rails 8.2 supports it end to end: routing, a request predicate, CSRF handling, and an integration test helper. Let’s build a product search on it.

What We’re Building

A QUERY /products/search endpoint for a small storefront. The client sends a JSON body with facets, a price range, a minimum rating, an in-stock flag, a multi-key sort, and a page number. The server returns matching products. By the end we’ll have a route, a search object, a controller, an integration test, a fetch client, and a Puma config that lets it all through.

The Rails Feature That Makes This Clean

The PR teaches each layer of Action Pack about the new verb:

# config/routes.rb
query "products/search", to: "product_searches#show"
match "filter", to: "search#filter", via: :query   # same thing, long form

# in a controller
request.query?                # => true
request.request_method_symbol # => :query

# in an integration test
query "/products/search", params: { filters: { category: ["audio"] } }, as: :json

Two quieter details make it production-ready:

  • No CSRF token needed. QUERY is exempt from forgery protection, like GET and HEAD. HTML forms can’t send QUERY, and a cross-origin QUERY always triggers a CORS preflight, so a browser can’t forge one for an attacker. A POST tunneled through _method=query is still checked like any other POST.
  • Body parsing just works. Rails parses params based on Content-Type, not the method, so JSON and form-encoded QUERY bodies arrive in params like any POST body.

Building It

The Model

A plain product table:

class CreateProducts < ActiveRecord::Migration[8.2]
  def change
    create_table :products do |t|
      t.string  :name, null: false
      t.string  :category, null: false
      t.string  :brand, null: false
      t.integer :price_cents, null: false
      t.decimal :rating, precision: 2, scale: 1
      t.boolean :in_stock, default: true, null: false
      t.timestamps
    end
  end
end
class Product < ApplicationRecord
end

The Search Object

The request body has structure, so the code that reads it gets its own home. ProductSearch takes permitted params and turns each filter into a scope. Sort fields go through an allowlist, so a client can never sort by a column we didn’t choose.

# app/models/product_search.rb
class ProductSearch
  SORTABLE = { "price" => :price_cents, "rating" => :rating, "name" => :name }.freeze
  PER_PAGE = 20

  def initialize(params)
    @filters = params.fetch(:filters, {})
    @sort = Array(params[:sort])
    @page = [params.fetch(:page, 1).to_i, 1].max
  end

  def results
    scope = Product.all
    scope = scope.where(category: @filters[:category]) if @filters[:category].present?
    scope = scope.where(brand: @filters[:brand]) if @filters[:brand].present?
    scope = scope.where(price_cents: price_range) if @filters[:price].present?
    scope = scope.where(rating: @filters[:min_rating]..) if @filters[:min_rating].present?
    scope = scope.where(in_stock: true) if @filters[:in_stock]
    scope.order(ordering).limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
  end

  private
    def price_range
      min, max = @filters[:price].values_at(:min, :max)
      (min && min.to_i * 100)..(max && max.to_i * 100)
    end

    def ordering
      clauses = @sort.filter_map do |s|
        column = SORTABLE[s[:field]] or next
        [column, s[:direction] == "desc" ? :desc : :asc]
      end
      clauses.any? ? clauses.to_h : { id: :asc }
    end
end

price_range builds a beginless or endless range when the client only sends min or max, and Active Record turns that into >= or <=. Nothing here knows about HTTP. That is the point: the verb is a transport detail.

The Route and Controller

# config/routes.rb
Rails.application.routes.draw do
  query "products/search", to: "product_searches#show", as: :product_search
end
# app/controllers/product_searches_controller.rb
class ProductSearchesController < ApplicationController
  def show
    products = ProductSearch.new(search_params).results
    render json: products.as_json(only: %i[id name category brand price_cents rating in_stock])
  end

  private
    def search_params
      params.permit(:page,
        filters: [:min_rating, :in_stock, category: [], brand: [], price: [:min, :max]],
        sort: [:field, :direction])
    end
end

The route answers QUERY only. A GET or POST to the same path gets a 404, so there’s one clear way to call it. I left the default protect_from_forgery on in ApplicationController. Thanks to the exemption above, the endpoint needs no token and no skip_forgery_protection.

Letting Puma Accept QUERY

This step is easy to miss. Puma only accepts the eight standard HTTP methods by default, and it rejects everything else with a 501 Not Implemented before Rails sees the request:

HTTP/1.1 501 Not Implemented
Puma caught this error: QUERY method is not supported (Puma::HttpParserError501)

Add QUERY in config/puma.rb:

# config/puma.rb
supported_http_methods Puma::Const::SUPPORTED_HTTP_METHODS + %w[QUERY]

Start from Puma’s constant. An array replaces the default list, so supported_http_methods %w[QUERY] alone would lock out GET and POST. If a load balancer, proxy, or CDN sits in front of your app, confirm that it passes QUERY through as well.

The Test

The new query helper sends params as the request body. Use as: :json for a JSON body. Without it, the default content type is form-encoded, because RFC 10008 requires QUERY requests to declare one.

# test/integration/product_search_test.rb
require "test_helper"

class ProductSearchTest < ActionDispatch::IntegrationTest
  test "filters, sorts and paginates from the request body" do
    query product_search_path, params: {
      filters: { category: ["audio"], price: { min: 50, max: 200 }, in_stock: true },
      sort: [{ field: "price", direction: "desc" }]
    }, as: :json

    assert_response :success
    assert request.query?
    assert_equal ["Studio Monitor Headphones", "Wireless Earbuds", "Portable Speaker"],
      response.parsed_body.pluck("name")
  end

  test "sorts by rating with a minimum" do
    query product_search_path, params: {
      filters: { min_rating: 4.4 },
      sort: [{ field: "rating", direction: "desc" }]
    }, as: :json

    assert_equal ["Bookshelf Speakers", "Mechanical Keyboard", "Studio Monitor Headphones", "4K Action Camera"],
      response.parsed_body.pluck("name")
  end
end

The Client

fetch accepts any method name, so the front end needs no library:

// app/javascript/product_search.js
export async function searchProducts(search) {
  const response = await fetch("/products/search", {
    method: "QUERY",
    headers: { "Content-Type": "application/json", "Accept": "application/json" },
    body: JSON.stringify(search)
  })
  return response.json()
}

Write "QUERY" in uppercase. fetch only normalizes the case of the classic methods like get and post, and sends any other name exactly as written. A same-origin call needs no preflight. Call it from a Stimulus controller on your filter form, and the filter state stays a JavaScript object, not a URL you rebuild by hand.

Seeing It Work

With these seed products:

[
  ["Studio Monitor Headphones", "audio", "Sonora", 149_00, 4.6, true],
  ["Wireless Earbuds",          "audio", "Pulse",   89_00, 4.1, true],
  ["Bookshelf Speakers",        "audio", "Sonora", 249_00, 4.8, false],
  ["Portable Speaker",          "audio", "Pulse",   59_00, 3.9, true],
  ["4K Action Camera",          "video", "Lumen",  299_00, 4.4, true],
  ["Ring Light",                "video", "Lumen",   39_00, 4.2, true],
  ["Mechanical Keyboard",       "desk",  "Keyo",   129_00, 4.7, true],
].each do |name, category, brand, price_cents, rating, in_stock|
  Product.create!(name:, category:, brand:, price_cents:, rating:, in_stock:)
end

Send a request with curl:

curl -X QUERY http://localhost:3000/products/search \
  -H "Content-Type: application/json" \
  --data '{
    "filters": { "category": ["audio"], "price": { "min": 50, "max": 200 }, "in_stock": true },
    "sort": [{ "field": "price", "direction": "desc" }]
  }'
[
  { "id": 1, "name": "Studio Monitor Headphones", "category": "audio", "brand": "Sonora", "price_cents": 14900, "rating": "4.6", "in_stock": true },
  { "id": 2, "name": "Wireless Earbuds", "category": "audio", "brand": "Pulse", "price_cents": 8900, "rating": "4.1", "in_stock": true },
  { "id": 4, "name": "Portable Speaker", "category": "audio", "brand": "Pulse", "price_cents": 5900, "rating": "3.9", "in_stock": true }
]

The Bookshelf Speakers are audio but out of stock, so they drop out. The log shows the new verb from end to end:

Started QUERY "/products/search" for ::1
Processing by ProductSearchesController#show as */*
  Parameters: {"filters" => {"category" => ["audio"], "price" => {"min" => 50, "max" => 200}, "in_stock" => true}, "sort" => [{"field" => "price", "direction" => "desc"}]}
Completed 200 OK

Form-encoded bodies work too, with no code changes:

curl -X QUERY http://localhost:3000/products/search --data 'filters[brand][]=Lumen'
# => the 4K Action Camera and the Ring Light

Taking It Further

Saved searches. The request body is already a complete, self-describing search. Save the permitted params in a json column and replay them later:

class SavedSearch < ApplicationRecord
  belongs_to :user
  # t.json :body, null: false -- saved from search_params.to_h

  def results = ProductSearch.new(body.with_indifferent_access).results
end

ProductSearch reads its input with symbol keys, so with_indifferent_access is all the replay needs. Its sort allowlist still applies, so an edited saved search can’t sort by an unexpected column.

One endpoint, many shapes. QUERY is a good fit for anything that is a read but needs a structured payload: GraphQL, JSON-RPC style reports, or an “export these 300 IDs” lookup that would blow past URL length limits. Each of those is now a one-line query route.

Wrapping Up

We built a faceted product search that takes its filters as a JSON body, over a verb that tells the truth: this request is safe, idempotent, and read-only. Rails 8.2 handles the routing, the request.query? predicate, the CSRF exemption, and the query test helper. The only extra step was one line of Puma config.

For the details, read PR #57973 and RFC 10008. If you build APIs, you might also like tiered API rate limits with dynamic rate_limit.