Module: Funicular::Schema

Defined in:
lib/funicular/schema.rb

Overview

Derives client-side validation rules from an ActiveModel/ActiveRecord class so they can be embedded in the JSON returned by a schema controller and reused by Funicular::Model on the client.

Security model: validations are derived ONLY for the attribute names the caller passes (the schema’s existing attribute allowlist), so nothing outside the already-public schema is ever introspected. A per-attribute ‘except` denylist suppresses specific validator kinds.

Defined Under Namespace

Modules: RegexpTranslator

Constant Summary collapse

SUPPORTED_KINDS =

Validator kinds that have a client-side counterpart in Funicular::Model::Validations. Others (notably :uniqueness, which needs the database, and any custom validator) are skipped.

%i[
  presence absence length format numericality
  inclusion exclusion acceptance confirmation
].freeze

Class Method Summary collapse

Class Method Details

.build(model_class, attributes:, endpoints: {}, except: {}) ⇒ Object

Build a full schema hash, merging derived validations inline into each attribute entry (the shape Funicular::Model.load_schema consumes):

Funicular::Schema.build(User,
  attributes: { "display_name" => { type: "string", readonly: false } },
  endpoints:  { "update" => { method: "PATCH", path: "/users/:id" } },
  except:     { username: [:format] })
# => { attributes: { "display_name" => { type:, readonly:,
#        validations: { "presence" => true, "length" => {...} } } },
#      endpoints: {...} }

Only the attributes you declare are introspected (allowlist); ‘except` drops specific kinds per attribute (denylist).



34
35
36
37
38
39
40
41
# File 'lib/funicular/schema.rb', line 34

def self.build(model_class, attributes:, endpoints: {}, except: {})
  merged = {}
  attributes.each do |name, definition|
    rules = rules_for(model_class, name, except_kinds(except, name))
    merged[name] = rules.empty? ? definition : definition.merge(validations: rules)
  end
  { attributes: merged, endpoints: endpoints }
end

.conditional?(options) ⇒ Boolean

Returns:

  • (Boolean)


77
78
79
# File 'lib/funicular/schema.rb', line 77

def self.conditional?(options)
  options.key?(:if) || options.key?(:unless) || options.key?(:on)
end

.except_kinds(except, name) ⇒ Object



73
74
75
# File 'lib/funicular/schema.rb', line 73

def self.except_kinds(except, name)
  Array(except[name.to_sym] || except[name.to_s]).map(&:to_sym)
end

.json_scalar?(value) ⇒ Boolean

Returns:

  • (Boolean)


126
127
128
129
# File 'lib/funicular/schema.rb', line 126

def self.json_scalar?(value)
  value.is_a?(String) || value.is_a?(Numeric) ||
    value == true || value == false || value.nil?
end

.rules_for(model_class, name, skip_kinds) ⇒ Object

Derive the { kind => options } rules for a single attribute.



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/funicular/schema.rb', line 56

def self.rules_for(model_class, name, skip_kinds)
  attr = name.to_sym
  rules = {}
  model_class.validators_on(attr).each do |validator|
    kind = validator.kind
    next unless SUPPORTED_KINDS.include?(kind)
    next if skip_kinds.include?(kind)
    # Conditional/context validators can't be evaluated on the client.
    next if conditional?(validator.options)

    serialized = serialize(kind, validator.options)
    next if serialized.nil?
    rules[kind.to_s] = serialized
  end
  rules
end

.serialize(kind, options) ⇒ Object



81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/funicular/schema.rb', line 81

def self.serialize(kind, options)
  case kind
  when :presence, :absence, :acceptance, :confirmation
    true
  when :length
    serialize_length(options)
  when :numericality
    serialize_numericality(options)
  when :inclusion, :exclusion
    serialize_set(options)
  when :format
    RegexpTranslator.translate(options[:with])
  end
end

.serialize_length(options) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
# File 'lib/funicular/schema.rb', line 96

def self.serialize_length(options)
  opts = {}
  [:minimum, :maximum, :is].each do |k|
    opts[k.to_s] = options[k] if options[k].is_a?(Integer)
  end
  if (range = options[:in] || options[:within]).is_a?(Range)
    opts["minimum"] = range.min
    opts["maximum"] = range.max
  end
  opts.empty? ? nil : opts
end

.serialize_numericality(options) ⇒ Object



108
109
110
111
112
113
114
115
116
# File 'lib/funicular/schema.rb', line 108

def self.serialize_numericality(options)
  opts = {}
  opts["only_integer"] = true if options[:only_integer]
  [:greater_than, :greater_than_or_equal_to, :equal_to,
   :less_than, :less_than_or_equal_to, :other_than].each do |k|
    opts[k.to_s] = options[k] if options[k].is_a?(Numeric)
  end
  opts.empty? ? true : opts
end

.serialize_set(options) ⇒ Object



118
119
120
121
122
123
124
# File 'lib/funicular/schema.rb', line 118

def self.serialize_set(options)
  list = options[:in] || options[:within]
  list = list.to_a if list.is_a?(Range)
  return nil unless list.is_a?(Array)
  return nil unless list.all? { |v| json_scalar?(v) }
  { "in" => list }
end

.validations_for(model_class, attribute_names, except: {}) ⇒ Object

Returns { “attr” => { “presence” => true, “length” => { “maximum” => 30 } } } for the given attribute names only. Useful when emitting validations as a separate block rather than inline (see #build for the inline form).



46
47
48
49
50
51
52
53
# File 'lib/funicular/schema.rb', line 46

def self.validations_for(model_class, attribute_names, except: {})
  result = {}
  attribute_names.each do |name|
    rules = rules_for(model_class, name, except_kinds(except, name))
    result[name.to_s] = rules unless rules.empty?
  end
  result
end