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
-
.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):.
- .conditional?(options) ⇒ Boolean
- .except_kinds(except, name) ⇒ Object
- .json_scalar?(value) ⇒ Boolean
-
.rules_for(model_class, name, skip_kinds) ⇒ Object
Derive the { kind => options } rules for a single attribute.
- .serialize(kind, options) ⇒ Object
- .serialize_length(options) ⇒ Object
- .serialize_numericality(options) ⇒ Object
- .serialize_set(options) ⇒ Object
-
.validations_for(model_class, attribute_names, except: {}) ⇒ Object
Returns { “attr” => { “presence” => true, “length” => { “maximum” => 30 } } } for the given attribute names only.
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
77 78 79 |
# File 'lib/funicular/schema.rb', line 77 def self.conditional?() .key?(:if) || .key?(:unless) || .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
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.) serialized = serialize(kind, validator.) 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, ) case kind when :presence, :absence, :acceptance, :confirmation true when :length serialize_length() when :numericality serialize_numericality() when :inclusion, :exclusion serialize_set() when :format RegexpTranslator.translate([: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() opts = {} [:minimum, :maximum, :is].each do |k| opts[k.to_s] = [k] if [k].is_a?(Integer) end if (range = [:in] || [: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() opts = {} opts["only_integer"] = true if [: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] = [k] if [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() list = [:in] || [: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 |