Skip to content

Commit 5f26197

Browse files
committed
Initial commit
1 parent aac58f8 commit 5f26197

File tree

5 files changed

+492
-4
lines changed

5 files changed

+492
-4
lines changed

Package.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import PackageDescription
55

66
let package = Package(
77
name: "DynamicJSON",
8+
platforms: [
9+
.iOS(.v16), .macOS(.v11)
10+
],
811
products: [
912
// Products define the executables and libraries a package produces, making them visible to other packages.
1013
.library(

README.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# 🧩 DynamicJSON
2+
3+
`DynamicJSON` is a resilient, smart, and flexible wrapper for decoding and interacting
4+
with dynamic or loosely-structured JSON in Swift. It supports fuzzy key matching,
5+
automatic type casting, and dot-path access — making it ideal for APIs,
6+
configuration files, feature flags, and analytics payloads.
7+
8+
---
9+
10+
## ✨ Why DynamicJSON?
11+
12+
Modern APIs often return unpredictable or inconsistently formatted JSON:
13+
14+
- Keys can be in `camelCase`, `snake_case`, `kebab-case`, or `ALLCAPS`
15+
- Values might be `"true"` instead of `true`, `"1"` instead of `1`
16+
- Dates may be strings, numbers (timestamps), or malformed
17+
- You might receive `"enabled"`, `"yes"`, `1`, or `true` for the same toggle
18+
19+
`DynamicJSON` makes handling all of this effortless with:
20+
21+
✅ Normalized key lookup
22+
✅ Fuzzy match for typos and partial keys
23+
✅ Smart casting for `Bool`, `Int`, `Double`, `String`, `Date`
24+
✅ Dot-path access for deep nested values
25+
✅ Dynamic member syntax (`json.user.name`)
26+
✅ Type-safe `.as()` casting method
27+
28+
---
29+
30+
## 💡 Key Use Cases
31+
32+
- Feature flag systems
33+
- API responses with evolving schemas
34+
- Third-party JSON data ingestion
35+
- Analytics payloads and event tracking
36+
- Backend-driven UI toggles
37+
- Configuration files
38+
39+
---
40+
41+
## 📦 Installation
42+
43+
```swift
44+
.package(url: "https://github.com/yourusername/DynamicFeatureFlag.git", from: "1.0.0")
45+
```
46+
47+
## ✅ Features Overview
48+
49+
Feature |Example
50+
----------------
51+
Dot-path access | json["user.settings.notifications.email"]
52+
Dynamic member lookup | json.user.settings.notifications.email
53+
Subscript with normalization | json["UserSettings"] == json.user_settings
54+
Case-insensitive + format-tolerant | "Feature_Toggle" == "featureToggle"
55+
Fuzzy key match (typo-tolerant) | "featurTogle" → "feature_toggle"
56+
Partial match support | "beta" → "beta_feature_x"
57+
58+
# 🚀 Usage Example
59+
60+
```json
61+
{
62+
"featureToggle": "true",
63+
"maxItems": "25",
64+
"discountRate": 12.5,
65+
"launchDate": "2025-01-01T00:00:00Z",
66+
"settings": {
67+
"dark_mode": "on",
68+
"notifications": {
69+
"email": "yes",
70+
"push": false
71+
}
72+
}
73+
}
74+
```
75+
76+
```swift
77+
let json = try JSONDecoder().decode(DynamicJSON.self, from: jsonData)
78+
79+
let isEnabled = json.featureToggle.bool // true
80+
let maxItems = json["maxItems"].int // 25
81+
let discount = json.discountRate.double // 12.5
82+
let launch = json.launchDate.as(Date.self) // 2025-01-01
83+
84+
let emailOn = json.settings.notifications.email.bool // true
85+
let pushOn = json["settings.notifications.push"].bool // false
86+
```
87+
88+
## 🧠 Smart Key Matching
89+
90+
DynamicJSON will normalize and match keys like:
91+
• "FeatureToggle" → "feature_toggle"
92+
• "darkMode" → "dark_mode"
93+
• "beta-feature-x" → "beta_feature_x"
94+
• "FEATURETOGGLE" → "feature_toggle"
95+
• "featurTogle" → fuzzy match → "feature_toggle"
96+
97+
📅 Date Parsing
98+
99+
Supports:
100+
• 2024-01-01T12:34:56Z
101+
• 2024-01-01T12:34:56.123Z
102+
• 2024-01-01 12:34:56
103+
• 2024-01-01
104+
• 01/01/2024
105+
• 01-01-2024
106+
• UNIX timestamp: 1704067200 or 1704067200000
107+
108+
## 🔬 API
109+
110+
Accessors
111+
112+
```swift
113+
json["key"]
114+
json.key
115+
json["nested.key.path"]
116+
json.key.bool / .int / .double / .string / .date
117+
json.key.as(Int.self)
118+
```
119+
120+
Containers
121+
122+
```swift
123+
json.array [DynamicJSON]?
124+
json.dictionary [String: DynamicJSON]?
125+
json.isNull Bool
126+
```
127+
128+
## 🧪 Testing
129+
130+
Includes a full real-world test case covering:
131+
• All primitive types
132+
• Arrays and nested keys
133+
• Date strings and timestamps
134+
• Fuzzy and normalized keys
135+
• Null and missing key handling
Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,207 @@
1-
// The Swift Programming Language
2-
// https://docs.swift.org/swift-book
1+
//
2+
// DynamicJSON.swift
3+
// DynamicFeatureFlag
4+
//
5+
// Created by Hamad Ali on 11/05/2025.
6+
//
7+
8+
import Foundation
9+
import Combine
10+
11+
@dynamicMemberLookup
12+
public enum DynamicJSON: Decodable {
13+
case dictionary([String: DynamicJSON])
14+
case array([DynamicJSON])
15+
case string(String)
16+
case number(Double)
17+
case bool(Bool)
18+
case null
19+
20+
public init(from decoder: Decoder) throws {
21+
if let container = try? decoder.container(keyedBy: DynamicCodingKeys.self) {
22+
var dict: [String: DynamicJSON] = [:]
23+
for key in container.allKeys {
24+
let normalized = key.stringValue.normalizedKey()
25+
dict[normalized] = try container.decode(DynamicJSON.self, forKey: key)
26+
}
27+
self = .dictionary(dict)
28+
} else if var arrayContainer = try? decoder.unkeyedContainer() {
29+
var arr: [DynamicJSON] = []
30+
while !arrayContainer.isAtEnd {
31+
arr.append(try arrayContainer.decode(DynamicJSON.self))
32+
}
33+
self = .array(arr)
34+
} else if let val = try? decoder.singleValueContainer().decode(Bool.self) {
35+
self = .bool(val)
36+
} else if let val = try? decoder.singleValueContainer().decode(Double.self) {
37+
self = .number(val)
38+
} else if let val = try? decoder.singleValueContainer().decode(String.self) {
39+
self = .string(val)
40+
} else {
41+
self = .null
42+
}
43+
}
44+
45+
private struct DynamicCodingKeys: CodingKey {
46+
var stringValue: String
47+
init?(stringValue: String) { self.stringValue = stringValue }
48+
var intValue: Int? = nil
49+
init?(intValue: Int) { nil }
50+
}
51+
52+
public subscript(dynamicMember key: String) -> DynamicJSON {
53+
self[key]
54+
}
55+
56+
public subscript(_ key: String) -> DynamicJSON {
57+
let parts = key.split(separator: ".").map { String($0).normalizedKey() }
58+
return parts.reduce(self) { current, part in
59+
guard case .dictionary(let dict) = current else { return .null }
60+
if let exact = dict[part] {
61+
return exact
62+
}
63+
return dict.fuzzyMatch(for: part) { original, matched in
64+
print("⚠️ [DynamicJSON] '\(original)' matched with '\(matched)' via fuzzy/partial logic.")
65+
} ?? .null
66+
}
67+
}
68+
69+
public var bool: Bool? {
70+
switch self {
71+
case .bool(let val): return val
72+
case .number(let num): return num != 0
73+
case .string(let str): return ["1", "true", "yes", "on"].contains(str.lowercased())
74+
default: return nil
75+
}
76+
}
77+
78+
public var string: String? {
79+
switch self {
80+
case .string(let str): return str
81+
case .number(let num):
82+
if num.truncatingRemainder(dividingBy: 1) == 0 {
83+
return String(Int(num))
84+
} else {
85+
return String(num)
86+
}
87+
case .bool(let b): return String(b)
88+
default: return nil
89+
}
90+
}
91+
92+
public var date: Date? {
93+
switch self {
94+
case .string(let str):
95+
96+
// Try ISO8601 first
97+
let isoFormatter = ISO8601DateFormatter()
98+
if let date = isoFormatter.date(from: str) {
99+
return date
100+
}
101+
102+
let formatter = DateFormatter()
103+
formatter.locale = Locale(identifier: "en_US_POSIX")
104+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
105+
106+
// Try fallback formats
107+
let fallbackFormats = [
108+
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX",
109+
"yyyy-MM-dd'T'HH:mm:ssXXXXX",
110+
"yyyy-MM-dd HH:mm:ss",
111+
"yyyy-MM-dd",
112+
"MM/dd/yyyy",
113+
"dd-MM-yyyy"
114+
]
115+
116+
for format in fallbackFormats {
117+
formatter.dateFormat = format
118+
if let date = formatter.date(from: str) {
119+
return date
120+
}
121+
}
122+
return nil
123+
case .number(let num):
124+
// If it's a large number, treat as milliseconds
125+
if num > 1_000_000_000_000 {
126+
return Date(timeIntervalSince1970: num / 1000)
127+
} else {
128+
return Date(timeIntervalSince1970: num)
129+
}
130+
default: return nil
131+
}
132+
}
133+
134+
public var int: Int? {
135+
switch self {
136+
case .number(let num):
137+
return Int(num)
138+
case .string(let str):
139+
if let intVal = Int(str) {
140+
return intVal
141+
} else if let doubleVal = Double(str) {
142+
return Int(doubleVal)
143+
} else if ["true", "yes", "on"].contains(str.lowercased()) {
144+
return 1
145+
} else if ["false", "no", "off"].contains(str.lowercased()) {
146+
return 0
147+
}
148+
return nil
149+
case .bool(let b):
150+
return b ? 1 : 0
151+
default:
152+
return nil
153+
}
154+
}
155+
156+
public var double: Double? {
157+
switch self {
158+
case .number(let num): return num
159+
case .string(let str):
160+
if let doubleVal = Double(str) {
161+
return doubleVal
162+
} else if let intVal = Int(str) {
163+
return Double(intVal)
164+
} else if ["true", "yes", "on"].contains(str.lowercased()) {
165+
return 1.0
166+
} else if ["false", "no", "off"].contains(str.lowercased()) {
167+
return 0.0
168+
}
169+
return nil
170+
case .bool(let b): return b ? 1.0 : 0.0
171+
default:
172+
return nil
173+
}
174+
}
175+
176+
public var isNull: Bool {
177+
if case .null = self { return true }
178+
return false
179+
}
180+
181+
public var dictionary: [String: DynamicJSON]? {
182+
if case .dictionary(let dict) = self { return dict }
183+
return nil
184+
}
185+
186+
public var array: [DynamicJSON]? {
187+
if case .array(let arr) = self { return arr }
188+
return nil
189+
}
190+
191+
public func `as`<T>(_ type: T.Type) -> T? {
192+
switch type {
193+
case is Bool.Type:
194+
return bool as? T
195+
case is Int.Type:
196+
return int as? T
197+
case is Double.Type:
198+
return double as? T
199+
case is String.Type:
200+
return string as? T
201+
case is Date.Type:
202+
return date as? T
203+
default:
204+
return nil
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)