Skip to content

Commit

Permalink
feat: upgrade dry-validation gem in preparation for ruby 3 upgrade
Browse files Browse the repository at this point in the history
  • Loading branch information
bethesque committed Mar 22, 2023
1 parent 0e3a257 commit 3b4b66b
Show file tree
Hide file tree
Showing 70 changed files with 1,271 additions and 1,160 deletions.
3 changes: 3 additions & 0 deletions Gemfile
Expand Up @@ -2,6 +2,9 @@ source "https://rubygems.org"

gemspec

# While https://github.com/brandonhilkert/sucker_punch/pull/253 is outstanding
gem "sucker_punch", git: "https://github.com/pact-foundation/sucker_punch.git", ref: "fix/rename-is-singleton-class-method-2"

gem "rake", "~>12.3.3"
gem "sqlite3", "~>1.3"
gem "conventional-changelog", "~>1.3"
Expand Down
1 change: 1 addition & 0 deletions lib/pact_broker/api.rb
Expand Up @@ -2,6 +2,7 @@
require "pact_broker/db/models"
require "pact_broker/api/resources"
require "pact_broker/api/decorators"
require "pact_broker/api/contracts"
require "pact_broker/application_context"
require "pact_broker/feature_toggle"

Expand Down
3 changes: 3 additions & 0 deletions lib/pact_broker/api/contracts.rb
@@ -0,0 +1,3 @@
Dir.glob(File.expand_path(File.join(__FILE__, "..", "contracts", "*.rb"))).sort.each do | path |
require path
end
21 changes: 21 additions & 0 deletions lib/pact_broker/api/contracts/README.md
@@ -0,0 +1,21 @@
# Using dry-validation

## Things you need to know about dry-validation

* It's better than it used to be.
* There are two parts to a Dry Validation contract.
* Schemas: are for defining TYPES and presence of values. They come in 3 different flavours - json, string params, and vanilla https://dry-rb.org/gems/dry-validation/1.8/schemas/. We generally use the JSON one.
* Rules: are for applying VALIDATION RULES. Don't try and put validation rules in the schema, or you'll end up wanting to punch something.
* The schema is applied first, and only if it passes are the rules evaluated for that particular parameter.
* The rules do NOT stop evaluating when one of them fails. Beth thinks this is less than ideal.
* Macros allow you to apply rules to fields in a declarative way. https://dry-rb.org/gems/dry-validation/1.8/macros/ We have some [here](lib/pact_broker/api/contracts/dry_validation_macros.rb)
* The docs are brief, but worth skimming through
* https://dry-rb.org/gems/dry-validation/1.8/
* https://dry-rb.org/gems/dry-schema/1.10/
* You can include modules and define methods inside the Contract classes.
* the "filled?" predicate in dry-schema will fail for "" but pass for " "

## dry-validation modifications for Pact Broker

* I cannot find any native support for Contract composition (there's schema composition, but I can't find Contract composition). The macros `validate_with_contract` and `validate_each_with_contract` in lib/pact_broker/api/contracts/dry_validation_macros.rb allow a child item or array of child items to be validated by a contract that is defined in a separate file, merging the results into the parent's results. There's an example in lib/pact_broker/api/contracts/pacts_for_verification_json_query_schema.rb
* The format that dry-validation returns the error messages for arrays didn't work with the original Pact Broker error format, so the errors are formatted in lib/pact_broker/api/contracts/dry_validation_errors_formatter.rb (one day, the errors should be returned directly to the resource, and the error decorator can either format them in problem json, or raw hash)
29 changes: 22 additions & 7 deletions lib/pact_broker/api/contracts/base_contract.rb
@@ -1,14 +1,29 @@
require "reform"
require "reform/form/dry"

Reform::Form.class_eval do
feature Reform::Form::Dry
end
require "dry-validation"
require "pact_broker/api/contracts/dry_validation_macros"
require "pact_broker/api/contracts/dry_validation_methods"
require "pact_broker/api/contracts/dry_validation_errors_formatter"
require "pact_broker/messages"
require "pact_broker/hash_refinements"

module PactBroker
module Api
module Contracts
class BaseContract < Reform::Form
class BaseContract < Dry::Validation::Contract
include DryValidationMethods
extend DryValidationErrorsFormatter

using PactBroker::HashRefinements

# The entry method for all the Dry::Validation::Contract classes
# eg. MyContract.call(params)
# It takes the params (doesn't matter if they're string or symbol keys)
# executes the dry-validation validation, and formats the errors into the Pactflow format.
#
# @param [Hash] the parameters to validate
# @return [Hash] the validation errors to display to the user
def self.call(params)
format_errors(new.call(params&.symbolize_keys).errors)
end
end
end
end
Expand Down
34 changes: 34 additions & 0 deletions lib/pact_broker/api/contracts/can_i_deploy_query_schema.rb
@@ -0,0 +1,34 @@
require "dry-validation"
require "pact_broker/messages"
require "pact_broker/project_root"
require "pact_broker/string_refinements"

module PactBroker
module Api
module Contracts
class CanIDeployQuerySchema < BaseContract
using PactBroker::StringRefinements

params do
required(:pacticipant).filled(:string)
required(:version).filled(:string)
optional(:to).filled(:string)
optional(:environment).filled(:string)
end

rule(:pacticipant).validate(:pacticipant_with_name_exists)
rule(:environment).validate(:environment_with_name_exists)

rule(:to, :environment) do
if provided?(values[:to]) && provided?(values[:environment])
base.failure(PactBroker::Messages.message("errors.validation.cannot_specify_tag_and_environment"))
end

if not_provided?(values[:to]) && not_provided?(values[:environment])
base.failure(PactBroker::Messages.message("errors.validation.must_specify_environment_or_tag"))
end
end
end
end
end
end
140 changes: 140 additions & 0 deletions lib/pact_broker/api/contracts/consumer_version_selector_contract.rb
@@ -0,0 +1,140 @@
require "pact_broker/api/contracts/base_contract"

module PactBroker
module Api
module Contracts
class ConsumerVersionSelectorContract < BaseContract
option :parent # the parent hash in which the ConsumerVersionSelector is embedded

BRANCH_KEYS = [:latest, :tag, :fallbackTag, :branch, :fallbackBranch, :matchingBranch, :mainBranch]
ENVIRONMENT_KEYS = [:environment, :deployed, :released, :deployedOrReleased]
ALL_KEYS = BRANCH_KEYS + ENVIRONMENT_KEYS + [:consumer]

json do
optional(:mainBranch).filled(included_in?: [true])
optional(:tag).filled(:str?)
optional(:branch).filled(:str?)
optional(:matchingBranch).filled(included_in?: [true])
optional(:latest).filled(included_in?: [true, false])
optional(:fallbackTag).filled(:str?)
optional(:fallbackBranch).filled(:str?)
optional(:consumer).filled(:str?)
optional(:deployed).filled(included_in?: [true])
optional(:released).filled(included_in?: [true])
optional(:deployedOrReleased).filled(included_in?: [true])
optional(:environment).filled(:str?)
end

rule(:consumer).validate(:not_blank_if_present)

# has minimum required params
rule(*ALL_KEYS) do
if not_provided?(values[:mainBranch]) &&
not_provided?(values[:tag]) &&
not_provided?(values[:branch]) &&
not_provided?(values[:environment]) &&
values[:matchingBranch] != true &&
values[:deployed] != true &&
values[:released] != true &&
values[:deployedOrReleased] != true &&
values[:latest] != true

base.failure(validation_message("pacts_for_verification_selector_required_params_missing"))
end
end

# mainBranch
rule(*ALL_KEYS) do
if values[:mainBranch] && values.slice(*ALL_KEYS - [:consumer, :mainBranch, :latest]).any?
base.failure(validation_message("pacts_for_verification_selector_main_branch_with_other_param_disallowed"))
end
end

# mainBranch/latest
rule(:mainBranch, :latest) do
if values[:mainBranch] && values[:latest] == false
base.failure(validation_message("pacts_for_verification_selector_main_branch_and_latest_false_disallowed"))
end
end

# matchingBranch
rule(*ALL_KEYS) do
if values[:matchingBranch] && values.slice(*ALL_KEYS - [:consumer, :matchingBranch]).any?
base.failure(validation_message("pacts_for_verification_selector_matching_branch_with_other_param_disallowed"))
end

if values[:matchingBranch] && not_provided?(parent[:providerVersionBranch])
base.failure(validation_message("pacts_for_verification_selector_matching_branch_requires_provider_version_branch"))
end
end

# tag and branch
rule(:tag, :branch) do
if values[:tag] && values[:branch]
base.failure(validation_message("pacts_for_verification_selector_tag_and_branch_disallowed"))
end
end

# branch/environment keys
rule(*ALL_KEYS) do
non_environment_fields = values.slice(*BRANCH_KEYS).keys.sort
environment_related_fields = values.slice(*ENVIRONMENT_KEYS).keys.sort

if (non_environment_fields.any? && environment_related_fields.any?)
base.failure("cannot specify the #{PactBroker::Messages.pluralize("field", non_environment_fields.count)} #{non_environment_fields.join("/")} with the #{PactBroker::Messages.pluralize("field", environment_related_fields.count)} #{environment_related_fields.join("/")}")
end
end

# fallbackTag
rule(:fallbackTag, :tag, :latest) do
if values[:fallbackTag] && !values[:latest]
base.failure(validation_message("pacts_for_verification_selector_fallback_tag"))
end

if values[:fallbackTag] && !values[:tag]
base.failure(validation_message("pacts_for_verification_selector_fallback_tag_requires_tag"))
end
end

# fallbackBranch
rule(:fallbackBranch, :branch, :latest) do
if values[:fallbackBranch] && !values[:branch]
base.failure(validation_message("pacts_for_verification_selector_fallback_branch_requires_branch"))
end


if values[:fallbackBranch] && values[:latest] == false
base.failure(validation_message("pacts_for_verification_selector_fallback_branch_and_latest_false_disallowed"))
end
end

# branch/latest
rule(:branch, :latest) do
if values[:branch] && values[:latest] == false
base.failure(validation_message("pacts_for_verification_selector_branch_and_latest_false_disallowed"))
end
end

# environment
rule(:environment) do
validate_environment_with_name_exists(value, key) if provided?(value)
end

# deployed, released, deployedOrReleased
rule(:deployed, :released, :deployedOrReleased) do
if values[:deployed] && values[:released]
base.failure(validation_message("pacts_for_verification_selector_deployed_and_released_disallowed"))
end

if values[:deployed] && values[:deployedOrReleased]
base.failure(validation_message("pacts_for_verification_selector_deployed_and_deployed_or_released_disallowed"))
end

if values[:released] && values[:deployedOrReleased]
base.failure(validation_message("pacts_for_verification_selector_released_and_deployed_or_released_disallowed"))
end
end
end
end
end
end
50 changes: 50 additions & 0 deletions lib/pact_broker/api/contracts/dry_validation_errors_formatter.rb
@@ -0,0 +1,50 @@
require "pact_broker/error"

module PactBroker
module Api
module Contracts
module DryValidationErrorsFormatter

# Formats the dry validation errors in the expected PactBroker error format of { :key => ["errors"] }
# where there are no nested hashes.
# @param [Dry::Validation::MessageSet] errors
# @return [Hash]
def format_errors(errors)
errors.each_with_object({}) do | error, errors_hash |
integers = error.path.select{ | k | k.is_a?(Integer) }

if integers.size > 1
raise PactBroker::Error, "Cannot currently format an error message with more than one index"
end

if integers.empty?
key = error.path == [nil] ? nil : error.path.join(".").to_sym
add_error(errors_hash, key, error.text)
else
add_indexed_error(errors_hash, error)
end
end
end

# @private
def add_error(errors_hash, key, text)
errors_hash[key] ||= []
errors_hash[key] << text
end

# @private
def add_indexed_error(errors_hash, error)
error_path_classes = error.path.collect(&:class)
if error_path_classes == [Symbol, Integer, Symbol]
add_error(errors_hash, error.path.first, "#{error.path.last} #{error.text} (at index #{error.path[1]})")
elsif error_path_classes == [Symbol, Integer]
add_error(errors_hash, error.path.first, "#{error.text} (at index #{error.path[1]})")
else
# Don't have any usecases for this - will deal with it when it happens
raise PactBroker::Error, "Cannot currently format an error message with path classes #{error_path_classes}"
end
end
end
end
end
end
79 changes: 79 additions & 0 deletions lib/pact_broker/api/contracts/dry_validation_macros.rb
@@ -0,0 +1,79 @@
require "pact_broker/api/contracts/dry_validation_methods"

Dry::Validation.register_macro(:not_multiple_lines) do
PactBroker::Api::Contracts::DryValidationMethods.validate_not_multiple_lines(value, key)
end

Dry::Validation.register_macro(:no_spaces_if_present) do
PactBroker::Api::Contracts::DryValidationMethods.validate_no_spaces_if_present(value, key)
end

Dry::Validation.register_macro(:not_blank_if_present) do
PactBroker::Api::Contracts::DryValidationMethods.validate_not_blank_if_present(value, key)
end

Dry::Validation.register_macro(:array_values_not_blank_if_any) do
value&.each_with_index do | item, index |
PactBroker::Api::Contracts::DryValidationMethods.validate_not_blank_if_present(item, key(path.keys + [index]))
end
end

Dry::Validation.register_macro(:valid_url_if_present) do
PactBroker::Api::Contracts::DryValidationMethods.validate_valid_url(value, key)
end

Dry::Validation.register_macro(:valid_version_number) do
PactBroker::Api::Contracts::DryValidationMethods.validate_version_number(value, key)
end

Dry::Validation.register_macro(:pacticipant_with_name_exists) do
PactBroker::Api::Contracts::DryValidationMethods.validate_pacticipant_with_name_exists(value, key)
end

Dry::Validation.register_macro(:environment_with_name_exists) do
PactBroker::Api::Contracts::DryValidationMethods.validate_environment_with_name_exists(value, key)
end

# Validate each object in an array with the specified contract,
# and merge the errors into the appropriate path in the parent
# validation results.
# eg.
# rule(:myChildArray).validate(validate_each_with_contract: MyChildContract)
#
# If the child contract defines a option called `parent` then it can access the parent
# hash for validation rules that need to work across the levels.
# eg. ConsumerVersionSelectorContract for the matchingBranch rule
#
# Will not add any errors if the array is nil
Dry::Validation.register_macro(:validate_each_with_contract) do |macro:|
value&.each_with_index do | item, index |
child_contract_class = macro.args[0]
messages = child_contract_class.new(parent: values).call(item).errors(full: true).to_hash.values.flatten
messages.each do | message |
key(path.keys + [index]).failure(message)
end
end
end

# Validate a child node with the specified contract,
# and merge the errors into the appropriate path in the parent
# validation results.
# eg.
# rule(:myChildHash).validate(validate_with_contract: MyChildContract)
#
# If the child contract defines a option called `parent` then it can access the parent
# hash for validation rules that need to work across the levels.
# eg. ConsumerVersionSelectorContract for the matchingBranch rule
#
# Will not add any errors if the value is nil
Dry::Validation.register_macro(:validate_with_contract) do |macro:|
if value
child_contract_class = macro.args[0]
errors = child_contract_class.new(parent: values).call(value).errors.to_hash
errors.each do | key, messages |
messages.each do | message |
key(path.keys + [key]).failure(message)
end
end
end
end

0 comments on commit 3b4b66b

Please sign in to comment.