Class: Servactory::ToolKit::DynamicOptions::Schema

Inherits:
Must
  • Object
show all
Defined in:
lib/servactory/tool_kit/dynamic_options/schema.rb

Overview

Validates Hash structures against a defined schema.

## Purpose

Schema provides deep validation for Hash-type attributes by checking that nested keys exist with correct types. It supports required/optional fields, default values, and nested object validation. This is essential for validating complex data structures like API payloads or configurations.

## Usage

This option is **included by default** for inputs, internals, and outputs. No registration required for basic usage.

To extend supported Hash-compatible types, use the ‘hash_mode_class_names` configuration:

“‘ruby configuration do

hash_mode_class_names([CustomHashClass])

end “‘

Define schema in your service:

“‘ruby class CreateUserService < ApplicationService::Base

input :user_data,
      type: Hash,
      schema: {
        name: { type: String },
        age: { type: Integer, required: false, default: 18 },
        address: {
          type: Hash,
          street: { type: String },
          city: { type: String, required: false }
        }
      }

end “‘

## Simple Mode

Specify schema definition directly:

“‘ruby class CreateUserService < ApplicationService::Base

input :user_data,
      type: Hash,
      schema: {
        name: { type: String },
        age: { type: Integer, required: false },
        email: { type: String }
      }

end “‘

## Advanced Mode

Specify schema with custom error message using a hash:

With static message:

“‘ruby input :user_data, type: Hash, schema: {

is: {
  name: { type: String },
  email: { type: String }
},
message: "Input `user_data` has invalid structure"

} “‘

With dynamic lambda message:

“‘ruby input :user_data, type: Hash, schema: {

is: {
  name: { type: String },
  email: { type: String }
},
message: lambda do |input:, reason:, key_name:, expected_type:, given_type:, **|
  "Schema error in `#{input.name}`: " \
    "key `#{key_name}` expected #{expected_type}, got #{given_type}"
end

} “‘

Lambda receives the following parameters:

  • For inputs: ‘input:, reason:, key_name:, expected_type:, given_type:, **`

  • For internals: ‘internal:, reason:, key_name:, expected_type:, given_type:, **`

  • For outputs: ‘output:, reason:, key_name:, expected_type:, given_type:, **`

Use ‘schema: false` to disable schema validation.

## Schema Options

Each field in the schema supports:

  • ‘type` - Expected type (String, Integer, Array, Hash, etc.)

  • ‘required` - Whether field is required (default: true)

  • ‘default` - Default value when field is missing

  • ‘prepare` - Proc to transform the value (inputs only)

## Processing Flow

  1. **Type check**: Verify attribute is Hash-compatible

  2. **Schema validation**: Recursively check all nested keys and types

  3. **Default application**: Apply defaults to missing optional fields

  4. Preparation: Execute prepare callbacks (inputs only)

## Important Notes

  • Empty values skip validation for: optional inputs, all internal/output attributes

  • Nested Hash types are validated recursively

  • The ‘prepare` option is stripped for internals and outputs

  • Reserved options: :type, :required, :default, :prepare

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Must

#equivalent_with, #initialize, #must, #must_content_message_with, #must_content_value_with, #must_content_with

Constructor Details

This class inherits a constructor from Servactory::ToolKit::DynamicOptions::Must

Class Method Details

.use(option_name = :schema, default_hash_mode_class_names:) ⇒ Servactory::Maintenance::Attributes::OptionHelper

Creates a Schema validator instance.

Parameters:

  • option_name (Symbol) (defaults to: :schema)

    The option name (default: :schema)

  • default_hash_mode_class_names (Array<Class>)

    Valid Hash-like types

Returns:



132
133
134
135
136
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 132

def self.use(option_name = :schema, default_hash_mode_class_names:)
  instance = new(option_name, :is, false)
  instance.assign(default_hash_mode_class_names)
  instance.must(:schema)
end

Instance Method Details

#assign(default_hash_mode_class_names) ⇒ void

This method returns an undefined value.

Assigns the list of valid Hash-compatible class names.

Parameters:

  • default_hash_mode_class_names (Array<Class>)

    Hash-like types to accept



142
143
144
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 142

def assign(default_hash_mode_class_names)
  @default_hash_mode_class_names = default_hash_mode_class_names
end

#common_condition_with(attribute:, value:, option:) ⇒ Boolean, Array

Common validation logic for all attribute types.

rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

Parameters:

  • attribute (Object)

    The attribute being validated

  • value (Object)

    Hash value to validate

  • option (WorkOption)

    Schema configuration

Returns:

  • (Boolean, Array)

    true if valid, or [false, reason, meta]



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
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 183

def common_condition_with(attribute:, value:, option:)
  # Schema disabled, skip validation.
  return true if option.value == false

  # Attribute type must be Hash-compatible.
  return [false, :wrong_type] unless @default_hash_mode_class_names.intersect?(attribute.types)

  # Skip validation for blank optional values.
  if value.blank? && ((attribute.input? && attribute.optional?) || attribute.internal? || attribute.output?)
    return true
  end

  schema = option.value.fetch(:is, option.value)

  # Remove :prepare option for internals and outputs.
  if attribute.internal? || attribute.output?
    schema = schema.transform_values { |options| options.except(:prepare) }
  end

  is_success, reason, meta = validate_for!(object: value, schema:)

  # Apply defaults and preparations if validation passed.
  prepare_object_with!(object: value, schema:) if is_success

  [is_success, reason, meta]
end

#condition_for_input_with(input:, value:, option:) ⇒ Boolean, Array

Validates schema condition for input attribute.

Parameters:

  • input (Object)

    Input attribute object

  • value (Object)

    Hash value to validate

  • option (WorkOption)

    Schema configuration

Returns:

  • (Boolean, Array)

    true if valid, or [false, reason, meta]



152
153
154
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 152

def condition_for_input_with(input:, value:, option:)
  common_condition_with(attribute: input, value:, option:)
end

#condition_for_internal_with(internal:, value:, option:) ⇒ Boolean, Array

Validates schema condition for internal attribute.

Parameters:

  • internal (Object)

    Internal attribute object

  • value (Object)

    Hash value to validate

  • option (WorkOption)

    Schema configuration

Returns:

  • (Boolean, Array)

    true if valid, or [false, reason, meta]



162
163
164
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 162

def condition_for_internal_with(internal:, value:, option:)
  common_condition_with(attribute: internal, value:, option:)
end

#condition_for_output_with(output:, value:, option:) ⇒ Boolean, Array

Validates schema condition for output attribute.

Parameters:

  • output (Object)

    Output attribute object

  • value (Object)

    Hash value to validate

  • option (WorkOption)

    Schema configuration

Returns:

  • (Boolean, Array)

    true if valid, or [false, reason, meta]



172
173
174
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 172

def condition_for_output_with(output:, value:, option:)
  common_condition_with(attribute: output, value:, option:)
end

#fetch_default_from(value) ⇒ Object?

Extracts default value from schema definition.

Parameters:

  • value (Hash)

    Schema definition

Returns:

  • (Object, nil)

    Default value or nil



322
323
324
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 322

def fetch_default_from(value)
  value.fetch(:default, nil)
end

#message_for_input_with(service:, input:, reason:, meta:) ⇒ String

Generates error message for input validation failure.

Parameters:

  • service (Object)

    Service context

  • input (Object)

    Input attribute

  • reason (Symbol)

    Failure reason

  • meta (Hash)

    Additional metadata

Returns:

  • (String)

    Localized error message



383
384
385
386
387
388
389
390
391
392
393
394
395
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 383

def message_for_input_with(service:, input:, reason:, meta:, **)
  i18n_key = "inputs.validations.must.dynamic_options.schema"
  i18n_key += reason.present? ? ".#{reason}" : ".default"

  service.translate(
    i18n_key,
    service_class_name: service.class_name,
    input_name: input.name,
    key_name: meta.fetch(:key_name),
    expected_type: meta.fetch(:expected_type),
    given_type: meta.fetch(:given_type)
  )
end

#message_for_internal_with(service:, internal:, reason:, meta:) ⇒ String

Generates error message for internal validation failure.

Parameters:

  • service (Object)

    Service context

  • internal (Object)

    Internal attribute

  • reason (Symbol)

    Failure reason

  • meta (Hash)

    Additional metadata

Returns:

  • (String)

    Localized error message



404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 404

def message_for_internal_with(service:, internal:, reason:, meta:, **)
  i18n_key = "internals.validations.must.dynamic_options.schema"
  i18n_key += reason.present? ? ".#{reason}" : ".default"

  service.translate(
    i18n_key,
    service_class_name: service.class_name,
    internal_name: internal.name,
    key_name: meta.fetch(:key_name),
    expected_type: meta.fetch(:expected_type),
    given_type: meta.fetch(:given_type)
  )
end

#message_for_output_with(service:, output:, reason:, meta:) ⇒ String

Generates error message for output validation failure.

Parameters:

  • service (Object)

    Service context

  • output (Object)

    Output attribute

  • reason (Symbol)

    Failure reason

  • meta (Hash)

    Additional metadata

Returns:

  • (String)

    Localized error message



425
426
427
428
429
430
431
432
433
434
435
436
437
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 425

def message_for_output_with(service:, output:, reason:, meta:, **)
  i18n_key = "outputs.validations.must.dynamic_options.schema"
  i18n_key += reason.present? ? ".#{reason}" : ".default"

  service.translate(
    i18n_key,
    service_class_name: service.class_name,
    output_name: output.name,
    key_name: meta.fetch(:key_name),
    expected_type: meta.fetch(:expected_type),
    given_type: meta.fetch(:given_type)
  )
end

#prepare_object_with!(object:, schema:) ⇒ void

This method returns an undefined value.

Applies defaults and preparations to the validated object.

rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

Parameters:

  • object (Hash)

    Object to modify

  • schema (Hash)

    Schema definition



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 334

def prepare_object_with!(object:, schema:)
  schema.map do |schema_key, schema_value|
    attribute_type = schema_value.fetch(:type, String)
    required = schema_value.fetch(:required, true)
    object_value = object[schema_key]

    if attribute_type == Hash
      # Apply nested Hash defaults.
      default_value = schema_value.fetch(:default, {})

      if !required && !default_value.nil? && !Servactory::Utils.value_present?(object_value)
        object[schema_key] = default_value
      end

      # Recursively prepare nested objects.
      prepare_object_with!(
        object: object.fetch(schema_key, {}),
        schema: schema_value.except(*RESERVED_OPTIONS)
      )
    else
      # Apply scalar defaults.
      default_value = schema_value.fetch(:default, nil)

      if !required && !default_value.nil? && !Servactory::Utils.value_present?(object_value)
        object[schema_key] = default_value
      end

      # Execute prepare callback if defined.
      unless (input_prepare = schema_value.fetch(:prepare, nil)).nil?
        object[schema_key] = input_prepare.call(value: object[schema_key])
      end

      object
    end
  end
end

#prepare_value_from(schema_value:, value:, required:) ⇒ Object

Prepares value for validation, applying defaults if needed.

Parameters:

  • schema_value (Hash)

    Field schema

  • value (Object)

    Current value

  • required (Boolean)

    Whether required

Returns:

  • (Object)

    Value to validate



310
311
312
313
314
315
316
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 310

def prepare_value_from(schema_value:, value:, required:)
  if !required && !fetch_default_from(schema_value).nil? && value.blank?
    fetch_default_from(schema_value)
  else
    value
  end
end

#should_be_checked_for?(object:, schema_key:, schema_value:, required:) ⇒ Boolean

Determines if a field should be validated.

Parameters:

  • object (Hash)

    Parent object

  • schema_key (Symbol)

    Field key

  • schema_value (Hash)

    Field schema

  • required (Boolean)

    Whether required

Returns:

  • (Boolean)

    true if validation needed



296
297
298
299
300
301
302
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 296

def should_be_checked_for?(object:, schema_key:, schema_value:, required:)
  required || (
    !required && !fetch_default_from(schema_value).nil?
  ) || (
    !required && !object.fetch(schema_key, nil).nil?
  )
end

#validate_for!(object:, schema:, root_schema_key: nil) ⇒ Boolean, Array

Recursively validates object against schema definition.

Parameters:

  • object (Hash)

    The object to validate

  • schema (Hash)

    Schema definition

  • root_schema_key (Symbol, nil) (defaults to: nil)

    Parent key for nested validation

Returns:

  • (Boolean, Array)

    true if valid, or [false, reason, meta]



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 217

def validate_for!(object:, schema:, root_schema_key: nil) # rubocop:disable Metrics/MethodLength
  # Object must be Hash-like (respond to :fetch).
  unless object.respond_to?(:fetch)
    return [
      false,
      :wrong_element_value,
      {
        key_name: root_schema_key,
        expected_type: Hash.name,
        given_type: object.class.name
      }
    ]
  end

  # Validate each schema field.
  errors = schema.map do |schema_key, schema_value|
    attribute_type = schema_value.fetch(:type, String)

    if attribute_type == Hash
      # Recursively validate nested Hash.
      validate_for!(
        object: object.fetch(schema_key, {}),
        schema: schema_value.except(*RESERVED_OPTIONS),
        root_schema_key: schema_key
      )
    else
      is_success, given_type = validate_with(
        object:,
        schema_key:,
        schema_value:,
        attribute_type:,
        attribute_required: schema_value.fetch(:required, true)
      )

      next if is_success

      [false, :wrong_element_type, { key_name: schema_key, expected_type: attribute_type, given_type: }]
    end
  end

  # Return first error or true.
  errors.compact.first || true
end

#validate_with(object:, schema_key:, schema_value:, attribute_type:, attribute_required:) ⇒ Array<Boolean, String>

Validates a single field against its type specification.

Parameters:

  • object (Hash)

    Parent object containing the field

  • schema_key (Symbol)

    Field key to validate

  • schema_value (Hash)

    Field schema definition

  • attribute_type (Class, Array<Class>)

    Expected type(s)

  • attribute_required (Boolean)

    Whether field is required

Returns:

  • (Array<Boolean, String>)
    success, given_type_name


269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/servactory/tool_kit/dynamic_options/schema.rb', line 269

def validate_with(object:, schema_key:, schema_value:, attribute_type:, attribute_required:) # rubocop:disable Metrics/MethodLength
  # Skip validation if not required and no value present.
  unless should_be_checked_for?(
    object:,
    schema_key:,
    schema_value:,
    required: attribute_required
  ) # do
    return true
  end

  value = object.fetch(schema_key, nil)
  prepared_value = prepare_value_from(schema_value:, value:, required: attribute_required)

  [
    Array(attribute_type).uniq.any? { |type| prepared_value.is_a?(type) },
    prepared_value.class.name
  ]
end