diff --git a/README.md b/README.md index e77e456..e68c5c3 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,8 @@ struct RegistrationView: View { | `PostalCodeValidationRule` | Validates postal/ZIP codes for different countries | `PostalCodeValidationRule(country: .uk, error: "Invalid post code")` | `Base64ValidationRule` | | `Base64ValidationRule(error: "The input is not valid Base64.")` | `UUIDValidationRule` | Validates UUID format | `UUIDValidationRule(error: "Please enter a valid UUID")` | - +| `JSONValidationRule` | Validates that a string represents valid JSON | `JSONValidationRule(error: "Invalid JSON")` + ## Custom Validators Create custom validation rules by conforming to `IValidationRule`: diff --git a/Sources/ValidatorCore/Classes/Rules/JSONValidationRule.swift b/Sources/ValidatorCore/Classes/Rules/JSONValidationRule.swift new file mode 100644 index 0000000..bc9d5fc --- /dev/null +++ b/Sources/ValidatorCore/Classes/Rules/JSONValidationRule.swift @@ -0,0 +1,71 @@ +// +// Validator +// Copyright © 2026 Space Code. All rights reserved. +// + +import Foundation + +/// Validates that a string represents valid JSON. +/// +/// # Example: +/// ```swift +/// let rule = JSONValidationRule(error: "Invalid JSON") +/// rule.validate(input: "{\"key\": \"value\"}") // true +/// rule.validate(input: "not json") // false +/// ``` +public struct JSONValidationRule: IValidationRule { + // MARK: Types + + public typealias Input = String + + // MARK: Properties + + /// The validation error returned if the input is not valid JSON. + public let error: IValidationError + + // MARK: Initialization + + /// Initializes a JSON validation rule. + /// + /// - Parameter error: The validation error returned if input fails validation. + public init(error: IValidationError) { + self.error = error + } + + // MARK: IValidationRule + + public func validate(input: String) -> Bool { + guard !input.isEmpty else { return false } + guard let data = input.data(using: .utf8) else { return false } + + do { + _ = try JSONSerialization.jsonObject(with: data, options: []) + } catch { + return false + } + + return isStrictJSON(input) + } + + // MARK: Private Methods + + /// Validates strict JSON syntax to catch issues like trailing commas. + private func isStrictJSON(_ input: String) -> Bool { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + + let trailingCommaPatterns = [ + ",\\s*}", + ",\\s*\\]", + ] + + for pattern in trailingCommaPatterns where trimmed.range(of: pattern, options: .regularExpression) != nil { + return false + } + + if trimmed.range(of: ",,", options: .literal) != nil { + return false + } + + return true + } +} diff --git a/Sources/ValidatorCore/Classes/Rules/URLValidationRule.swift b/Sources/ValidatorCore/Classes/Rules/URLValidationRule.swift index e151840..c7c9553 100644 --- a/Sources/ValidatorCore/Classes/Rules/URLValidationRule.swift +++ b/Sources/ValidatorCore/Classes/Rules/URLValidationRule.swift @@ -5,14 +5,14 @@ import Foundation -/// Validates that a string represents a valid URL. -/// -/// # Example: -/// ```swift -/// let rule = URLValidationRule(error: "Invalid URL") -/// rule.validate(input: "https://example.com") // true -/// rule.validate(input: "not_a_url") // false -/// ``` +// Validates that a string represents a valid URL. +// +// # Example: +// ```swift +// let rule = URLValidationRule(error: "Invalid URL") +// rule.validate(input: "https://example.com") // true +// rule.validate(input: "not_a_url") // false +// ``` public struct URLValidationRule: IValidationRule { // MARK: Types diff --git a/Sources/ValidatorCore/Validator.docc/Overview.md b/Sources/ValidatorCore/Validator.docc/Overview.md index 88548e6..d2857b0 100644 --- a/Sources/ValidatorCore/Validator.docc/Overview.md +++ b/Sources/ValidatorCore/Validator.docc/Overview.md @@ -42,6 +42,7 @@ ValidatorCore contains all core validation rules, utilities, and mechanisms for - ``PostalCodeValidationRule`` - ``Base64ValidationRule`` - ``UUIDValidationRule`` +- ``JSONValidationRule`` ### Articles diff --git a/Tests/ValidatorCoreTests/UnitTests/Rules/JSONValidationRuleTests.swift b/Tests/ValidatorCoreTests/UnitTests/Rules/JSONValidationRuleTests.swift new file mode 100644 index 0000000..a07d9d7 --- /dev/null +++ b/Tests/ValidatorCoreTests/UnitTests/Rules/JSONValidationRuleTests.swift @@ -0,0 +1,189 @@ +// +// Validator +// Copyright © 2026 Space Code. All rights reserved. +// + +import ValidatorCore +import XCTest + +// MARK: - JSONValidationRuleTests + +final class JSONValidationRuleTests: XCTestCase { + // MARK: - Properties + + private var sut: JSONValidationRule! + + // MARK: - Setup + + override func setUp() { + super.setUp() + sut = JSONValidationRule(error: String.error) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Tests + + func test_validate_validJSONObject_shouldReturnTrue() { + // given + let json = "{\"key\": \"value\"}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertTrue(result) + } + + func test_validate_validJSONArray_shouldReturnTrue() { + // given + let json = "[1, 2, 3]" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertTrue(result) + } + + func test_validate_validNestedJSON_shouldReturnTrue() { + // given + let json = "{\"user\": {\"name\": \"John\", \"age\": 30}}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertTrue(result) + } + + func test_validate_validJSONWithNumbers_shouldReturnTrue() { + // given + let json = "{\"count\": 42, \"price\": 19.99}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertTrue(result) + } + + func test_validate_validJSONWithBooleans_shouldReturnTrue() { + // given + let json = "{\"active\": true, \"deleted\": false}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertTrue(result) + } + + func test_validate_validJSONWithNull_shouldReturnTrue() { + // given + let json = "{\"value\": null}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertTrue(result) + } + + func test_validate_invalidJSON_shouldReturnFalse() { + // given + let json = "{key: value}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertFalse(result) + } + + func test_validate_incompleteBraces_shouldReturnFalse() { + // given + let json = "{\"key\": \"value\"" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertFalse(result) + } + + func test_validate_trailingComma_shouldReturnFalse() { + // given + let json = "{\"key\": \"value\",}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertFalse(result) + } + + func test_validate_plainText_shouldReturnFalse() { + // given + let json = "not json" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertFalse(result) + } + + func test_validate_emptyString_shouldReturnFalse() { + // given + let json = "" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertFalse(result) + } + + func test_validate_whitespaceString_shouldReturnFalse() { + // given + let json = " " + + // when + let result = sut.validate(input: json) + + // then + XCTAssertFalse(result) + } + + func test_validate_singleQuotesJSON_shouldReturnFalse() { + // given + let json = "{'key': 'value'}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertFalse(result) + } + + func test_validate_missingQuotesOnKey_shouldReturnFalse() { + // given + let json = "{key: \"value\"}" + + // when + let result = sut.validate(input: json) + + // then + XCTAssertFalse(result) + } +} + +// MARK: Constants + +private extension String { + static let error = "JSON is invalid" +}