discourse/lib/schema_settings_object_validator.rb
Isaac Janzen c53b34eaef
DEV: Add datetime type for site settings / object settings (#36849)
Implement a `datetime` type  for site settings and object site settings

You can implement this type as so: 

```
  test_setting:
    default: ""
    client: true
    type: "datetime"
```

- Add necessary validations
- Add tests

### Preview
<img width="670" height="264" alt="Screenshot 2025-12-29 at 11 59 13 AM"
src="https://github.com/user-attachments/assets/48d330e3-a38c-4d6a-9ea7-c4271b3e360e"
/>
2025-12-30 10:52:30 -06:00

327 lines
8.7 KiB
Ruby

# frozen_string_literal: true
class SchemaSettingsObjectValidator
class << self
def validate_objects(schema:, objects:)
error_messages = []
objects.each_with_index do |object, index|
humanize_error_messages(
self.new(schema: schema, object: object).validate,
index:,
error_messages:,
)
end
error_messages
end
def property_values_of_type(schema:, objects:, type:)
values = Set.new
objects.each do |object|
values.merge(self.new(schema: schema, object: object).property_values_of_type(type))
end
values.to_a
end
private
def humanize_error_messages(errors, index:, error_messages:)
errors.each do |property_json_pointer, error_details|
error_messages.push(*error_details.humanize_messages("/#{index}#{property_json_pointer}"))
end
end
end
class SchemaSettingsObjectErrors
def initialize
@errors = []
end
def add_error(error, i18n_opts = {})
@errors << SchemaSettingsObjectError.new(error, i18n_opts)
end
def humanize_messages(property_json_pointer)
@errors.map { |error| error.humanize_messages(property_json_pointer) }
end
def full_messages
@errors.map(&:error_message)
end
end
class SchemaSettingsObjectError
def initialize(error, i18n_opts = {})
@error = error
@i18n_opts = i18n_opts
end
def humanize_messages(property_json_pointer)
I18n.t(
"themes.settings_errors.objects.humanize_#{@error}",
@i18n_opts.merge(property_json_pointer:),
)
end
def error_message
I18n.t("themes.settings_errors.objects.#{@error}", @i18n_opts)
end
end
def initialize(schema:, object:, json_pointer_prefix: "", errors: {}, valid_ids_lookup: {})
@object = object.with_indifferent_access
@schema_name = schema[:name]
@properties = schema[:properties]
@errors = errors
@json_pointer_prefix = json_pointer_prefix
@valid_ids_lookup = valid_ids_lookup
end
def validate
@properties.each do |property_name, property_attributes|
if property_attributes[:type] == "objects"
validate_child_objects(
@object[property_name],
property_name:,
schema: property_attributes[:schema],
)
else
validate_property(property_name, property_attributes)
end
end
@errors
end
def property_values_of_type(type)
fetch_property_values_of_type(@properties, @object, type)
end
private
def validate_child_objects(objects, property_name:, schema:)
return if objects.blank?
objects.each_with_index do |object, index|
self
.class
.new(
schema:,
object:,
valid_ids_lookup:,
json_pointer_prefix: "#{@json_pointer_prefix}#{property_name}/#{index}/",
errors: @errors,
)
.validate
end
end
def validate_property(property_name, property_attributes)
return if property_attributes[:required] && !is_property_present?(property_name)
return if !has_valid_property_value_type?(property_attributes, property_name)
!has_valid_property_value?(property_attributes, property_name)
end
def has_valid_property_value_type?(property_attributes, property_name)
value = @object[property_name]
type = property_attributes[:type]
return true if value.nil?
is_value_valid =
case type
when "string", "datetime"
value.is_a?(String)
when "integer", "topic", "post"
value.is_a?(Integer)
when "upload"
if value.is_a?(String)
if upload = Upload.get_from_url(value)
@object[property_name] = upload.id
# upload already verified via get_from_url, so we can add it to valid ids
(@valid_ids_lookup["upload"] ||= Set.new) << upload.id
true
else
false
end
else
value.is_a?(Integer)
end
when "float"
value.is_a?(Float) || value.is_a?(Integer)
when "boolean"
[true, false].include?(value)
when "enum"
property_attributes[:choices].include?(value)
when "categories", "groups"
value.is_a?(Array) && value.all? { |id| id.is_a?(Integer) }
when "tags"
value.is_a?(Array) && value.all? { |tag| tag.is_a?(String) }
else
add_error(property_name, :invalid_type, type:)
return false
end
if is_value_valid
true
else
add_error(property_name, "not_valid_#{type}_value", property_attributes)
false
end
end
def has_valid_property_value?(property_attributes, property_name)
validations = property_attributes[:validations]
type = property_attributes[:type]
value = @object[property_name]
return true if value.nil?
case type
when "topic", "upload", "post"
if !valid_ids(type).include?(value)
add_error(property_name, :"not_valid_#{type}_value")
return false
end
when "tags", "categories", "groups"
if !Array(value).to_set.subset?(valid_ids(type))
add_error(property_name, :"not_valid_#{type}_value")
return false
end
if (min = validations&.dig(:min)) && value.length < min
add_error(property_name, :"#{type}_value_not_valid_min", count: min)
return false
end
if (max = validations&.dig(:max)) && value.length > max
add_error(property_name, :"#{type}_value_not_valid_max", count: max)
return false
end
when "datetime"
return true if value.blank?
begin
# DateTime.iso8601 checks the format but does not enforce timezone presence
# so we need to do an additional check for the presence of timezone info.
DateTime.iso8601(value)
if value.include?("T") && (value.end_with?("Z") || value.match?(/[+-]\d{2}:\d{2}$/))
return true
end
add_error(property_name, :not_valid_datetime_value)
return false
rescue ArgumentError, TypeError
add_error(property_name, :not_valid_datetime_value)
return false
end
when "string"
if (min = validations&.dig(:min_length)) && value.length < min
add_error(property_name, :string_value_not_valid_min, count: min)
return false
end
if (max = validations&.dig(:max_length)) && value.length > max
add_error(property_name, :string_value_not_valid_max, count: max)
return false
end
if validations&.dig(:url) && !UrlHelper.is_valid_url?(value)
add_error(property_name, :string_value_not_valid_url)
return false
end
when "integer", "float"
if (min = validations&.dig(:min)) && value < min
add_error(property_name, :number_value_not_valid_min, min:)
return false
end
if (max = validations&.dig(:max)) && value > max
add_error(property_name, :number_value_not_valid_max, max:)
return false
end
end
true
end
def is_property_present?(property_name)
if @object[property_name].blank?
add_error(property_name, :required)
false
else
true
end
end
def add_error(property_name, key, i18n_opts = {})
pointer = json_pointer(property_name)
@errors[pointer] ||= SchemaSettingsObjectErrors.new
@errors[pointer].add_error(key, i18n_opts)
end
def json_pointer(property_name)
"/#{@json_pointer_prefix}#{property_name}"
end
def valid_ids_lookup
@valid_ids_lookup ||= {}
end
TYPE_TO_MODEL_MAP = {
"categories" => {
klass: Category,
},
"topic" => {
klass: Topic,
},
"post" => {
klass: Post,
},
"groups" => {
klass: Group,
},
"upload" => {
klass: Upload,
},
"tags" => {
klass: Tag,
column: :name,
},
}
private_constant :TYPE_TO_MODEL_MAP
def valid_ids(type)
valid_ids_lookup[type] ||= begin
column = TYPE_TO_MODEL_MAP[type][:column] || :id
Set.new(
TYPE_TO_MODEL_MAP[type][:klass].where(
column => fetch_property_values_of_type(@properties, @object, type),
).pluck(column),
)
end
end
def fetch_property_values_of_type(properties, object, type)
values = Set.new
properties.each do |property_name, property_attributes|
if property_attributes[:type] == type
values.merge(Array(object[property_name]))
elsif property_attributes[:type] == "objects"
object[property_name]&.each do |child_object|
values.merge(
fetch_property_values_of_type(
property_attributes[:schema][:properties],
child_object,
type,
),
)
end
end
end
values
end
end