Validate correctly on the edge of the app

We should validate data on the edges of the app in controllers using dry-validation and dry-types. Invalid states should not be allowed.

Bad - no validation πŸ”—

Good - precise validation at the edge πŸ”—

Why dry-validation? πŸ”—

Another gem just to do validation? It’s worth it:

  1. This gem is way more flexible and feature filled than the sloppy and tightly coupled validations in ActiveModel
  2. By separating out the validation concern it means we are pushed towards a world where we cannot instantiate an invalid object - this avoids a huge Rails anti-pattern that is the root cause of this crash
  3. It plays really nicely with Dry::Types allowing us to define required enums concisely
  4. Allows us to inject other objects to validate with. So in future we can plug in US::City and so validate the city exists in the database. Not done in this PR but it’d be a big step forwards!
  5. Like ActiveModel it seamlessly supports translation of error messages for when we need to show errors to users
  6. It separates out validation from the domain object giving a result object that responds to #success? and #failure?.
  7. We can call #to_h on the validation result to give a hash we can use to build the Dry::Struct seamlessly, cutting down on messy boilerplate code.

Example πŸ”—

Bad - zero validation, sloppy testing, timebomb code πŸ”—

module Agent
  module Match
    class FlowsController < BaseController
      def index
        @location = params[:location] # no validation, accepts any location
        # ... snip ...
      end
    end
  end
end

Good - precise validation, no bad data in database, shows user error on invalid data πŸ”—

module Agent
  module Match
    module Location
      # Describes a state using an enum - cannot be invalid
      State = Dry.Types::String.enum(
        'ALABAMA' => 'AL',
        'CALIFORNIA' => 'CA',
        # ... snip ...
      ).constructor(&:upcase)

      # Describes a city - currently too loose - will look up city/state combo in db
      City = Dry.Types::String.constructor(&:titleize)

      # Validate defines the validation rules
      class Validate < Dry::Validation::Contract
        params do
          required(:city).value(City).filled
          required(:state).value(State).filled
        end
      end

      # Raw converts from "Denver, CO" -> { city: "Denver", state: "CO" }
      class Raw
        def self.from_display_location(location)
          raw_city, raw_state = location.split(",").map(&:strip)
          { city: raw_city, state: raw_state }
        end
      end
    end

    # Finally, protect against bad data at the controller level
    class FlowsController < BaseController
      def index
        raw_location = Location::Raw.from_display_location(params[:location])
        validation = Location::Validate.new.call(raw_location)
        if validation.success?
          @location = validation.to_h # { city: City.new('Denver'), state: State.new('CO') }
                                      #   ^^^ A hash with validated instances
                                      #       At this point, location is valid so can be depended on
          # ... snip ...
        else
          redirect_to root_path, alert: "Invalid location #{location}" # Show error if location is invalid
        end
      end
    end
  end
end