ruby 66 lines · 2 tabs

Typed Query Param Coercion in a Sinatra before Filter with Schema Defaults

Shared by codesnips Aug 2026
2 tabs
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
2 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Typed Query Param Coercion in a Sinatra before Filter with Schema Defaults — share card
Link copied