├── .gitignore ├── Tests ├── LinuxMain.swift └── json2codableTests │ ├── XCTestManifests.swift │ └── json2codableTests.swift ├── Sources └── json2codable │ ├── Error.swift │ ├── ReadStdin.swift │ ├── Deserialize.swift │ ├── String+Extension.swift │ ├── main.swift │ ├── JSONType.swift │ ├── Parse.swift │ ├── Render.swift │ └── Merge.swift ├── Package.swift ├── LICENSE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import json2codableTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += json2codableTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/json2codableTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(json2codableTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Sources/json2codable/Error.swift: -------------------------------------------------------------------------------- 1 | enum J2CError: Error { 2 | case stdinReadError 3 | case deserializationError(Error) 4 | case unknownType 5 | case unableToMergeTypes(String) 6 | case invalidRootType 7 | case internalInconsistencyError 8 | } 9 | -------------------------------------------------------------------------------- /Sources/json2codable/ReadStdin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Read from `stdin` and return input as `Data` after stdin is closed 4 | func readStdin() -> Result { 5 | let data = FileHandle.standardInput.readDataToEndOfFile() 6 | return .success(data) 7 | } 8 | -------------------------------------------------------------------------------- /Sources/json2codable/Deserialize.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Deserialize a JSON document to an `Any` type 4 | /// - Parameter data: The JSON document as a `Data` value 5 | func deserialize(data: Data) -> Result { 6 | do { 7 | let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) 8 | return .success(jsonObject) 9 | } catch { 10 | return .failure(.deserializationError(error)) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "json2codable", 8 | dependencies: [ 9 | ], 10 | targets: [ 11 | .target( 12 | name: "json2codable", 13 | dependencies: []), 14 | .testTarget( 15 | name: "json2codableTests", 16 | dependencies: ["json2codable"]), 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /Sources/json2codable/String+Extension.swift: -------------------------------------------------------------------------------- 1 | extension String { 2 | /// Capitalize the first letter. Don't change any other letters. 3 | func capitalizedFirst() -> String { 4 | guard let first = first else { 5 | return self 6 | } 7 | 8 | return first.uppercased() + self.dropFirst() 9 | } 10 | 11 | /// Singularize a string by dropping the last character if string ends with "s" 12 | func singularize() -> String { 13 | guard hasSuffix("s") else { 14 | return self 15 | } 16 | return String(dropLast()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/json2codable/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// json2codable main function 4 | /// 5 | /// `json2codable` runs through three stages: 6 | /// - Deserializing: Deserializes the JSON document into an `Any` type using `JSONSerialization` 7 | /// - Parsing: The `Any` type is parsed into a `JSONType` value. 8 | /// - Rendering: The `JSONType` is rendered as a new Swift `struct` type 9 | func main() { 10 | let result = readStdin() 11 | .flatMap(deserialize(data:)) 12 | .flatMap(parse(object:)) 13 | .flatMap(render(type:)) 14 | 15 | switch result { 16 | case .failure(let error): 17 | print("Error: \(error)") 18 | case .success(let type): 19 | print(type) 20 | } 21 | } 22 | 23 | main() 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Simon Stiefel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/json2codable/JSONType.swift: -------------------------------------------------------------------------------- 1 | /// This type contains the parsed JSON document 2 | /// 3 | /// Notes: 4 | /// * `unknown` is not expected to be used in the final value 5 | /// * A `null` value in a JSON document is stored as an `.optional(.unknown)` until it is merged with another known type 6 | indirect enum JSONType: Equatable { 7 | case unknown 8 | case bool 9 | case int 10 | case double 11 | case string 12 | case array(JSONType) 13 | case dict([String: JSONType]) 14 | case optional(JSONType) 15 | } 16 | 17 | extension JSONType: CustomStringConvertible { 18 | var description: String { 19 | switch self { 20 | case .unknown: 21 | return "unknown" 22 | case .bool: 23 | return "bool" 24 | case .int: 25 | return "int" 26 | case .double: 27 | return "double" 28 | case .string: 29 | return "string" 30 | case .array(let content): 31 | return "array(\(content.description))" 32 | case .dict(let content): 33 | let contentString = content 34 | .map { "\($0): \($1.description)" } 35 | .joined(separator: ",\n") 36 | return "dict(\(contentString))" 37 | case .optional(let content): 38 | return "optional(\(content.description))" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON2Codable 2 | 3 | JSON2Codable is a simple command-line tool that reads a JSON document from stdin and prints out a new Codable-conforming Swift struct that matches the structure of the JSON document. 4 | 5 | ## Example 6 | 7 | ``` 8 | $ cat colors.json 9 | { 10 | "colors": [ 11 | { 12 | "color": "red", 13 | "category": "hue", 14 | "type": "primary", 15 | "code": { 16 | "rgba": [255,0,0,1], 17 | "hex": "#FF0" 18 | } 19 | }, 20 | { 21 | "color": "blue", 22 | "category": "hue", 23 | "type": "primary", 24 | "code": { 25 | "rgba": [0,0,255,1], 26 | "hex": "#00F" 27 | } 28 | } 29 | ] 30 | } 31 | 32 | $ cat colors.json | json2codable 33 | struct NewType: Codable { 34 | let colors: [Color] 35 | struct Color: Codable { 36 | let category: String 37 | let code: Code 38 | struct Code: Codable { 39 | let hex: String 40 | let rgba: [Int] 41 | } 42 | let color: String 43 | let type: String 44 | } 45 | } 46 | ``` 47 | 48 | ## Known issues 49 | - Heterogeneous types in arrays are currently not supported (e.g `[123, "string"]`) unless the types can be "merged", for example `Int` can be promoted to `Double` and any type can become an `Optional` if a `null` is present in the array. 50 | - If the root type of the JSON document is an array, it is unwrapped until a dictionary is found. That dictionary forms the new root type of the final Swift struct. JSON documents with an array root type are expected to be decoded to a Swift type wrapped in an array (e.g. `JSONDecoder().decode([SomeType].self, from: jsonData)`) 51 | 52 | -------------------------------------------------------------------------------- /Sources/json2codable/Parse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Parse an `Any` value to a `JSONType` 4 | /// - Parameter object: A derserialized JSON document 5 | func parse(object: Any) -> Result { 6 | 7 | if object is Bool { 8 | return .success(.bool) 9 | } 10 | 11 | if object is Int { 12 | return .success(.int) 13 | } 14 | 15 | if object is Double { 16 | return .success(.double) 17 | } 18 | 19 | if object is String { 20 | return .success(.string) 21 | } 22 | 23 | if object is NSNull { 24 | return .success(.optional(.unknown)) 25 | } 26 | 27 | if let dict = object as? [String: Any] { 28 | var resultDict = [String: JSONType]() 29 | for element in dict { 30 | let result = parse(object: element.value) 31 | guard case .success(let type) = result else { 32 | return result 33 | } 34 | resultDict[element.key] = type 35 | } 36 | 37 | return .success(.dict(resultDict)) 38 | } 39 | 40 | if let array = object as? [Any] { 41 | // Progressively merge the types of all elements together 42 | var type: JSONType = .unknown 43 | 44 | for element in array { 45 | let parseResult = parse(object: element) 46 | guard case .success(let parsedType) = parseResult else { 47 | return parseResult 48 | } 49 | 50 | let mergeResult = merge(type, parsedType) 51 | guard case .success(let mergedType) = mergeResult else { 52 | return mergeResult 53 | } 54 | type = mergedType 55 | } 56 | return .success(.array(type)) 57 | } 58 | 59 | return .failure(.unknownType) 60 | } 61 | -------------------------------------------------------------------------------- /Sources/json2codable/Render.swift: -------------------------------------------------------------------------------- 1 | /// The rendered representation of a `JSONType` 2 | /// 3 | /// - `name` is the name of the type, e.g. `[Int?]` 4 | /// - `def` is the definition of the type if one exists (only used for dictionaries) 5 | typealias RenderedType = (name: String, def: String) 6 | 7 | /// Render a `JSONType` to a String (Swift struct) 8 | /// 9 | /// Only `.array` or `.dict` values can be rendered. 10 | /// `.array` is unwrapped until a `.dict` is found. That dictionary then forms the new root type of the final Swift struct. 11 | /// JSON documents with an array root type are expected to be decoded to a Swift type wrapped into an array 12 | /// (e.g.: `JSONDecoder().decode([SomeType].self, from: jsonData)`) 13 | /// - Parameter type: A `JSONType` value. Only `.array` or `.dict` values are allowed. 14 | func render(type: JSONType) -> Result { 15 | switch type { 16 | case .array(let arrayType): 17 | return render(type: arrayType) 18 | case .dict: 19 | return .success(render(type, indentLevel: 0).def) 20 | default: 21 | return .failure(.invalidRootType) 22 | } 23 | } 24 | 25 | /// Render a `JSONType` as a `RenderedType` 26 | /// - Parameters: 27 | /// - type: The `JSONType` 28 | /// - indentLevel: The indentation level 29 | /// - nameHint: Name hint of what a new type could be called (used for dictionaries) 30 | private func render(_ type: JSONType, indentLevel i: Int, nameHint: String? = nil) -> RenderedType { 31 | switch type { 32 | case .unknown: 33 | return (name: "Unknown", def: "") 34 | case .bool: 35 | return (name: "Bool", def: "") 36 | case .int: 37 | return (name: "Int", def: "") 38 | case .double: 39 | return (name: "Double", def: "") 40 | case .string: 41 | return (name: "String", def: "") 42 | case .array(let arrayType): 43 | var r = render(arrayType, indentLevel: i, nameHint: nameHint) 44 | r.name = "[" + r.name + "]" 45 | return r 46 | case .optional(let optionalType): 47 | var r = render(optionalType, indentLevel: i, nameHint: nameHint) 48 | r.name += "?" 49 | return r 50 | case .dict(let dictContent): 51 | let structName = nameHint?.toTypeName() ?? "NewType" 52 | var def = indent(i) + "struct \(structName): Codable {\n" 53 | 54 | dictContent 55 | .sorted(by: { $0.key < $1.key }) 56 | .forEach { (key, value) in 57 | let r = render(value, indentLevel: i + 1, nameHint: key) 58 | def += indent(i + 1) + "let \(key): \(r.name)\n" 59 | def += r.def 60 | } 61 | 62 | def += indent(i) + "}\n" 63 | 64 | return (name: structName, def: def) 65 | } 66 | } 67 | 68 | private func indent(_ level: Int) -> String { 69 | return String(repeating: " ", count: level * 4) 70 | } 71 | 72 | extension String { 73 | func toTypeName() -> String { 74 | return singularize().capitalizedFirst() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/json2codable/Merge.swift: -------------------------------------------------------------------------------- 1 | 2 | 3 | /// Merge two types if possible 4 | /// 5 | /// During the parsing phase, it is often not clear what the type of a value exactly is. For example, parsing this array: 6 | /// ``` 7 | /// [null, 123] 8 | /// ``` 9 | /// Looking at the first value (`null`) it is not clear what the type of the array should be and it is stored as a `.optional(.unknown)` type. 10 | /// Only after the parser sees the second value(`123`), it can attempt to merge the two types (`.optional(.unknown)` and `int`). The final 11 | /// value for the array content is `.optional(.int)`. 12 | /// 13 | /// - Parameters: 14 | /// - type1: A `JSONType` 15 | /// - type2: Another `JSONType` 16 | func merge(_ type1: JSONType, _ type2: JSONType) -> Result { 17 | guard type1 != type2 else { return .success(type1) } 18 | 19 | switch (type1, type2) { 20 | // Int + Bool = Int 21 | case (.int, .bool), 22 | (.bool, .int): 23 | return .success(.int) 24 | // Double + Bool = Double 25 | case (.double, .bool), 26 | (.bool, .double): 27 | return .success(.double) 28 | // Double + Int = Double 29 | case (.double, .int), 30 | (.int, .double): 31 | return .success(.double) 32 | // unknown + T = T 33 | case (.unknown, let other), 34 | (let other, .unknown): 35 | return .success(other) 36 | // optional(T) + optional(S) = optional(merge(T,S)) 37 | case (.optional(let optionalType1), .optional(let optionalType2)): 38 | return merge(optionalType1, optionalType2).map(JSONType.optional) 39 | // optional(T) + S = optional(merge(T,S)) 40 | case (.optional(let optionalType), let type), 41 | (let type, .optional(let optionalType)): 42 | return merge(type, optionalType).map(JSONType.optional) 43 | // dict1 + dict2 = mergeDicts(dict1, dict2) 44 | case (.dict(let content1), .dict(let content2)): 45 | return mergeDicts(content1, content2) 46 | // array1(T) + array2(S) = array(merge(T, S)) 47 | case (.array(let content1), .array(let content2)): 48 | return merge(content1, content2).map(JSONType.array) 49 | default: 50 | return .failure(.unableToMergeTypes("Unable to merge type \(type1) with \(type2)")) 51 | } 52 | } 53 | 54 | /// Merge dictionaries 55 | /// 56 | /// The keys and their types of two dictionaries can be merged together into a single dictionary. This is neccessary if the content type of an array 57 | /// are dictionaries and we need to find a common dictionary type that can store any of the dictionaries in the array. For example: 58 | /// ``` 59 | /// [ 60 | /// { 61 | /// "commonKey1": true, 62 | /// "commonKey2": 123, 63 | /// "uniqueKey": "someValue", 64 | /// }, 65 | /// { 66 | /// "commonKey1": false, 67 | /// "commonKey2": null, 68 | /// } 69 | /// ] 70 | /// ``` 71 | /// Here, we have two dictionaries with some overlap. The types of overlapping keys are merged using the `merge` function. Types for unique keys that only 72 | /// exist in some of the dictionaries are automatically changed to `.optional`. 73 | /// - Parameters: 74 | /// - d1: Contents of a `JSONType.dict` 75 | /// - d2: Contents of another `JSONType.dict` 76 | private func mergeDicts(_ d1: [String: JSONType], _ d2: [String: JSONType]) -> Result { 77 | let d1Keys = Set(d1.keys) 78 | let d2Keys = Set(d2.keys) 79 | var resultDict: [String: JSONType] = [:] 80 | 81 | // Common keys that exist in both dictionaries must have same type or be mergeable 82 | let commonKeys = d1Keys.intersection(d2Keys) 83 | for key in commonKeys { 84 | guard let d1Type = d1[key], let d2Type = d2[key] else { 85 | return .failure(.internalInconsistencyError) 86 | } 87 | 88 | let result = merge(d1Type, d2Type) 89 | guard case .success(let mergedType) = result else { 90 | return result 91 | } 92 | 93 | resultDict[key] = mergedType 94 | } 95 | 96 | // Types for unique keys that exist in only one dictionary are changed to `.optional(T)` 97 | let uniqueKeys = d1Keys.union(d2Keys).subtracting(commonKeys) 98 | for key in uniqueKeys { 99 | guard let type = d1[key] ?? d2[key] else { 100 | return .failure(.internalInconsistencyError) 101 | } 102 | 103 | let result = merge(.optional(.unknown), type) 104 | guard case .success(let mergedType) = result else { 105 | return result 106 | } 107 | resultDict[key] = mergedType 108 | } 109 | 110 | return .success(.dict(resultDict)) 111 | } 112 | -------------------------------------------------------------------------------- /Tests/json2codableTests/json2codableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import class Foundation.Bundle 3 | 4 | final class json2codableTests: XCTestCase { 5 | 6 | static var allTests = [ 7 | ("testScalars", testScalars), 8 | ("testArrayInt", testArrayInt), 9 | ("testNestedDictionary", testNestedDictionary), 10 | ("testDictionaryInArrayMatchingKeys", testDictionaryInArrayMatchingKeys), 11 | ("testDictionaryInArrayNotMatchingKeys", testDictionaryInArrayNotMatchingKeys), 12 | ("testDictionaryInArrayWithNullValues", testDictionaryInArrayWithNullValues), 13 | ("testBoolToIntMerge", testBoolToIntMerge), 14 | ("testBoolToFloatMerge", testBoolToFloatMerge), 15 | ("testIntToFloatMerge", testIntToFloatMerge), 16 | ("testOptionalMerge", testOptionalMerge), 17 | ("testSingularizeStructName", testSingularizeStructName), 18 | ] 19 | 20 | /// Returns path to the built products directory. 21 | var productsDirectory: URL { 22 | #if os(macOS) 23 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 24 | return bundle.bundleURL.deletingLastPathComponent() 25 | } 26 | fatalError("couldn't find the products directory") 27 | #else 28 | return Bundle.main.bundleURL 29 | #endif 30 | } 31 | 32 | // MARK: - Tests 33 | 34 | 35 | func testScalars() throws { 36 | let doc = """ 37 | { 38 | "bool": true, 39 | "int": 123, 40 | "float": 3.1415, 41 | "string": "Swift is fun" 42 | } 43 | """ 44 | let expected = """ 45 | struct NewType: Codable { 46 | let bool: Bool 47 | let float: Double 48 | let int: Int 49 | let string: String 50 | } 51 | """ 52 | try runTest(jsonDoc: doc, expected: expected) 53 | } 54 | 55 | func testArrayInt() throws { 56 | let doc = """ 57 | { 58 | "array": [123, 456] 59 | } 60 | """ 61 | let expected = """ 62 | struct NewType: Codable { 63 | let array: [Int] 64 | } 65 | """ 66 | try runTest(jsonDoc: doc, expected: expected) 67 | } 68 | 69 | func testNestedDictionary() throws { 70 | let doc = """ 71 | { 72 | "nestedDict": { 73 | "key": "value" 74 | } 75 | } 76 | """ 77 | let expected = """ 78 | struct NewType: Codable { 79 | let nestedDict: NestedDict 80 | struct NestedDict: Codable { 81 | let key: String 82 | } 83 | } 84 | """ 85 | try runTest(jsonDoc: doc, expected: expected) 86 | } 87 | 88 | func testDictionaryInArrayMatchingKeys() throws { 89 | let doc = """ 90 | { 91 | "dictArray": [ 92 | { 93 | "key": "value1" 94 | }, 95 | { 96 | "key": "value2" 97 | } 98 | ] 99 | } 100 | """ 101 | let expected = """ 102 | struct NewType: Codable { 103 | let dictArray: [DictArray] 104 | struct DictArray: Codable { 105 | let key: String 106 | } 107 | } 108 | """ 109 | try runTest(jsonDoc: doc, expected: expected) 110 | } 111 | 112 | func testDictionaryInArrayNotMatchingKeys() throws { 113 | let doc = """ 114 | { 115 | "dictArray": [ 116 | { 117 | "key": "value1", 118 | "common": true 119 | }, 120 | { 121 | "common": false, 122 | "anotherKey": 123 123 | } 124 | ] 125 | } 126 | """ 127 | let expected = """ 128 | struct NewType: Codable { 129 | let dictArray: [DictArray] 130 | struct DictArray: Codable { 131 | let anotherKey: Int? 132 | let common: Bool 133 | let key: String? 134 | } 135 | } 136 | """ 137 | try runTest(jsonDoc: doc, expected: expected) 138 | } 139 | 140 | func testDictionaryInArrayWithNullValues() throws { 141 | let doc = """ 142 | { 143 | "dictArray": [ 144 | { 145 | "key": "value1", 146 | "common": true 147 | }, 148 | { 149 | "common": false, 150 | "key": null 151 | } 152 | ] 153 | } 154 | """ 155 | let expected = """ 156 | struct NewType: Codable { 157 | let dictArray: [DictArray] 158 | struct DictArray: Codable { 159 | let common: Bool 160 | let key: String? 161 | } 162 | } 163 | """ 164 | try runTest(jsonDoc: doc, expected: expected) 165 | } 166 | 167 | func testBoolToIntMerge() throws { 168 | // `1` and `0` are parsed as `Bool` and can be promoted to `Int` if neccessary. 169 | let doc = """ 170 | { 171 | "numberAsBool": 1, 172 | "mixedArray": [ 173 | 1, 174 | 123 175 | ] 176 | } 177 | """ 178 | let expected = """ 179 | struct NewType: Codable { 180 | let mixedArray: [Int] 181 | let numberAsBool: Bool 182 | } 183 | """ 184 | try runTest(jsonDoc: doc, expected: expected) 185 | } 186 | 187 | func testBoolToFloatMerge() throws { 188 | // `1` and `0` are parsed as `Bool` and can be promoted to `Int` if neccessary. 189 | let doc = """ 190 | { 191 | "numberAsBool": 1, 192 | "mixedArray": [ 193 | 1, 194 | 123.123 195 | ] 196 | } 197 | """ 198 | let expected = """ 199 | struct NewType: Codable { 200 | let mixedArray: [Double] 201 | let numberAsBool: Bool 202 | } 203 | """ 204 | try runTest(jsonDoc: doc, expected: expected) 205 | } 206 | 207 | func testIntToFloatMerge() throws { 208 | // `1` and `0` are parsed as `Bool` and can be promoted to `Int` if neccessary. 209 | let doc = """ 210 | { 211 | "numberAsInt": 2, 212 | "mixedArray": [ 213 | 2, 214 | 123.123 215 | ] 216 | } 217 | """ 218 | let expected = """ 219 | struct NewType: Codable { 220 | let mixedArray: [Double] 221 | let numberAsInt: Int 222 | } 223 | """ 224 | try runTest(jsonDoc: doc, expected: expected) 225 | } 226 | 227 | func testOptionalMerge() throws { 228 | let doc = """ 229 | { 230 | "mixedArray": [ 231 | 123, 232 | null 233 | ] 234 | } 235 | """ 236 | let expected = """ 237 | struct NewType: Codable { 238 | let mixedArray: [Int?] 239 | } 240 | """ 241 | try runTest(jsonDoc: doc, expected: expected) 242 | } 243 | 244 | func testSingularizeStructName() throws { 245 | let doc = """ 246 | { 247 | "pluralNames": [ 248 | { 249 | "key": "value" 250 | } 251 | ], 252 | "singularName": { 253 | "key": "value" 254 | } 255 | } 256 | """ 257 | let expected = """ 258 | struct NewType: Codable { 259 | let pluralNames: [PluralName] 260 | struct PluralName: Codable { 261 | let key: String 262 | } 263 | let singularName: SingularName 264 | struct SingularName: Codable { 265 | let key: String 266 | } 267 | } 268 | """ 269 | try runTest(jsonDoc: doc, expected: expected) 270 | } 271 | 272 | private func runTest(jsonDoc: String, expected: String, file: StaticString = #file, line: UInt = #line) throws { 273 | 274 | // Some of the APIs that we use below are available in macOS 10.13 and above. 275 | guard #available(macOS 10.13, *) else { 276 | XCTFail("Requires macOS 10.13 or above") 277 | return 278 | } 279 | 280 | let binary = productsDirectory.appendingPathComponent("json2codable") 281 | 282 | let process = Process() 283 | process.executableURL = binary 284 | 285 | let stdoutPipe = Pipe() 286 | let stdinPipe = Pipe() 287 | process.standardOutput = stdoutPipe 288 | process.standardInput = stdinPipe 289 | 290 | try process.run() 291 | guard let jsonData = jsonDoc.data(using: .utf8) else { 292 | XCTFail("Unable to convery json document to Data") 293 | return 294 | } 295 | 296 | stdinPipe.fileHandleForWriting.write(jsonData) 297 | stdinPipe.fileHandleForWriting.closeFile() 298 | process.waitUntilExit() 299 | 300 | let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() 301 | let output = String(data: outputData, encoding: .utf8) 302 | 303 | XCTAssertEqual( 304 | output?.trimmingCharacters(in: .whitespacesAndNewlines), 305 | expected.trimmingCharacters(in: .whitespacesAndNewlines), 306 | file: file, 307 | line: line 308 | ) 309 | } 310 | 311 | } 312 | --------------------------------------------------------------------------------