Skip to content

Commit 9ece819

Browse files
authored
feat: add JSON validation rule (#116)
1 parent ad223df commit 9ece819

File tree

5 files changed

+271
-9
lines changed

5 files changed

+271
-9
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,8 @@ struct RegistrationView: View {
319319
| `PostalCodeValidationRule` | Validates postal/ZIP codes for different countries | `PostalCodeValidationRule(country: .uk, error: "Invalid post code")`
320320
| `Base64ValidationRule` | | `Base64ValidationRule(error: "The input is not valid Base64.")`
321321
| `UUIDValidationRule` | Validates UUID format | `UUIDValidationRule(error: "Please enter a valid UUID")` |
322-
322+
| `JSONValidationRule` | Validates that a string represents valid JSON | `JSONValidationRule(error: "Invalid JSON")`
323+
323324
## Custom Validators
324325

325326
Create custom validation rules by conforming to `IValidationRule`:
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// Validator
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
8+
/// Validates that a string represents valid JSON.
9+
///
10+
/// # Example:
11+
/// ```swift
12+
/// let rule = JSONValidationRule(error: "Invalid JSON")
13+
/// rule.validate(input: "{\"key\": \"value\"}") // true
14+
/// rule.validate(input: "not json") // false
15+
/// ```
16+
public struct JSONValidationRule: IValidationRule {
17+
// MARK: Types
18+
19+
public typealias Input = String
20+
21+
// MARK: Properties
22+
23+
/// The validation error returned if the input is not valid JSON.
24+
public let error: IValidationError
25+
26+
// MARK: Initialization
27+
28+
/// Initializes a JSON validation rule.
29+
///
30+
/// - Parameter error: The validation error returned if input fails validation.
31+
public init(error: IValidationError) {
32+
self.error = error
33+
}
34+
35+
// MARK: IValidationRule
36+
37+
public func validate(input: String) -> Bool {
38+
guard !input.isEmpty else { return false }
39+
guard let data = input.data(using: .utf8) else { return false }
40+
41+
do {
42+
_ = try JSONSerialization.jsonObject(with: data, options: [])
43+
} catch {
44+
return false
45+
}
46+
47+
return isStrictJSON(input)
48+
}
49+
50+
// MARK: Private Methods
51+
52+
/// Validates strict JSON syntax to catch issues like trailing commas.
53+
private func isStrictJSON(_ input: String) -> Bool {
54+
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
55+
56+
let trailingCommaPatterns = [
57+
",\\s*}",
58+
",\\s*\\]",
59+
]
60+
61+
for pattern in trailingCommaPatterns where trimmed.range(of: pattern, options: .regularExpression) != nil {
62+
return false
63+
}
64+
65+
if trimmed.range(of: ",,", options: .literal) != nil {
66+
return false
67+
}
68+
69+
return true
70+
}
71+
}

Sources/ValidatorCore/Classes/Rules/URLValidationRule.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55

66
import Foundation
77

8-
/// Validates that a string represents a valid URL.
9-
///
10-
/// # Example:
11-
/// ```swift
12-
/// let rule = URLValidationRule(error: "Invalid URL")
13-
/// rule.validate(input: "https://example.com") // true
14-
/// rule.validate(input: "not_a_url") // false
15-
/// ```
8+
// Validates that a string represents a valid URL.
9+
//
10+
// # Example:
11+
// ```swift
12+
// let rule = URLValidationRule(error: "Invalid URL")
13+
// rule.validate(input: "https://example.com") // true
14+
// rule.validate(input: "not_a_url") // false
15+
// ```
1616
public struct URLValidationRule: IValidationRule {
1717
// MARK: Types
1818

Sources/ValidatorCore/Validator.docc/Overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ ValidatorCore contains all core validation rules, utilities, and mechanisms for
4242
- ``PostalCodeValidationRule``
4343
- ``Base64ValidationRule``
4444
- ``UUIDValidationRule``
45+
- ``JSONValidationRule``
4546

4647
### Articles
4748

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
//
2+
// Validator
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import ValidatorCore
7+
import XCTest
8+
9+
// MARK: - JSONValidationRuleTests
10+
11+
final class JSONValidationRuleTests: XCTestCase {
12+
// MARK: - Properties
13+
14+
private var sut: JSONValidationRule!
15+
16+
// MARK: - Setup
17+
18+
override func setUp() {
19+
super.setUp()
20+
sut = JSONValidationRule(error: String.error)
21+
}
22+
23+
override func tearDown() {
24+
sut = nil
25+
super.tearDown()
26+
}
27+
28+
// MARK: - Tests
29+
30+
func test_validate_validJSONObject_shouldReturnTrue() {
31+
// given
32+
let json = "{\"key\": \"value\"}"
33+
34+
// when
35+
let result = sut.validate(input: json)
36+
37+
// then
38+
XCTAssertTrue(result)
39+
}
40+
41+
func test_validate_validJSONArray_shouldReturnTrue() {
42+
// given
43+
let json = "[1, 2, 3]"
44+
45+
// when
46+
let result = sut.validate(input: json)
47+
48+
// then
49+
XCTAssertTrue(result)
50+
}
51+
52+
func test_validate_validNestedJSON_shouldReturnTrue() {
53+
// given
54+
let json = "{\"user\": {\"name\": \"John\", \"age\": 30}}"
55+
56+
// when
57+
let result = sut.validate(input: json)
58+
59+
// then
60+
XCTAssertTrue(result)
61+
}
62+
63+
func test_validate_validJSONWithNumbers_shouldReturnTrue() {
64+
// given
65+
let json = "{\"count\": 42, \"price\": 19.99}"
66+
67+
// when
68+
let result = sut.validate(input: json)
69+
70+
// then
71+
XCTAssertTrue(result)
72+
}
73+
74+
func test_validate_validJSONWithBooleans_shouldReturnTrue() {
75+
// given
76+
let json = "{\"active\": true, \"deleted\": false}"
77+
78+
// when
79+
let result = sut.validate(input: json)
80+
81+
// then
82+
XCTAssertTrue(result)
83+
}
84+
85+
func test_validate_validJSONWithNull_shouldReturnTrue() {
86+
// given
87+
let json = "{\"value\": null}"
88+
89+
// when
90+
let result = sut.validate(input: json)
91+
92+
// then
93+
XCTAssertTrue(result)
94+
}
95+
96+
func test_validate_invalidJSON_shouldReturnFalse() {
97+
// given
98+
let json = "{key: value}"
99+
100+
// when
101+
let result = sut.validate(input: json)
102+
103+
// then
104+
XCTAssertFalse(result)
105+
}
106+
107+
func test_validate_incompleteBraces_shouldReturnFalse() {
108+
// given
109+
let json = "{\"key\": \"value\""
110+
111+
// when
112+
let result = sut.validate(input: json)
113+
114+
// then
115+
XCTAssertFalse(result)
116+
}
117+
118+
func test_validate_trailingComma_shouldReturnFalse() {
119+
// given
120+
let json = "{\"key\": \"value\",}"
121+
122+
// when
123+
let result = sut.validate(input: json)
124+
125+
// then
126+
XCTAssertFalse(result)
127+
}
128+
129+
func test_validate_plainText_shouldReturnFalse() {
130+
// given
131+
let json = "not json"
132+
133+
// when
134+
let result = sut.validate(input: json)
135+
136+
// then
137+
XCTAssertFalse(result)
138+
}
139+
140+
func test_validate_emptyString_shouldReturnFalse() {
141+
// given
142+
let json = ""
143+
144+
// when
145+
let result = sut.validate(input: json)
146+
147+
// then
148+
XCTAssertFalse(result)
149+
}
150+
151+
func test_validate_whitespaceString_shouldReturnFalse() {
152+
// given
153+
let json = " "
154+
155+
// when
156+
let result = sut.validate(input: json)
157+
158+
// then
159+
XCTAssertFalse(result)
160+
}
161+
162+
func test_validate_singleQuotesJSON_shouldReturnFalse() {
163+
// given
164+
let json = "{'key': 'value'}"
165+
166+
// when
167+
let result = sut.validate(input: json)
168+
169+
// then
170+
XCTAssertFalse(result)
171+
}
172+
173+
func test_validate_missingQuotesOnKey_shouldReturnFalse() {
174+
// given
175+
let json = "{key: \"value\"}"
176+
177+
// when
178+
let result = sut.validate(input: json)
179+
180+
// then
181+
XCTAssertFalse(result)
182+
}
183+
}
184+
185+
// MARK: Constants
186+
187+
private extension String {
188+
static let error = "JSON is invalid"
189+
}

0 commit comments

Comments
 (0)