class ParamSchema
def initialize(fields)
@fields = fields
end
def coerce(params)
result = {}
@fields.each do |key, spec|
raw = params[key.to_s]
value = coerce_value(raw, spec[:type], spec[:default])
value = clamp_allowed(value, spec[:in], spec[:default]) if spec[:in]
value = [value, spec[:max]].min if spec[:max] && value.is_a?(Integer)
result[key] = value
end
result
end
private
def coerce_value(raw, type, default)
return default if raw.nil? || raw.to_s.strip.empty?
case type
when :integer then (Integer(raw) rescue default)
when :float then (Float(raw) rescue default)
when :boolean then %w[1 true yes].include?(raw.to_s.downcase)
when :string then raw.to_s.strip
else raw
end
end
def clamp_allowed(value, allowed, default)
allowed.include?(value) ? value : default
end
end
require 'sinatra/base'
require_relative 'param_schema'
class ProductsApi < Sinatra::Base
PARAMS = ParamSchema.new(
page: { type: :integer, default: 1 },
per_page: { type: :integer, default: 25, max: 100 },
sort: { type: :string, default: 'created_at', in: %w[created_at price name] },
in_stock: { type: :boolean, default: false },
min_price: { type: :float, default: 0.0 }
)
before '/products' do
@query = PARAMS.coerce(params)
@query[:page] = 1 if @query[:page] < 1
end
get '/products' do
content_type :json
scope = Product.order(@query[:sort])
scope = scope.where(in_stock: true) if @query[:in_stock]
scope = scope.where('price >= ?', @query[:min_price]) if @query[:min_price] > 0
records = scope
.offset((@query[:page] - 1) * @query[:per_page])
.limit(@query[:per_page])
{ page: @query[:page], per_page: @query[:per_page], data: records.map(&:to_h) }.to_json
end
end
This snippet shows how a Sinatra API can normalize untrusted query strings once, before any route runs, so handlers work with clean typed values instead of scattered params[:page].to_i calls. The core idea is a small param schema plus a before filter that walks it, coercing each raw string into the declared type and falling back to a typed default when a value is missing or invalid.
In param_schema.rb, ParamSchema is a tiny value object built from a hash of field definitions. Each field declares a type, an optional default, and an optional in: whitelist. The coerce method dispatches on type through coerce_value, which converts :integer, :float, :boolean, and :string from their raw string forms. The interesting decisions live in the failure paths: a blank or unparseable integer does not raise, it returns the field's default via Integer(raw) rescue default. :boolean treats "1", "true", and "yes" as true so it tolerates the several conventions clients actually send. When an in: list is present, clamp_allowed rejects out-of-range values and substitutes the default, which is how sort is kept to a known column set and per_page is capped.
The pattern here is coerce-and-default rather than validate-and-reject. For read-only list endpoints that is usually the right trade-off: a bad page=abc should quietly behave like page=1, not return a 400. The schema centralizes those rules so every endpoint sharing it behaves identically, which matters for cache keys and for avoiding subtle off-by-one bugs when different routes parse pagination differently.
In app.rb, the PARAMS constant defines the schema for the products listing. The before '/products' filter runs PARAMS.coerce(params) and stores the result in @query, so the route body reads @query[:page] as a real Integer with no further guarding. Because before filters run in the same request scope as routes, instance variables set there are visible to the handler. Note that before matches by path pattern, so the filter only fires for the products path and leaves other routes untouched.
A pitfall worth knowing: Sinatra's params keys are strings, so the schema reads raw = params[key.to_s] rather than assuming symbol access. Reach for this when several endpoints share pagination, sorting, and filtering semantics and duplicated coercion has started to drift.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
Share this code
Here's the card — post it anywhere.