Module: Itsi::Server::TypedHandlers::ParamParser

Included in:
HttpRequest
Defined in:
lib/itsi/server/typed_handlers/param_parser.rb

Defined Under Namespace

Classes: ValidationError

Constant Summary collapse

CONVERSION_MAP =

Conversion map for primitive/base type conversions.

{
  String    => ->(v){ v.to_s },
  Symbol    => ->(v){ v.to_sym },
  Integer   => ->(v){ Integer(v) },
  Float     => ->(v){ Float(v) },
  :Number   => ->(v){ Float(v) },
  TrueClass => ->(v){
    case v
    when true, 'true', '1', 1 then true
    when false, 'false', '0', 0 then false
    else raise ValidationError.new("Cannot cast #{v.inspect} to Boolean")
    end
  },
  FalseClass => ->(v){
    case v
    when true, 'true', '1', 1 then true
    when false, 'false', '0', 0 then false
    else raise ValidationError.new("Cannot cast #{v.inspect} to Boolean")
    end
  },
  :Boolean  => ->(v){
    case v
    when true, 'true', '1', 1 then true
    when false, 'false', '0', 0 then false
    else raise ValidationError.new("Cannot cast #{v.inspect} to Boolean")
    end
  },
  Date      => ->(v){ Date.parse(v.to_s) },
  Time      => ->(v){ Time.parse(v.to_s) },
  DateTime  => ->(v){ DateTime.parse(v.to_s) }
}.compare_by_identity

Instance Method Summary collapse

Instance Method Details

#apply_schema!(params, schema, path = []) ⇒ Object

Applies the schema in place to the given params hash. Fixed keys are converted to symbols, and regex-matched keys remain as strings. The current location in the params is tracked as an array of path segments.

Raises:



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/itsi/server/typed_handlers/param_parser.rb', line 145

def apply_schema!(params, schema, path = [])
  # Support top-level array schema: homogeneous arrays.
  if schema.is_a?(Array)
    # Only allow homogeneous array types
    unless schema.size == 1
      raise ValidationError.new(["Schema Array must contain exactly one type. Got #{schema.size}"])
    end
    expected_type = schema.first
    # Expect params to be an Array
    unless params.is_a?(Array)
      raise ValidationError.new(["Expected Array at #{format_path(path)}, got #{params.class}"])
    end
    errors = []
    params.each_with_index do |_, idx|
      err = cast_value!(params, idx, expected_type, path + [idx])
      errors << err if err
    end
    raise ValidationError.new(errors) unless errors.empty?
    return params
  end

  # Ensure schema is a Hash
  unless schema.is_a?(Hash)
    raise ValidationError.new(["Unsupported schema type: #{schema.class} at #{format_path(path)}"])
  end

  errors = []
  processed = processed_schema(schema)
  fixed_schema = processed[0]
  regex_schema = processed[1]

  # Process fixed keys.
  fixed_schema.each do |fixed_key, (expected_type, required)|
    new_path = path + [fixed_key]
    if params.key?(fixed_key)
      # Symbol key present.
    elsif params.key?(fixed_key.to_s)
      params[fixed_key] = params.delete(fixed_key.to_s)
    else
      if required
        errors << "Missing required key: #{format_path(new_path)}"
      else
        params[fixed_key] = nil
      end
      next
    end

    err = cast_value!(params, fixed_key, expected_type, new_path)
    errors << err if err
  end

  # Process regex keys (only string keys not already handled as fixed keys).
  params.keys.each do |key|
    if key == :_required
      params.delete(key)
      next
    end
    next if fixed_schema.has_key?(key.to_sym) || fixed_schema.has_key?(key)
    unless regex_schema.find do |regex, (expected_type, _required)|
      if regex.match(key)
        new_path = path + [key]
        err = cast_value!(params, key, expected_type, new_path)
        errors << err if err
        true  # only use the first matching regex
      end
    end
      params.delete(key)
    end
  end

  raise ValidationError.new(errors) unless errors.empty?
  params
end

#cast_value!(container, key, expected_type, path) ⇒ Object

In-place casts the value at container according to expected_type. On success, updates container and returns nil. On failure, returns an error message string that uses the formatted path.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/itsi/server/typed_handlers/param_parser.rb', line 85

def cast_value!(container, key, expected_type, path)
  if expected_type.is_a?(Array)
    # Only allow homogeneous array types.
    return "Only homogeneous array types are supported at #{format_path(path)}" if expected_type.size != 1

    # Expect container[key] to be an Array; process each element in place.
    unless container[key].is_a?(Array)
      return "Expected an Array at #{format_path(path)}, got #{container[key].class}"
    end
    container[key].each_with_index do |_, idx|
      err = cast_value!(container[key], idx, expected_type.first, path + [idx])
      return err if err
    end
    return nil

  elsif expected_type.is_a?(Hash)
    # Nested schema: expect container[key] to be a Hash; process it in place.
    unless container[key].is_a?(Hash)
      return "Expected a Hash at #{format_path(path)}, got #{container[key].class}"
    end
    begin
      apply_schema!(container[key], expected_type, path)
      return nil
    rescue ValidationError => ve
      return ve.errors.join('; ')
    end

  else
    converter = CONVERSION_MAP[expected_type]
    if converter
      begin
        container[key] = converter.call(container[key])
        return nil
      rescue => e
        return "Invalid value for #{expected_type} at #{format_path(path)}: #{container[key].inspect} (#{e.message})"
      end
    end

    # Fallbacks.
    if expected_type == Array
      unless container[key].is_a?(Array)
        return "Expected Array at #{format_path(path)}, got #{container[key].class}"
      end
      return nil
    elsif expected_type == Hash
      unless container[key].is_a?(Hash)
        return "Expected Hash at #{format_path(path)}, got #{container[key].class}"
      end
      return nil
    elsif expected_type == File && container[key].is_a?(Hash) && container[key][:tempfile].is_a?(Tempfile)
      return nil
    else
      return "Unsupported type: #{expected_type.inspect} at #{format_path(path)}"
    end
  end
end

#format_path(path) ⇒ Object

Helper that converts an array of path segments into a string. For example, [:user, “addresses”, 0, :street] becomes “user.addresses.street”.



70
71
72
73
74
75
76
77
78
79
80
# File 'lib/itsi/server/typed_handlers/param_parser.rb', line 70

def format_path(path)
  result = "".dup
  path.each do |seg|
    if seg.is_a?(Integer)
      result << "[#{seg}]"
    else
      result << (result.empty? ? seg.to_s : ".#{seg}")
    end
  end
  result
end

#processed_schema(schema) ⇒ Object

Preprocess the schema into fixed keys (as symbols) and regex keys. Memoizes the result based on the schema.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/itsi/server/typed_handlers/param_parser.rb', line 50

def processed_schema(schema)
  @@schema_cache ||= {}
  @@schema_cache[schema] ||= begin
    fixed = {}
    regex = []
    required_params = schema[:_required] || []
    schema.each do |k, schema_def|
      expected_type, required = schema_def, required_params.include?(k)
      if k.is_a?(Regexp)
        regex << [k, [expected_type, required]]
      else
        fixed[k.to_sym] = [expected_type, required]
      end
    end
    [fixed, regex]
  end
end