houdini/lib/json_resp.rb

139 lines
4.3 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
# Provide a declarative json validation and error responses for the rails 'render' method in controllers
#
# The return value of the block you pass into #when_valid should look like {status: code, json: data}
#
# Concerns of this lib are:
# * Validate the params, only running a block when valid
# * Provide a declarative system for request parameter requirements
# * Respond with proper codes and error messages for everything
class JsonResp
attr_accessor :errors
def initialize(params, &block)
@params = params
validation = JsonResp::Validation.new(params)
validation.instance_exec(params, &block)
@errors = validation.errors
self
end
def when_valid
return { status: 422, json: { errors: @errors } } if @errors.any?
begin
@response = yield(@params)
rescue Exception => e
@response = { status: 500, json: { error: "We're sorry, but something went wrong. We've been notified about this issue." } }
puts e
puts e.backtrace.first(10)
end
@response
end
# Validation of a set of request parameters
class Validation
attr_accessor :errors, :params
def initialize(params)
@params = params
@errors = []
self
end
def requires(*keys)
@errors.concat keys.select { |k| @params[k].blank? }.map { |k| "#{k} required" }
Param.new(keys, @errors, @params)
end
def requires_either(key1, key2)
error_message = "#{key1} or #{key2} is required"
if @params[key1].blank? && @params[key2].blank?
@errors << error_message
else
@errors.concat [key1, key2].select { |k| @params[k].blank? }.map { |k| "#{k} required" }
end
Param.new([key1, key2], @errors, @params)
end
def optional(*keys)
keys_to_check = keys.select { |k| @params[k].present? }
Param.new(keys_to_check, @errors, @params)
end
end
class Param
# param validation methods
# To make more validators, you can extend this class
# All methods here are no-ops if the key was optional and is not present
attr_accessor :keys, :errors, :params
def initialize(keys, errors, params)
@keys = keys.reject { |k| params[k].nil? }
@errors = errors
@params = params
end
def as_string
@errors.concat @keys.reject { |k| @params[k].is_a?(String) }.map { |k| "#{k} must be a string" }
self
end
def as_int
@errors.concat @keys
.reject { |k| @params[k].is_a?(Integer) || @params[k].to_i.to_s == @params[k] }
.map { |k| "#{k} must be an integer" }
self
end
def with_format(regex)
@errors.concat @keys.reject { |k| @params[k] =~ regex }.map { |k| "#{k} must match: #{regex}" }
self
end
def one_of(*vals)
@errors.concat @keys.reject { |k| vals.include?(@params[k]) }.map { |k| "#{k} must be one of: #{vals.join(', ')}" }
self
end
def nested(&block)
@errors.concat @keys.map { |k| Validation.new(@params[k]).instance_exec(@params, &block).errors }.flatten
self
end
def as_array
@errors.concat @keys.reject { |k| @params[k].is_a?(Array) }.map { |k| "#{k} must be an array" }
end
def array_of(&block)
@errors.concat @keys.reject { |k| @params[k].is_a?(Array) }.map { |k| "#{k} must be an array" }
@errors.concat @keys.map { |k| @params[k].map { |h| Validation.new(h).instance_exec(@params, &block).errors } }.flatten
self
end
def as_date
with_format /\d\d\d\d-\d\d-\d\d/
@errors.concat @keys.map { |k| [k].concat @params[k].split('-').map(&:to_i) }
.reject { |_key, year, month, day| year.present? && year > 1000 && year < 3000 && month.present? && month > 0 && month < 13 && day.present? && day > 0 && day < 32 }
.map { |k, _, _, _| "#{k} must be a valid date" }
end
def min(n)
@errors.concat @keys.reject { |k| @params[k] >= n }.map { |k| "#{k} must be at least #{n}" }
self
end
def max(n)
@errors.concat @keys.reject { |k| @params[k] <= n }.map { |k| "#{k} must be less than #{n + 1}" }
self
end
# TODO: min_len, max_len, as_float, as_currency, as_time, as_datetime
# TODO return err resp on unrecognized params
end
end