Last active
May 31, 2016 09:30
-
-
Save jon-cotton/ae57aeac4c9a9d9e59b1025d05013dc5 to your computer and use it in GitHub Desktop.
Basic validation 'engine' for Swift, allows you to quickly test a group of UITextFields against either basic built in validation rules or custom regex patterns and get back a group of specfic errors as to what didn't match/validate. Copy and paste the raw contents into a Swift playground to try it out for yourself.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//: Playground - noun: a place where people can play | |
import UIKit | |
protocol Validator { | |
associatedtype T | |
func isValid(value: T) throws -> Bool | |
} | |
protocol Validateable { | |
associatedtype ValidatorType: Validator | |
func validValue(validators: ValidatorType...) throws -> ValidatorType.T | |
} | |
extension Validateable where ValidatorType.T == Self { | |
func validValue(validators: [ValidatorType]) throws -> Self { | |
var errors = AggregateError() | |
for validator in validators { | |
do { | |
try validator.isValid(self) | |
} catch { | |
errors.addError(error) | |
} | |
} | |
guard errors.isEmpty else { | |
throw errors | |
} | |
return self | |
} | |
func validValue(validators: ValidatorType...) throws -> Self { | |
return try validValue(validators) | |
} | |
} | |
extension Optional where Wrapped: Validateable, Wrapped.ValidatorType.T == Wrapped { | |
func validValue(validators: [Wrapped.ValidatorType]) throws -> Wrapped { | |
switch self { | |
case .None: | |
throw ValidationError.valueIsNil | |
case .Some(let value): | |
return try value.validValue(validators) | |
} | |
} | |
func validValue(validators: Wrapped.ValidatorType...) throws -> Wrapped { | |
return try validValue(validators) | |
} | |
} | |
// MARK:- Validation Errors | |
struct AggregateError: ErrorType { | |
private(set) var errors: [ErrorType] = [] | |
mutating func addError(error: ErrorType) { | |
errors.append(error) | |
} | |
var isEmpty: Bool { | |
return errors.isEmpty | |
} | |
} | |
extension AggregateError: CollectionType { | |
typealias Index = Int | |
var startIndex: Int { | |
return 0 | |
} | |
var endIndex: Int { | |
return errors.count | |
} | |
subscript(i: Int) -> ErrorType { | |
return errors[i] | |
} | |
} | |
enum RegexError: ErrorType { | |
case stringDoesNotMatchRegexPattern | |
} | |
enum ValidationError: ErrorType { | |
case valueIsNil | |
} | |
enum StringValidationError: ErrorType { | |
case stringIsEmpty | |
case stringContainsNonAlphaCharacters | |
case stringContainsNonNumericCharacters | |
case stringContainsNonAlphaNumericCharacters | |
case stringIsNotAValidEmailAddress | |
case stringsDoNotMatch | |
} | |
enum ComparableValidationError<T>: ErrorType { | |
case valueIsBelowMinimumBounds(T) | |
case valueIsAboveMaximumBounds(T) | |
case lowerBoundsMustBeLessThanUpperBounds | |
} | |
// MARK:- Regex Things | |
protocol RegexPattern { | |
var pattern: String {get} | |
var errorToThrow: ErrorType {get} | |
func match(string: String) throws -> Bool | |
} | |
extension RegexPattern { | |
func match(string: String) throws -> Bool { | |
guard string =~ pattern else { | |
throw errorToThrow | |
} | |
return true | |
} | |
} | |
extension String: RegexPattern { | |
var pattern: String { | |
return self | |
} | |
var errorToThrow: ErrorType { | |
return RegexError.stringDoesNotMatchRegexPattern | |
} | |
} | |
extension RegexPattern where Self: RawRepresentable, Self.RawValue == String { | |
var pattern: String { | |
return rawValue | |
} | |
} | |
struct Regex { | |
let expression: NSRegularExpression | |
init(_ pattern: RegexPattern) throws { | |
expression = try NSRegularExpression(pattern: pattern.pattern, options: .DotMatchesLineSeparators) | |
} | |
func test(string: String) -> Bool { | |
let matches = expression.matchesInString(string, options: .ReportCompletion, range: NSMakeRange(0, string.characters.count)) | |
return matches.count > 0 | |
} | |
} | |
infix operator =~ {} | |
func =~ (input: String, pattern: RegexPattern) -> Bool { | |
do { | |
return try Regex(pattern).test(input) | |
} catch { | |
return false | |
} | |
} | |
// MARK:- Validators | |
enum StringValidationPattern: String, RegexPattern { | |
case alphaOnly = "^[a-zA-Z]*$" | |
case numericOnly = "^[0-9]*$" | |
case alphaNumericOnly = "^[a-zA-Z0-9]*$" | |
case email = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`" | |
var errorToThrow: ErrorType { | |
switch self { | |
case .alphaOnly: return StringValidationError.stringContainsNonAlphaCharacters | |
case .numericOnly: return StringValidationError.stringContainsNonNumericCharacters | |
case .alphaNumericOnly: return StringValidationError.stringContainsNonAlphaNumericCharacters | |
case .email: return StringValidationError.stringIsNotAValidEmailAddress | |
} | |
} | |
} | |
enum StringValidator: Validator { | |
case nonEmpty | |
case regex(RegexPattern) | |
case match(String) | |
func isValid(value: String) throws -> Bool { | |
let string = value | |
switch self { | |
case .nonEmpty: | |
guard string != "" else { | |
throw StringValidationError.stringIsEmpty | |
} | |
case .regex(let pattern): | |
try pattern.match(string) | |
case .match(let toMatch): | |
guard string == toMatch else { | |
throw StringValidationError.stringsDoNotMatch | |
} | |
} | |
return true | |
} | |
} | |
enum ComparableValidator<T: Comparable>: Validator { | |
case minimumValue(T) | |
case maximumValue(T) | |
case range(T, T) | |
func isValid(value: T) throws -> Bool { | |
var min: T? | |
var max: T? | |
switch self { | |
case .minimumValue(let inMin): | |
min = inMin | |
case .maximumValue(let inMax): | |
max = inMax | |
case .range(let inMin, let inMax): | |
min = inMin | |
max = inMax | |
guard min < max else { | |
throw ComparableValidationError<T>.lowerBoundsMustBeLessThanUpperBounds | |
} | |
} | |
if let min = min { | |
guard value >= min else { | |
throw ComparableValidationError.valueIsBelowMinimumBounds(min) | |
} | |
} | |
if let max = max { | |
guard value <= max else { | |
throw ComparableValidationError.valueIsAboveMaximumBounds(max) | |
} | |
} | |
return true | |
} | |
} | |
// MARK:- String Validation | |
extension String: Validateable { | |
typealias ValidatorType = StringValidator | |
} | |
// Pass | |
do { | |
try "abc".validValue(.regex(StringValidationPattern.alphaOnly), .match("abc")) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Fail | |
do { | |
try "123".validValue(.regex(StringValidationPattern.alphaOnly)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Pass | |
do { | |
try "123".validValue(.regex(StringValidationPattern.numericOnly)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Pass | |
do { | |
try "abc123".validValue(.regex(StringValidationPattern.alphaNumericOnly)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Pass | |
do { | |
try "[email protected]".validValue(.regex(StringValidationPattern.email)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// MARK:- Numeric Validation | |
// This is probably a little OTT as it would be more succint/readable to just do the comparison inline, but is here to demonstrate how generic the pattern is | |
extension Int: Validateable { | |
typealias ValidatorType = ComparableValidator<Int> | |
} | |
extension Double: Validateable { | |
typealias ValidatorType = ComparableValidator<Double> | |
} | |
extension Float: Validateable { | |
typealias ValidatorType = ComparableValidator<Float> | |
} | |
// Pass | |
do { | |
try 100.validValue(.minimumValue(50)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Fail | |
do { | |
try 100.validValue(.minimumValue(400)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Fail | |
do { | |
try 100.0.validValue(.range(0.1, 99.9)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// MARK:- TextField Validation | |
extension UITextField: Validateable { | |
typealias ValidatorType = StringValidator | |
func validValue(validators: StringValidator...) throws -> String { | |
return try text.validValue(validators) | |
} | |
} | |
let textField = UITextField() | |
// Fail | |
textField.text = "" | |
do { | |
let value = try textField.validValue(.nonEmpty, .regex(StringValidationPattern.alphaOnly)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Pass | |
textField.text = "abc" | |
do { | |
let value = try textField.validValue(.nonEmpty, .regex(StringValidationPattern.alphaOnly)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Fail | |
textField.text = "abc" | |
do { | |
let value = try textField.validValue(.regex(StringValidationPattern.numericOnly), .regex(StringValidationPattern.email)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Pass | |
textField.text = "[email protected]" | |
do { | |
let value = try textField.validValue(.nonEmpty, .regex(StringValidationPattern.email)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// Fail | |
textField.text = "not_a_valid_@_email_address.com" | |
do { | |
let value = try textField.validValue(.nonEmpty, .regex(StringValidationPattern.email)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} | |
// MARK:- Domain Specific Validation | |
enum UserValidationError: ErrorType { | |
case passwordIsUnexpectedFormat | |
} | |
enum UserValidationPattern: String, RegexPattern { | |
case password = "^[a-zA-Z0-9*_\\-&%\\$£@]{8,32}$" | |
var errorToThrow: ErrorType { | |
switch self { | |
case .password: return UserValidationError.passwordIsUnexpectedFormat | |
} | |
} | |
} | |
// Pass | |
let confirmationTextField = UITextField() | |
confirmationTextField.text = "somePa$$word" | |
textField.text = "somePa$$word" | |
do { | |
let value = try textField.validValue(.regex(UserValidationPattern.password), .match(confirmationTextField.text!)) | |
} catch { | |
error | |
} | |
// Fail | |
confirmationTextField.text = "somePa$$word" | |
textField.text = "somePa))word" | |
do { | |
let value = try textField.validValue(.regex(UserValidationPattern.password), .match(confirmationTextField.text!)) | |
} catch let aggregateError as AggregateError { | |
aggregateError.errors | |
} catch { | |
error | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment