diff --git a/.gitignore b/.gitignore index 330d167..bb460e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,90 +1,7 @@ -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## User settings +.DS_Store +/.build +/Packages +/*.xcodeproj xcuserdata/ - -## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) -build/ DerivedData/ -*.moved-aside -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 - -## Obj-C/Swift specific -*.hmap - -## App packaging -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace - -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - -.build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# Accio dependency management -Dependencies/ -.accio/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output - -# Code Injection -# -# After new code Injection tools there's a generated folder /iOSInjectionProject -# https://github.com/johnno1962/injectionforxcode - -iOSInjectionProject/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/LICENSE b/LICENSE index f355128..3019a2f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2020, Roger Oba +Copyright (c) 2021, Roger Oba All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..80b0054 --- /dev/null +++ b/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:5.4 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "JSEN", + products: [ + .library(name: "JSEN", targets: ["JSEN"]), + ], + targets: [ + .target( + name: "JSEN", + dependencies: [], + path: "Sources" + ), + .testTarget( + name: "JSENTests", + dependencies: ["JSEN"], + path: "Tests" + ), + ] +) diff --git a/README.md b/README.md index 3c357bb..8aedeb1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,162 @@ -# Open Source Template +# JSEN +> _/ˈdʒeɪsən/ JAY-sən_ -## License +JSEN (JSON Swift Enum Notation) is a lightweight enum representation of a JSON, written in Swift. -This project is open source and covered by a standard 2-clause BSD license. That means you have to mention *Roger Oba* as the original author of this code and reproduce the LICENSE text inside your app, repository, project or research paper. +A JSON, as defined in the [ECMA-404 standard](https://www.json.org) , can be: + +- A number +- A boolean +- A string +- Null +- An array of those things +- A dictionary of those things + +Thus, JSONs can be represented as a recursive enum (or `indirect enum`, in Swift), effectively creating a statically-typed JSON payload in Swift. + +# Installation + +Using Swift Package Manager: + +```swift +dependencies: [ + .package(name: "JSEN", url: "https://github.com/rogerluan/JSEN", .upToNextMajor(from: "1.0.0")), +] +``` + +# Usage + +I think it's essential for the understanding of how simple this is, for you to visualize the JSEN declaration: + +```swift +/// A simple JSON value representation using enum cases. +public enum JSEN : Equatable { + /// An integer value. + case int(Int) + /// A floating point value. + case double(Double) + /// A string value. + case string(String) + /// A boolean value. + case bool(Bool) + /// An array value in which all elements are also JSEN values. + indirect case array([JSEN]) + /// An object value, also known as dictionary, hash, and map. + /// All values of this object are also JSEN values. + indirect case dictionary([String:JSEN]) + /// A null value. + case null +} +``` + +That's it. + +###### `ExpressibleBy…Literal` + +Now that you're familiar with JSEN, it provides a few syntactic sugary utilities, such as conformance to most `ExpressibleBy…Literal` protocols: + +- `ExpressibleByIntegerLiteral` initializer returns an `.int(…)`. +- `ExpressibleByFloatLiteral` initializer returns a `.double(…)`. +- `ExpressibleByStringLiteral` initializer returns a `.string(…)`. +- `ExpressibleByBooleanLiteral` initializer returns a `.bool(…)`. +- `ExpressibleByArrayLiteral` initializer returns an `.array(…)` as long as its Elements are JSENs. +- `ExpressibleByDictionaryLiteral` initializer returns an `.dictionary(…)` as long as its keys are Strings and Values JSENs. +- `ExpressibleByNilLiteral` initializer returns a `.null`. + +Conformance to `ExpressibleBy…Literal` protocols are great when you want to build a JSON structure like this: + +```swift +let request: [String:JSEN] = [ + "key": "value", + "another_key": 42, +] +``` + +But what if you're not working with literals? + +```swift +let request: [String:JSEN] = [ + "amount": normalizedAmount // This won't compile +] +``` + +Enters the… + +### `%` Suffix Operator + +```swift +let request: [String:JSEN] = [ +"amount": %normalizedAmount // This works! +] +``` + +The custom `%` suffix operator transforms any `Int`, `Double`, `String`, `Bool`, `[JSEN]` and `[String:JSEN]` values into its respective JSEN value. + + +By design, no support was added to transform `Optional` into a `.null` to prevent misuse. + +
Click here to expand the reason why it could lead to mistakes +

+ +To illustrate the possible problems around an `%optionalValue` operation, picture the following scenario: + +```swift +let request: [String:JSEN] = [ +"middle_name": %optionalString +] + +network.put(request) +``` + +Now, if the `%` operator detected a nonnull String, great. But if it detected its underlying value to be `.none` (aka `nil`), it would convert the value to `.null`, which, when encoded, would be converted to `NSNull()` (more on this below in the Codable section). As you imagine, `NSNull()` and `nil` have very different behaviors when it comes to RESTful APIs - the former might delete the key information on the database, while the latter will simply be ignored by Swift Dictionary (as if the field wasn't even there). + +Hence, if you want to use an optional value, make the call explicit by using either `.null` if you know the value must be encoded into a `NSNull()` instance, or unwrap its value and wrap it around one of the non-null JSEN cases. + +

+
+ +### Conformance to Codable + +Of course! We couldn't miss this. JSEN has native support to `Encodable & Decodable` (aka `Codable`), so you can easily parse JSEN to/from JSON-like structures. + +One additional utility was added as well, which's the `decode(as:)` function. It receives a Decodable-conformant Type as parameter and will attempt to decode the JSEN value into the given type using a two-pass strategy: +- First, it encodes the JSEN to `Data`, and attempts to decode that `Data` into the given type. +- If that fails and the JSEN is a `.string(…)` case, it attempts to encode the JSEN's string using `.utf8`. If it is able to encode it, it attempts to decode the resulting `Data` into the given type. + +### Subscript Using KeyPath + +Last, but not least, comes the `KeyPath` subscript. + +Based on [@olebegemann](https://twitter.com/olebegemann)'s [article](https://oleb.net/blog/2017/01/dictionary-key-paths), `KeyPath` is a simple struct used to represent multiple segments of a string. It is initializable by a string literal such as `"this.is.a.keypath"` and, when initialized, the string gets separated by periods, which compounds the struct's segments. + +The subscript to JSEN allows the following syntax: + +```swift +let request: [String:JSEN] = [ + "1st": [ + "2nd": [ + "3rd": "Hello!" + ] + ] +] +print(request[keyPath: "1st.2nd.3rd"]) // "Hello!" +``` + +Without this syntax, to access a nested value in a dictionary you'd have to create multiple chains of awkward optionals and unwrap them in weird and verbosy ways. I'm not a fan of doing that :) + +# Contributions + +If you spot something wrong, missing, or if you'd like to propose improvements to this project, please open an Issue or a Pull Request with your ideas and I promise to get back to you within 24 hours! 😇 + +# References + +JSEN was heavily based on [Statically-typed JSON payload in Swift](https://jobandtalent.engineering/statically-typed-json-payload-in-swift-bd193a9e8cf2) and other various implementations of this same utility spread throughout Stack Overflow and Swift Forums. I brought everything I needed together in this project because I couldn't something similar as a Swift Package, that had everything I needed. + +# License + +This project is open source and covered by a standard 2-clause BSD license. That means you can use (publicly, commercially and privately), modify and distribute this project's content, as long as you mention *Roger Oba* as the original author of this code and reproduce the LICENSE text inside your app, repository, project or research paper. + +# Contact + +Twitter: [@rogerluan_](https://twitter.com/rogerluan_) diff --git a/Sources/JSEN+Codable.swift b/Sources/JSEN+Codable.swift new file mode 100644 index 0000000..033a12f --- /dev/null +++ b/Sources/JSEN+Codable.swift @@ -0,0 +1,76 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +import Foundation + +extension JSEN : Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .int(let int): try container.encode(int) + case .double(let double): try container.encode(double) + case .string(let string): try container.encode(string) + case .bool(let bool): try container.encode(bool) + case .array(let array): try container.encode(array) + case .dictionary(let dictionary): try container.encode(dictionary) + case .null: try container.encodeNil() + } + } +} + +extension JSEN : Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode([JSEN].self) { + self = .array(value) + } else if let value = try? container.decode([String:JSEN].self) { + self = .dictionary(value) + } else if container.decodeNil() { + self = .null + } else { + throw NSError(domain: "domain.codable.jsen", code: 1, userInfo: [ "message" : "Failed to decode JSEN into any known type." ]) + } + } + + /// Decodes **self** into the given type, if possible. + /// + /// This method will attempt to decode to the given type by first encoding **self** to Data, and then attempting to decode that Data. + /// If this step fails, it will attempt to encode **self** using utf8 if **self** is a `.string` case. If it succeeds, it will attempt to + /// decode into the given type using the resulting Data. + /// + /// - Parameters: + /// - type: the Decodable type to decode **self** into. + /// - dumpingErrorOnFailure: whether the function should dump the error on the console, upon failure. Set true for debugging purposes. Defaults to false. + /// - Returns: An instance of the given type, or nil if the decoding wasn't possible. + public func decode(as type: T.Type, dumpingErrorOnFailure: Bool = false) -> T? { + do { + let data = try JSONEncoder().encode(self) + return try JSONDecoder().decode(type, from: data) + } catch { + do { + switch self { + case .string(let string): + guard let data = string.data(using: .utf8) else { + // Should never happen + assertionFailure("Received a string that is utf8-encoded. This is a provider precondition, please investigate why this provider is sending strings encoded in something different than utf8.") + return nil + } + return try JSONDecoder().decode(type, from: data) + default: throw error + } + } catch { + if dumpingErrorOnFailure { + dump(error) + } + return nil + } + } + } +} diff --git a/Sources/JSEN+KeyPath.swift b/Sources/JSEN+KeyPath.swift new file mode 100644 index 0000000..b068d1f --- /dev/null +++ b/Sources/JSEN+KeyPath.swift @@ -0,0 +1,60 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +public extension JSEN { + subscript(_ key: String) -> JSEN? { + get { + switch self { + case .dictionary(let value): return value[key] + default: return nil + } + } + set { + switch self { + case .dictionary(var value): + value[key] = newValue + self = .dictionary(value) + default: break + } + } + } + + subscript(keyPath keyPath: KeyPath) -> JSEN? { + get { + switch keyPath.headAndTail() { + case nil: return nil // Key path is empty. + case let (head, remainingKeyPath)? where remainingKeyPath.isEmpty: + // Reached the end of the key path. + return self[head] + case let (head, remainingKeyPath)?: + // Key path has a tail we need to traverse. + let remaining = self[keyPath: KeyPath(head)] + if let jsen = remaining, case .dictionary(let nestedDictionary) = jsen { + // Nest level is a dictionary-like JSEN value. + // Recursively access dictionary's values with the remaining key path. + return nestedDictionary[keyPath: remainingKeyPath] as? JSEN + } else { + // Invalid key path, abort. + return nil + } + } + } + set { + switch keyPath.headAndTail() { + case nil: return // Key path is empty. + case let (head, remainingKeyPath)? where remainingKeyPath.isEmpty: + // Reached the end of the key path. + self[head] = newValue + case let (head, remainingKeyPath)?: + let value = self[keyPath: KeyPath(head)] + if let jsonValue = value, case .dictionary(var nestedDictionary) = jsonValue { + // Key path has a tail we need to traverse + nestedDictionary[keyPath: remainingKeyPath] = newValue + self[head] = JSEN.dictionary(nestedDictionary) + } else { + // Invalid keyPath + return + } + } + } + } +} diff --git a/Sources/JSEN+SyntacticSugar.swift b/Sources/JSEN+SyntacticSugar.swift new file mode 100644 index 0000000..f2980fa --- /dev/null +++ b/Sources/JSEN+SyntacticSugar.swift @@ -0,0 +1,45 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +extension JSEN : ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = value.asJSEN() + } +} +extension JSEN : ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = value.asJSEN() + } +} +extension JSEN : ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = value.asJSEN() + } +} +extension JSEN : ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = value.asJSEN() + } +} +extension JSEN : ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSEN...) { + self = elements.asJSEN() + } +} +extension JSEN : ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSEN)...) { + self = .dictionary(elements.reduce(into: [:]) { $0[$1.0] = $1.1 }) + } +} +extension JSEN : ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +prefix operator % +prefix func % (rhs: Int) -> JSEN { .int(rhs) } +prefix func % (rhs: Double) -> JSEN { .double(rhs) } +prefix func % (rhs: String) -> JSEN { .string(rhs) } +prefix func % (rhs: Bool) -> JSEN { .bool(rhs) } +prefix func % (rhs: [JSEN]) -> JSEN { .array(rhs) } +prefix func % (rhs: [String:JSEN]) -> JSEN { .dictionary(rhs) } diff --git a/Sources/JSEN.swift b/Sources/JSEN.swift new file mode 100644 index 0000000..6570449 --- /dev/null +++ b/Sources/JSEN.swift @@ -0,0 +1,53 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +/// A simple JSON value representation using enum cases. +public enum JSEN : Equatable { + /// An integer value. + case int(Int) + /// A floating point value. + case double(Double) + /// A string value. + case string(String) + /// A boolean value. + case bool(Bool) + /// An array value in which all elements are also JSEN values. + indirect case array([JSEN]) + /// An object value, also known as dictionary, hash, and map. + /// All values of this object are also JSEN values. + indirect case dictionary([String:JSEN]) + /// A null value. + case null + + /// The extracted value from **self**, or **nil** if **self** is a `.null` case. + public var valueType: Any? { + switch self { + case .int(let value): return value + case .double(let value): return value + case .string(let value): return value + case .bool(let value): return value + case .array(let array): return array.map { $0.valueType } + case .dictionary(let dictionary): return dictionary.mapValues { $0.valueType } + case .null: return nil + } + } +} + +extension JSEN : CustomStringConvertible { + public var description: String { + switch self { + case .int(let value): return value.description + case .double(let value): return value.description + case .string(let value): return "\"\(value)\"" + case .bool(let value): return value.description + case .array(let value): return value.description // TODO: Improve printing + case .dictionary(let value): return value.description // TODO: Improve printing + case .null: return "null" + } + } +} + +extension JSEN : CustomDebugStringConvertible { + public var debugDescription: String { + return description + } +} diff --git a/Sources/JSENRepresentable.swift b/Sources/JSENRepresentable.swift new file mode 100644 index 0000000..9940ae1 --- /dev/null +++ b/Sources/JSENRepresentable.swift @@ -0,0 +1,57 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +/// Defines a type that can be convered to a JSEN. +public protocol JSENRepresentable : Codable { + func asJSEN() -> JSEN +} + +extension Int : JSENRepresentable { + public func asJSEN() -> JSEN { + return .int(self) + } +} + +extension Double : JSENRepresentable { + public func asJSEN() -> JSEN { + return .double(self) + } +} + +extension String : JSENRepresentable { + public func asJSEN() -> JSEN { + return .string(self) + } +} + +extension Bool : JSENRepresentable { + public func asJSEN() -> JSEN { + return .bool(self) + } +} + +extension Array : JSENRepresentable where Element == JSEN { + public func asJSEN() -> JSEN { + return .array(self) + } +} + +extension Dictionary : JSENRepresentable where Key == String, Value == JSEN { + public func asJSEN() -> JSEN { + return .dictionary(self) + } +} + +extension Optional : JSENRepresentable where Wrapped : JSENRepresentable { + public func asJSEN() -> JSEN { + switch self { + case .none: return .null + case .some(let jsen): return jsen.asJSEN() + } + } +} + +extension RawRepresentable where RawValue : JSENRepresentable { + public func asJSEN() -> JSEN { + return rawValue.asJSEN() + } +} diff --git a/Sources/KeyPath.swift b/Sources/KeyPath.swift new file mode 100644 index 0000000..054efac --- /dev/null +++ b/Sources/KeyPath.swift @@ -0,0 +1,93 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +import Foundation + +/// Simple struct used to represent multiple segments of a string. +/// This is a utility used to recursively access values in nested dictionaries. +public struct KeyPath { + var segments: [String] + + var isEmpty: Bool { return segments.isEmpty } + var path: String { + return segments.joined(separator: ".") + } + + /// Strips off the first segment and returns a pair + /// consisting of the first segment and the remaining key path. + /// Returns nil if the key path has no segments. + func headAndTail() -> (head: String, tail: KeyPath)? { + guard !isEmpty else { return nil } + var tail = segments + let head = tail.removeFirst() + return (head, KeyPath(segments: tail)) + } +} + +/// Initializes a KeyPath with a string of the form "this.is.a.keypath" +public extension KeyPath { + init(_ string: String) { + let segments = string.components(separatedBy: ".") + if segments.count == 1 && segments.first!.isEmpty { + self.segments = [] + } else { + self.segments = segments + } + } +} + +extension KeyPath: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self.init(value) + } +} + +public extension Dictionary where Key == String { + subscript(keyPath keyPath: KeyPath) -> Any? { + get { + switch keyPath.headAndTail() { + case nil: return nil // Key path is empty. + case let (head, remainingKeyPath)? where remainingKeyPath.isEmpty: + // Reached the end of the key path. + return self[head] + case let (head, remainingKeyPath)?: + // Key path has a tail we need to traverse. + let remaining = self[head] + if let nestedDictionary = remaining as? [Key:Any] { + // Next nest level is a dictionary. + // Recursively access dictionary's values with the remaining key path. + return nestedDictionary[keyPath: remainingKeyPath] + } else if let jsonValue = remaining as? JSEN, case .dictionary(let nestedDictionary) = jsonValue { + // It's a dictionary-like JSONValue. + // Recursively access dictionary's values with the remaining key path. + return nestedDictionary[keyPath: remainingKeyPath] + } else { + // Next nest level isn't a dictionary nor a dictionary-like JSONValue + // Invalid key path, abort. + return nil + } + } + } + set { + switch keyPath.headAndTail() { + case nil: return // Key path is empty. + case let (head, remainingKeyPath)? where remainingKeyPath.isEmpty: + // Reached the end of the key path. + self[head] = newValue as? Value + case let (head, remainingKeyPath)?: + let value = self[head] + if var nestedDictionary = value as? [Key:Any] { + // Key path has a tail we need to traverse + nestedDictionary[keyPath: remainingKeyPath] = newValue + self[head] = nestedDictionary as? Value + } else if let jsonValue = value as? JSEN, case .dictionary(var nestedDictionary) = jsonValue { + // Key path has a tail we need to traverse + nestedDictionary[keyPath: remainingKeyPath] = newValue + self[head] = JSEN.dictionary(nestedDictionary) as? Value + } else { + // Invalid keyPath + return + } + } + } + } +} diff --git a/Tests/JSENCodableTests.swift b/Tests/JSENCodableTests.swift new file mode 100644 index 0000000..66f0cbc --- /dev/null +++ b/Tests/JSENCodableTests.swift @@ -0,0 +1,71 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +import XCTest +@testable import JSEN + +final class JSENCodableTests : XCTestCase { + struct Model : Codable { + var name: String? + var age: Int? + var height: Double? + var isAlive: Bool? + var skills: [String]? + var address: Address? + var isMineral: Bool? + + struct Address : Codable { + var country: String? + } + } + func test_codable_withDictionaryLiteral_shouldSucceed() throws { + let dictionary: [String:JSEN] = [ + "name" : "John Doe", + "age" : 21, + "height" : 1.73, + "isAlive" : true, + "skills" : ["Coding", "Reading", "Writing"], + "address" : .dictionary(["country" : "Greenland" ]), + "isMineral": .null, + ] + let jsen = JSEN.dictionary(dictionary) + let data = try JSONEncoder().encode(jsen) + let model = try JSONDecoder().decode(Model.self, from: data) + XCTAssertEqual(model.name, "John Doe") + XCTAssertEqual(model.age, 21) + XCTAssertEqual(model.height, 1.73) + XCTAssertEqual(model.isAlive, true) + XCTAssertEqual(model.skills, ["Coding", "Reading", "Writing"]) + XCTAssertEqual(model.address?.country, "Greenland") + XCTAssertNil(model.isMineral) + + let newJSEN = try JSONDecoder().decode(JSEN.self, from: data) + XCTAssertEqual(newJSEN, jsen) + } + + func test_codable_withEncodedString_shouldSucceed() throws { + let json = """ + { + "name": "John Doe", + "age": 21, + "height": 1.73, + "isAlive": true, + "skills": ["Coding", "Reading", "Writing"], + "address": { + "country": "Greenland" + }, + "isMineral": null + } + """ + let jsen = try JSONDecoder().decode(JSEN.self, from: json.data(using: .utf8)!) + let expectedJSEN = JSEN.dictionary([ + "name" : "John Doe", + "age" : 21, + "height" : 1.73, + "isAlive" : true, + "skills" : ["Coding", "Reading", "Writing"], + "address" : .dictionary(["country" : "Greenland" ]), + "isMineral": .null, + ]) + XCTAssertEqual(jsen, expectedJSEN) + } +} diff --git a/Tests/JSENKeyPathTests.swift b/Tests/JSENKeyPathTests.swift new file mode 100644 index 0000000..c12b2bd --- /dev/null +++ b/Tests/JSENKeyPathTests.swift @@ -0,0 +1,87 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +import XCTest +@testable import JSEN + +final class JSENKeyPathTests : XCTestCase { + func test_readingNestedDictionaryLiteral_shouldReadValue() { + let nestedDictinary = JSEN.dictionary([ + "1st" : [ + "2nd" : [ + "3rd" : "hi!" + ] + ] + ]) + XCTAssertEqual(nestedDictinary[keyPath: "1st.2nd.3rd"], "hi!") + } + + func test_readingNestedJSEN_shouldReadValue() { + let nestedJSEN = JSEN.dictionary([ "1st" : JSEN.dictionary([ "2nd" : "hi!" ])]) + XCTAssertEqual(nestedJSEN[keyPath: "1st.2nd"], "hi!") + } + + func test_readingMoreKeyPathsThanAvailable_shouldReturnNil() { + let dictionary = JSEN.dictionary([ "flat_dictionary" : "some value" ]) + XCTAssertNil(dictionary[keyPath: "flat_dictionary.2nd"]) + } + + func test_readingUsingEmptyKeyPath_shouldReturnNil() { + let dictionary = JSEN.dictionary([ "flat_dictionary" : "some value" ]) + XCTAssertNil(dictionary[keyPath: ""]) + } + + func test_writingToNestedDictionaryToExistingKey_shouldOverrideExistingValue() { + var nestedDictinary = JSEN.dictionary([ + "1st" : [ + "2nd" : [ + "3rd" : "hi!" + ] + ] + ]) + nestedDictinary[keyPath: "1st.2nd.3rd"] = "hello!" + XCTAssertEqual(nestedDictinary[keyPath: "1st.2nd.3rd"], "hello!") + } + + func test_writingToNestedJSENUsingInvalidKeyPath_shouldntDoAnything() { + let originalDictionary = JSEN.dictionary([ + "1st" : JSEN.dictionary([ + "2nd" : JSEN.dictionary([ + "3rd" : "hi!" + ]) + ]) + ]) + var nestedDictinary = originalDictionary + nestedDictinary[keyPath: "1st.2nd.3rd.4th"] = "does this work?" + XCTAssertEqual(nestedDictinary, originalDictionary) + } + + func test_writingToNestedJSEN_shouldOverrideExistingValue() { + var nestedJSEN = JSEN.dictionary([ + "1st" : JSEN.dictionary([ + "2nd" : "hi!" + ]) + ]) + nestedJSEN[keyPath: "1st.2nd"] = "hello!" + XCTAssertEqual(nestedJSEN[keyPath: "1st.2nd"], "hello!") + } + + func test_writingUsingEmptyKeyPath_shouldntDoAnything() { + let originalDictionary = JSEN.dictionary([ "flat_dictionary" : "some value" ]) + var nestedDictinary = originalDictionary + nestedDictinary[keyPath: ""] = "does this work?" + XCTAssertEqual(nestedDictinary, originalDictionary) + } + + func test_writingUsingSingleLevelKeyPath_shouldOverrideExistingValue() { + var nestedDictinary = JSEN.dictionary([ "flat_dictionary" : "some value" ]) + nestedDictinary[keyPath: "flat_dictionary"] = "another value" + XCTAssertEqual(nestedDictinary["flat_dictionary"], "another value") + } + + func test_writingUsingInvalidKeyPath_shouldntDoAnything() { + let originalDictionary = JSEN.dictionary([ "flat_dictionary" : "some value" ]) + var nestedDictinary = originalDictionary + nestedDictinary[keyPath: "."] = "does this work?" + XCTAssertEqual(nestedDictinary, originalDictionary) + } +} diff --git a/Tests/JSENRepresentableTests.swift b/Tests/JSENRepresentableTests.swift new file mode 100644 index 0000000..f81f5a4 --- /dev/null +++ b/Tests/JSENRepresentableTests.swift @@ -0,0 +1,20 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +import XCTest +@testable import JSEN + +final class JSENRepresentableTests : XCTestCase { + func test_asJSEN_shouldReturnTheRightAssociatedValue() { + enum Test : String { + case myFirstCase + } + XCTAssertEqual(42.asJSEN(), .int(42)) + XCTAssertEqual(3.14.asJSEN(), .double(3.14)) + XCTAssertEqual("testing".asJSEN(), .string("testing")) + XCTAssertEqual(true.asJSEN(), .bool(true)) + XCTAssertEqual(["testing"].asJSEN(), .array(["testing"])) + XCTAssertEqual(["testing" : "value"].asJSEN(), .dictionary(["testing" : "value"])) + XCTAssertEqual(Optional.none.asJSEN(), .null) + XCTAssertEqual(Test.myFirstCase.asJSEN(), .string("myFirstCase")) + } +} diff --git a/Tests/JSENSyntacticSugarTests.swift b/Tests/JSENSyntacticSugarTests.swift new file mode 100644 index 0000000..bb08494 --- /dev/null +++ b/Tests/JSENSyntacticSugarTests.swift @@ -0,0 +1,77 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +import XCTest +@testable import JSEN + +final class JSENSyntacticSugarTests : XCTestCase { + func test_expressibleByIntegerLiteral_shouldHaveTheRightAssociatedValue() { + let jsenLiteral: JSEN = 77 + XCTAssertEqual(jsenLiteral.valueType as? Int, 77) + } + + func test_expressibleByFloatLiteral_shouldHaveTheRightAssociatedValue() { + let jsenLiteral: JSEN = 24.46 + XCTAssertEqual(jsenLiteral.valueType as? Double, 24.46) + } + + func test_expressibleByStringLiteral_shouldHaveTheRightAssociatedValue() { + let jsenLiteral: JSEN = "string literal" + XCTAssertEqual(jsenLiteral.valueType as? String, "string literal") + } + + func test_expressibleByBooleanLiteral_shouldHaveTheRightAssociatedValue() { + let jsenLiteral: JSEN = true + XCTAssertEqual(jsenLiteral.valueType as? Bool, true) + } + + func test_expressibleByArrayLiteral_shouldHaveTheRightAssociatedValue() { + let jsenLiteral: JSEN = [ "this", "is", "an", "array" ] + XCTAssertEqual(jsenLiteral.valueType as? [String], [ "this", "is", "an", "array" ]) + } + + func test_expressibleByDictionaryLiteral_shouldHaveTheRightAssociatedValue() { + let jsenLiteral: JSEN = [ "this is it" : "some value" ] + XCTAssertEqual(jsenLiteral.valueType as? [String:String], [ "this is it" : "some value" ]) + } + + func test_expressibleByNilLiteral_shouldHaveTheRightAssociatedValue() { + let jsenLiteral: JSEN = nil + XCTAssertNil(jsenLiteral.valueType) + } + + func test_prefixOperator_withInt_shouldHaveTheRightAssociatedValue() { + let value: Int = 67 + let jsenValue: JSEN = %value + XCTAssertEqual(jsenValue.valueType as? Int, value) + } + + func test_prefixOperator_withDouble_shouldHaveTheRightAssociatedValue() { + let value: Double = 3.14 + let jsenValue: JSEN = %value + XCTAssertEqual(jsenValue.valueType as? Double, value) + } + + func test_prefixOperator_withString_shouldHaveTheRightAssociatedValue() { + let value: String = "testing value" + let jsenValue: JSEN = %value + XCTAssertEqual(jsenValue.valueType as? String, value) + } + + func test_prefixOperator_withBool_shouldHaveTheRightAssociatedValue() { + let value: Bool = true + let jsenValue: JSEN = %value + XCTAssertEqual(jsenValue.valueType as? Bool, value) + } + + func test_prefixOperator_withArray_shouldHaveTheRightAssociatedValue() { + let value: [JSEN] = [ "my element" ] + let jsenValue: JSEN = %value + XCTAssertEqual(jsenValue.valueType as? [String], [ "my element" ]) + } + + func test_prefixOperator_withDictionary_shouldHaveTheRightAssociatedValue() { + let value: [String:JSEN] = [ "key" : "value" ] + let jsenValue: JSEN = %value + XCTAssertEqual(jsenValue.valueType as? [String:String], [ "key" : "value" ]) + } +} diff --git a/Tests/JSENTests.swift b/Tests/JSENTests.swift new file mode 100644 index 0000000..1c0682f --- /dev/null +++ b/Tests/JSENTests.swift @@ -0,0 +1,50 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +import XCTest +@testable import JSEN + +final class JSENTests : XCTestCase { + func test_valueType_shouldMatchUnderlyingValue() { + XCTAssertEqual(JSEN.int(42).valueType as? Int, 42) + XCTAssertEqual(JSEN.double(3.14).valueType as? Double, 3.14) + XCTAssertEqual(JSEN.string("hello").valueType as? String, "hello") + XCTAssertEqual(JSEN.bool(false).valueType as? Bool, false) + XCTAssertEqual(JSEN.bool(true).valueType as? Bool, true) + XCTAssertEqual(JSEN.array(["some array", "2nd element"]).valueType as? [String], ["some array", "2nd element"]) + XCTAssertEqual(JSEN.dictionary(["my_key" : "my_value"]).valueType as? [String:String], ["my_key" : "my_value"]) + XCTAssertNil(JSEN.null.valueType) + } + + struct Model : Decodable { + var key: String + } + + func test_decodeAsType_withValidTypeFromDictionary_shouldDecode() { + let jsen = JSEN.dictionary([ "key" : "value"]) + let decoded = jsen.decode(as: Model.self) + XCTAssertEqual(decoded?.key, "value") + } + + func test_decodeAsType_withValidTypeFromEncodedString_shouldDecode() { + let jsen = JSEN.string(#"{"key":"value"}"#) + let decoded = jsen.decode(as: Model.self) + XCTAssertEqual(decoded?.key, "value") + } + + func test_decodeAsType_withInvalidType_shouldReturnNil() { + let jsen = JSEN.dictionary(["wrong_key": "value"]) + let decoded = jsen.decode(as: Model.self, dumpingErrorOnFailure: true) + XCTAssertNil(decoded) + } + + func test_JSENCustomStringConvertible() { + XCTAssertEqual(JSEN.int(42).description, "42") + XCTAssertEqual(JSEN.double(3.14).description, "3.14") + XCTAssertEqual(JSEN.string("hello").description, #""hello""#) + XCTAssertEqual(JSEN.bool(false).description, "false") + XCTAssertEqual(JSEN.bool(true).description, "true") + XCTAssertEqual(JSEN.array(["some array", "2nd element"]).description, #"["some array", "2nd element"]"#) + XCTAssertEqual(JSEN.dictionary(["my_key" : "my_value"]).description, #"["my_key": "my_value"]"#) + XCTAssertEqual(JSEN.null.description, "null") + } +} diff --git a/Tests/KeyPathTests.swift b/Tests/KeyPathTests.swift new file mode 100644 index 0000000..739a660 --- /dev/null +++ b/Tests/KeyPathTests.swift @@ -0,0 +1,77 @@ +// Copyright © 2021 Roger Oba. All rights reserved. + +import XCTest +@testable import JSEN + +final class KeyPathTests : XCTestCase { + func test_readingNestedDictionaryLiteral_shouldReadValue() { + let nestedDictinary = [ + "1st" : [ + "2nd" : [ + "3rd" : "hi!" + ] + ] + ] + XCTAssertEqual(nestedDictinary[keyPath: "1st.2nd.3rd"] as? String, "hi!") + } + + func test_readingNestedJSEN_shouldReadValue() { + let nestedJSEN = [ "1st" : JSEN.dictionary([ "2nd" : "hi!" ])] + XCTAssertEqual((nestedJSEN[keyPath: "1st.2nd"] as? JSEN), "hi!") + } + + func test_readingMoreKeyPathsThanAvailable_shouldReturnNil() { + let dictionary = [ "flat_dictionary" : "some value" ] + XCTAssertNil(dictionary[keyPath: "flat_dictionary.2nd"]) + } + + func test_readingUsingEmptyKeyPath_shouldReturnNil() { + let dictionary = [ "flat_dictionary" : "some value" ] + XCTAssertNil(dictionary[keyPath: ""]) + } + + func test_writingToNestedDictionaryToExistingKey_shouldOverrideExistingValue() { + var nestedDictinary = [ + "1st" : [ + "2nd" : [ + "3rd" : "hi!" + ] + ] + ] + nestedDictinary[keyPath: "1st.2nd.3rd"] = "hello!" + XCTAssertEqual(nestedDictinary[keyPath: "1st.2nd.3rd"] as? String, "hello!") + } + + func test_writingToNestedDictionaryUsingInvalidKeyPath_shouldntDoAnything() { + let originalDictionary = [ + "1st" : [ + "2nd" : [ + "3rd" : "hi!" + ] + ] + ] + var nestedDictinary = originalDictionary + nestedDictinary[keyPath: "1st.2nd.3rd.4th"] = "does this work?" + XCTAssertEqual(nestedDictinary, originalDictionary) + } + + func test_writingToNestedJSEN_shouldOverrideExistingValue() { + var nestedJSEN = [ "1st" : JSEN.dictionary([ "2nd" : "hi!" ])] + // There's one limitation: the value being assigned gotta have the same value as dictionary Value type. + nestedJSEN[keyPath: "1st.2nd"] = JSEN.string("hello!") + XCTAssertEqual((nestedJSEN[keyPath: "1st.2nd"] as? JSEN), "hello!") + } + + func test_writingUsingEmptyKeyPath_shouldntDoAnything() { + let originalDictionary = [ "flat_dictionary" : "some value" ] + var nestedDictinary = originalDictionary + nestedDictinary[keyPath: ""] = "does this work?" + XCTAssertEqual(nestedDictinary, originalDictionary) + } + + func test_path_shouldMatchKeyPathInitializerPath() { + let path = "1st.2nd.3rd" + let keyPath = KeyPath(path) + XCTAssertEqual(keyPath.path, path) + } +}