diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b6621a9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + open-pull-requests-limit: 10 + schedule: + interval: daily + time: '07:00' + timezone: Europe/Berlin + assignees: + - ns-vasilev + reviewers: + - ns-vasilev + commit-message: + prefix: "chore" + include: "scope" + labels: + - "dependencies" + + - package-ecosystem: swift + directory: / + open-pull-requests-limit: 10 + schedule: + interval: daily + time: '07:00' + timezone: Europe/Berlin + assignees: + - ns-vasilev + reviewers: + - ns-vasilev + commit-message: + prefix: "chore" + include: "scope" + labels: + - "dependencies" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 602285e..684e245 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: SwiftLint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: GitHub Action for SwiftLint uses: norio-nomura/action-swiftlint@3.2.1 with: @@ -80,7 +80,7 @@ jobs: - uses: actions/checkout@v5 - name: ${{ matrix.name }} run: xcodebuild test -scheme "Validator-Package" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: ${{ matrix.name }} path: test_output diff --git a/.github/workflows/conventional-pr.yml b/.github/workflows/conventional-pr.yml new file mode 100644 index 0000000..75569d0 --- /dev/null +++ b/.github/workflows/conventional-pr.yml @@ -0,0 +1,20 @@ +name: conventional-pr +on: + pull_request: + branches: + - main + - dev + types: + - opened + - edited + - synchronize +jobs: + lint-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: amannn/action-semantic-pull-request@v6 + with: + requireScope: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 158ca87..6a0dbaa 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -18,7 +18,7 @@ jobs: ruby-version: 3.1.4 bundler-cache: true - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v5 - name: Setup gems run: | gem install bundler diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Validator-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Validator-Package.xcscheme deleted file mode 100644 index ea021af..0000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Validator-Package.xcscheme +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ValidatorCore.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ValidatorCore.xcscheme deleted file mode 100644 index 57f8c0c..0000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/ValidatorCore.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ValidatorUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ValidatorUI.xcscheme deleted file mode 100644 index ef67b36..0000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/ValidatorUI.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b5df2..25248a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,29 @@ All notable changes to this project will be documented in this file. - `1.1.x` Releases - [1.1.0](#110) - `1.0.x` Releases - [1.0.0](#100) | [1.0.1](#101) -## [1.1.0](https://github.com/space-code/validator/releases/tag/1.2.0) +## [Unreleased] + +#### Added +- Add `dependabot.yml` + - Added in Pull Request [#21](https://github.com/space-code/validator/pull/21). +- Add `URLValidationRule`. + - Added in Pull Request [#25](https://github.com/space-code/validator/pull/25). +- Add `CreditCardValidationRule`. + - Added in Pull Request [#26](https://github.com/space-code/validator/pull/26). +- Add `conventional-pr.yml` for PR validation. + - Added in Pull Request [#27](https://github.com/space-code/validator/pull/27). + +#### Updated +- Update `Mintfile` + - Updated in Pull Request [#22](https://github.com/space-code/validator/pull/22). +- Update `dependabot.yml` + - Updated in Pull Request [#28](https://github.com/space-code/validator/pull/28). + +#### Fixed +- Fix conventional commit script execution in GitHub Actions + - Fixed in Pull Request [#29](https://github.com/space-code/validator/pull/29). + +## [1.2.0](https://github.com/space-code/validator/releases/tag/1.2.0) Released on 2025-11-13. #### Updated diff --git a/Mintfile b/Mintfile index ebeb1c1..4ca9786 100644 --- a/Mintfile +++ b/Mintfile @@ -1,2 +1,2 @@ -nicklockwood/SwiftFormat@0.54.0 -realm/SwiftLint@0.55.1 +nicklockwood/SwiftFormat@0.58.6 +realm/SwiftLint@0.62.2 \ No newline at end of file diff --git a/README.md b/README.md index 9abe793..68351f4 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,14 @@ If you need to validate some data, you can use the `Validator` object for this p import ValidatorCore let validator = Validator() -let result = validator.validate(input: "text", rule: LengthValidationRule(min: 4, error: "error text")) +let result = validator.validate(input: "text", rule: LengthValidationRule(min: 4, error: "Text must be at least 4 characters long")) switch result { case .valid: - print("text is valid") + print("Text is valid") case let .invalid(errors): // handle validation errors - print(errors) + print("Validation errors: \(errors.map(\.message))") } ``` @@ -66,16 +66,16 @@ class ViewController: UIViewController { super.viewDidLoad() /// Adds validation rule to the text field. - textField.add(rule: LengthValidationRule(max: 10, error: "error text")) + textField.add(rule: LengthValidationRule(max: 10, error: "Text must be at most 10 characters")) /// Enables automatic validation on input change. textField.validateOnInputChange(isEnabled: true) /// Handle the validation result. textField.validationHandler = { result in switch result { case .valid: - print("text is valid") + print("Text is valid") case let .invalid(errors): - print(errors) + print("Validation errors: \(errors)") } } } @@ -122,14 +122,14 @@ struct ContentView: View { @State private var text: String = "" private let validationRules: [any IValidationRule] = [ - LengthValidationRule(max: 10, error: "Text error") + LengthValidationRule(max: 10, error: "Text must be at most 10 characters") ] var body: some View { VStack { - TextField("Text", text: $text) + TextField("Enter text", text: $text) .validate(item: $text, rules: validationRules) { errors in - Text("Text is bigger than 10 characters") + Text("Text exceeds 10 characters") } } } @@ -149,10 +149,10 @@ class Form: ObservableObject { @Published var manager = FormFieldManager() - @FormField(rules: [LengthValidationRule(max: 20, error: "The first name is very long")]) + @FormField(rules: [LengthValidationRule(max: 20, error: "First name is too long")]) var firstName: String = "" - @FormField(rules: [LengthValidationRule(min: 5, error: "The last name is too short")]) + @FormField(rules: [LengthValidationRule(min: 5, error: "Last name is too short")]) var lastName: String = "" lazy var firstNameValidationContainer = _firstName.validate(manager: manager) @@ -180,9 +180,9 @@ struct ContentView: View { form.manager.$isValid, perform: { value in if value { - print("The form's fields are valid") + print("All form fields are valid") } else { - print("The form's fields aren't valid") + print("Some form fields are invalid") } } ) @@ -199,6 +199,8 @@ struct ContentView: View { | **PrefixValidationRule** | To validate whether a string contains a prefix | | **SuffixValidationRule** | To validate whether a string contains a suffix | | **RegexValidationRule** | To validate if a pattern is matched | +| **URLValidationRule** | To validate whether a string contains a URL | +| **CreditCardValidationRule** | To validate whether a string has a valid credit card number | ## Custom Validation Rules diff --git a/Sources/ValidatorCore/Classes/Rules/CreditCardValidationRule.swift b/Sources/ValidatorCore/Classes/Rules/CreditCardValidationRule.swift new file mode 100644 index 0000000..76c2c70 --- /dev/null +++ b/Sources/ValidatorCore/Classes/Rules/CreditCardValidationRule.swift @@ -0,0 +1,76 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +/// A credit card validation rule. +public struct CreditCardValidationRule: IValidationRule { + // MARK: Types + + public enum CardType: String, Sendable, CaseIterable { + case visa, masterCard, amex, jcb, unionPay + } + + public typealias Input = String + + // MARK: Properties + + public let types: [CardType] + + /// The validation error. + public let error: IValidationError + + // MARK: Initialization + + public init(types: [CardType] = CardType.allCases, error: IValidationError) { + self.types = types + self.error = error + } + + // MARK: IValidationRule + + public func validate(input: String) -> Bool { + let sanitized = input.replacingOccurrences(of: " ", with: "") + + guard sanitized.allSatisfy(\.isNumber) else { return false } + + guard types.contains(where: { matches(cardNumber: sanitized, type: $0) }) else { return false } + + return isValidLuhn(sanitized) + } + + // MARK: Private + + private func matches(cardNumber: String, type: CardType) -> Bool { + switch type { + case .visa: + cardNumber.hasPrefix("4") && (cardNumber.count == 13 || cardNumber.count == 16 || cardNumber.count == 19) + case .masterCard: + (cardNumber.hasPrefix("51") || cardNumber.hasPrefix("52") || + cardNumber.hasPrefix("53") || cardNumber.hasPrefix("54") || + cardNumber.hasPrefix("55")) && cardNumber.count == 16 + case .amex: + (cardNumber.hasPrefix("34") || cardNumber.hasPrefix("37")) && cardNumber.count == 15 + case .jcb: + (cardNumber.hasPrefix("3528") || cardNumber.hasPrefix("3589")) && cardNumber.count == 16 + case .unionPay: + cardNumber.hasPrefix("62") && (cardNumber.count >= 16 && cardNumber.count <= 19) + } + } + + private func isValidLuhn(_ cardNumber: String) -> Bool { + let reversedDigits = cardNumber.reversed().map { Int(String($0)) ?? 0 } + var sum = 0 + + for (index, digit) in reversedDigits.enumerated() { + if !index.isMultiple(of: 2) { + let doubled = digit * 2 + sum += doubled > 9 ? doubled - 9 : doubled + } else { + sum += digit + } + } + + return sum.isMultiple(of: 10) + } +} diff --git a/Sources/ValidatorCore/Classes/Rules/URLValidationRule.swift b/Sources/ValidatorCore/Classes/Rules/URLValidationRule.swift new file mode 100644 index 0000000..c52f6ae --- /dev/null +++ b/Sources/ValidatorCore/Classes/Rules/URLValidationRule.swift @@ -0,0 +1,31 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +import Foundation + +/// A url validation rule. +public struct URLValidationRule: IValidationRule { + // MARK: Types + + public typealias Input = String + + // MARK: Properties + + /// The validation error. + public let error: IValidationError + + // MARK: Initialization + + public init(error: IValidationError) { + self.error = error + } + + // MARK: IValidationRule + + public func validate(input: String) -> Bool { + guard let url = URL(string: input) else { return false } + return url.isFileURL || (url.host != nil && url.scheme != nil) + } +} diff --git a/Tests/ValidatorCoreTests/UnitTests/Rules/CreditCardValidationRuleTests.swift b/Tests/ValidatorCoreTests/UnitTests/Rules/CreditCardValidationRuleTests.swift new file mode 100644 index 0000000..9ac1993 --- /dev/null +++ b/Tests/ValidatorCoreTests/UnitTests/Rules/CreditCardValidationRuleTests.swift @@ -0,0 +1,164 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +@testable import ValidatorCore +import XCTest + +// MARK: - CreditCardValidationRuleTests + +final class CreditCardValidationRuleTests: XCTestCase { + // MARK: - Visa + + func test_validate_visaCardValid_shouldReturnTrue() { + // given + let rule = CreditCardValidationRule(types: [.visa], error: String.error) + let validVisa = "4111111111111111" + + // when + let isValid = rule.validate(input: validVisa) + + // then + XCTAssertTrue(isValid) + } + + func test_validate_visaCardInvalid_shouldReturnFalse() { + // given + let rule = CreditCardValidationRule(types: [.visa], error: String.error) + let invalidVisa = "411111111111112" + + // when + let isValid = rule.validate(input: invalidVisa) + + // then + XCTAssertFalse(isValid) + } + + // MARK: - MasterCard + + func test_validate_masterCardValid_shouldReturnTrue() { + // given + let rule = CreditCardValidationRule(types: [.masterCard], error: String.error) + let validMaster = "5500000000000004" + + // when + let isValid = rule.validate(input: validMaster) + + // then + XCTAssertTrue(isValid) + } + + func test_validate_masterCardInvalid_shouldReturnFalse() { + // given + let rule = CreditCardValidationRule(types: [.masterCard], error: String.error) + let invalidMaster = "550000000000" + + // when + let isValid = rule.validate(input: invalidMaster) + + // then + XCTAssertFalse(isValid) + } + + // MARK: - American Express + + func test_validate_amexValid_shouldReturnTrue() { + // given + let rule = CreditCardValidationRule(types: [.amex], error: String.error) + let validAmex = "340000000000009" + + // when + let isValid = rule.validate(input: validAmex) + + // then + XCTAssertTrue(isValid) + } + + func test_validate_amexInvalid_shouldReturnFalse() { + // given + let rule = CreditCardValidationRule(types: [.amex], error: String.error) + let invalidAmex = "3400000000000099" + + // when + let isValid = rule.validate(input: invalidAmex) + + // then + XCTAssertFalse(isValid) + } + + // MARK: - JCB + + func test_validate_jcbValid_shouldReturnTrue() { + // given + let rule = CreditCardValidationRule(types: [.jcb], error: String.error) + let validJCB = "3528000000000007" + + // when + let isValid = rule.validate(input: validJCB) + + // then + XCTAssertTrue(isValid) + } + + // MARK: - UnionPay + + func test_validate_unionPayValid_shouldReturnTrue() { + // given + let rule = CreditCardValidationRule(types: [.unionPay], error: String.error) + let validUnionPay = "6221260000000000" + + // when + let isValid = rule.validate(input: validUnionPay) + + // then + XCTAssertTrue(isValid) + } + + // MARK: - Multiple Types + + func test_validate_multipleTypesValid_shouldReturnTrue() { + // given + let rule = CreditCardValidationRule(types: [.visa, .amex, .masterCard], error: String.error) + let validVisa = "4111111111111111" + + // when + let isValid = rule.validate(input: validVisa) + + // then + XCTAssertTrue(isValid) + } + + // MARK: - Unknown Card Type + + func test_validate_unknownCardType_shouldReturnFalse() { + // given + let rule = CreditCardValidationRule(types: [.visa], error: String.error) + let randomCard = "9999999999999999" + + // when + let isValid = rule.validate(input: randomCard) + + // then + XCTAssertFalse(isValid) + } + + // MARK: - Empty Input + + func test_validate_emptyString_shouldReturnFalse() { + // given + let rule = CreditCardValidationRule(types: [.visa], error: String.error) + + // when + let isValid = rule.validate(input: "") + + // then + XCTAssertFalse(isValid) + } +} + +// MARK: Constants + +private extension String { + static let error = "Invalid card number" +} diff --git a/Tests/ValidatorCoreTests/UnitTests/Rules/URLValidationRuleTests.swift b/Tests/ValidatorCoreTests/UnitTests/Rules/URLValidationRuleTests.swift new file mode 100644 index 0000000..b95358e --- /dev/null +++ b/Tests/ValidatorCoreTests/UnitTests/Rules/URLValidationRuleTests.swift @@ -0,0 +1,123 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +import ValidatorCore +import XCTest + +// MARK: - URLValidationRuleTests + +final class URLValidationRuleTests: XCTestCase { + // MARK: - Properties + + private var sut: URLValidationRule! + + // MARK: - Setup + + override func setUp() { + super.setUp() + sut = URLValidationRule(error: String.error) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Tests + + func test_validate_validURL_shouldReturnTrue() { + // given + let url = "https://google.com" + + // when + let result = sut.validate(input: url) + + // then + XCTAssertTrue(result) + } + + func test_validate_missingScheme_shouldReturnFalse() { + // given + let url = "google.com" + + // when + let result = sut.validate(input: url) + + // then + XCTAssertFalse(result) + } + + func test_validate_missingHost_shouldReturnFalse() { + // given + let url = "https://" + + // when + let result = sut.validate(input: url) + + // then + XCTAssertFalse(result) + } + + func test_validate_emptyString_shouldReturnFalse() { + // given + let url = "" + + // when + let result = sut.validate(input: url) + + // then + XCTAssertFalse(result) + } + + func test_validate_whitespaceString_shouldReturnFalse() { + // given + let url = " " + + // when + let result = sut.validate(input: url) + + // then + XCTAssertFalse(result) + } + + func test_validate_ipAddressURL_shouldReturnTrue() { + // given + let url = "http://192.168.0.1" + + // when + let result = sut.validate(input: url) + + // then + XCTAssertTrue(result) + } + + func test_validate_urlWithPort_shouldReturnTrue() { + // given + let url = "https://localhost:8080" + + // when + let result = sut.validate(input: url) + + // then + XCTAssertTrue(result) + } + + func test_validate_ftpScheme_shouldReturnTrue() { + // given + let url = "ftp://example.com" + + // when + let result = sut.validate(input: url) + + // then + XCTAssertTrue(result) + } +} + +// MARK: Constants + +private extension String { + static let error = "URL is invalid" +}