Home > Software design >  Ruby: How to do class/struct input validation
Ruby: How to do class/struct input validation

Time:01-09

I am writing a class based on a struct but I'm struggling finding a good way to do input validation.

The struct has a number of members defined. Some of the members are mandatory (m) and some are optional (o). When creating an instance of the class you must provide at least all the mandatory inputs except if you don't provide any, in which case they are initialized to nil. If you provide too few or too many arguments an exception should be raised. Furthermore you should be able to initialize the instance using a hash where all keys match the members of the struct. If there are any keys not matching a member an exception should be raised.

Please consider this code:

MyClass = Struct.new(:m1, :m2, :o1) do
    # Write class content...
end

# Should be allowed and initialize m1, m2 and o1 to nil - works out of the box
instance1 = MyClass.new

# Should be allowed - works out of the box
instance2 = MyClass.new("m1", "m2") # o1 automatically initialized to nil
instance3 = MyClass.new("m1", "m2", "o1")

# Should be allowed and should map hash values and keys to struct members
instance4 = MyClass.new({m1: "m1", m2: "m2"}) # o1 automatically initialized to nil
instance5 = MyClass.new({m1: "m1", m2: "m2", o1: "o1"})

# Should raise exception saying too many arguments
instance6 = MyClass.new({m1: "m1", m2: "m2", o1: "o1", extra: "extra"})
instance7 = MyClass.new("m1", "m2", "o1", "extra")

# Should raise exception saying "m2" is missing
instance8 = MyClass.new({m1: "m1"})
instance9 = MyClass.new("m1")

# Should raise exception saying "other" key isn't allowed
instance10 = MyClass.new({m1: "m1", other: "other"})

I tried to define constants inside the class to say which inputs are mandatory and optional and then looping though them. But it seems wrong and cumbersome. I also think that the constants are leaking outside of my class because I get redefinition warnings if I use the same constant name in another class.

MyClass = Struct.new(:m1, :m2, :o1) do
    MANDATORY_INPUT = [:m1, :m2]
    OPTIONAL_INPUT = [:o1]
    def initialize(args = nil)
        if args != nil
            # loop through arrays above and raise exception if necessary
        end
        # if input is a Hash assign members
    end
end

Please note that Ruby is pretty new to me so there might be obvious answers to/problems with my question.

CodePudding user response:

The first issue here is that this isn't an appropriate use of a struct.

Structs are suitible when you want to have a simple object that encapsulates some data but doesn't really have much logic attached. Otherwise use a class.

The reason your constants are "leaking" into the outer scope is due to how constant scope and module nesting works in Ruby. The current module nesting is only changed by the module and class keywords and not blocks:

module Foo
  BAR = 1
end

puts BAR # uninitialized constant BAR (NameError)

Baz = Module.new do
  BAR = "ooops"
end

puts BAR # "ooops"

If you used self::MANDATORY_INPUT = [:m1, :m2] it would scope the constant to the Struct but its a silly thing to do.

Its also questionable if you really need validations - most of what you're looking to do seems to hint that what you actually want are just keyword arguments:

class MyClass 
  def initialize(required_arg_1:, required_arg_2:, optional_arg: nil, **kwargs)
    # ...
    @required_1 = required_arg_1
    @required_2 = required_arg_2
    @optional = optional_arg
    @extra_options = kwargs
  end
end

Taking a hash as a positional argument is a pretty outdated practice - keyword arguments were introduced in Ruby 2.0 and that was a long time ago.

If you later actually need complex rules to verify the formatting, type etc of the arguments then add that later - but just take a minute first to reflect over what kind of objects you need to be building and what kind of signatures they should have. They should probally be a lot less flexible then what you're envisioning.

Well the hash formatted data is coming though a proprietary json protocol. Then it's parsed to a ruby hash. And then I imagine instantiating an instance of this class using the hash.

This should not all be handled in the initializer for a class.

Instead you can use factory methods (a class method that returns an instance) or even better separate objects that normalize the input data into your own representation of it.

class MyClass 
  def self.from_wonky_json(garbage_input)
    # map the input from the API to your own class signature
    new(
      foo: garbage_input.dig("a", "b", "c"),
      bar: garbage_input.dig("c", "d")&.frobnobize
    )    
  end
end

If you're following the single responisbility principle an object should not be responsible for both the data and normalizing it.

  • Related