Skip to content

Commit 64299df

Browse files
committed
Initial commit.
1 parent ed7d60a commit 64299df

16 files changed

+954
-92
lines changed

.gitignore

Lines changed: 5 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,7 @@
1-
# Xcode
2-
#
3-
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4-
5-
## User settings
1+
.DS_Store
2+
/.build
3+
/Packages
4+
/*.xcodeproj
65
xcuserdata/
7-
8-
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9-
*.xcscmblueprint
10-
*.xccheckout
11-
12-
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13-
build/
146
DerivedData/
15-
*.moved-aside
16-
*.pbxuser
17-
!default.pbxuser
18-
*.mode1v3
19-
!default.mode1v3
20-
*.mode2v3
21-
!default.mode2v3
22-
*.perspectivev3
23-
!default.perspectivev3
24-
25-
## Obj-C/Swift specific
26-
*.hmap
27-
28-
## App packaging
29-
*.ipa
30-
*.dSYM.zip
31-
*.dSYM
32-
33-
## Playgrounds
34-
timeline.xctimeline
35-
playground.xcworkspace
36-
37-
# Swift Package Manager
38-
#
39-
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40-
# Packages/
41-
# Package.pins
42-
# Package.resolved
43-
# *.xcodeproj
44-
#
45-
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46-
# hence it is not needed unless you have added a package configuration file to your project
47-
# .swiftpm
48-
49-
.build/
50-
51-
# CocoaPods
52-
#
53-
# We recommend against adding the Pods directory to your .gitignore. However
54-
# you should judge for yourself, the pros and cons are mentioned at:
55-
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56-
#
57-
# Pods/
58-
#
59-
# Add this line if you want to avoid checking in source code from the Xcode workspace
60-
# *.xcworkspace
61-
62-
# Carthage
63-
#
64-
# Add this line if you want to avoid checking in source code from Carthage dependencies.
65-
# Carthage/Checkouts
66-
67-
Carthage/Build/
68-
69-
# Accio dependency management
70-
Dependencies/
71-
.accio/
72-
73-
# fastlane
74-
#
75-
# It is recommended to not store the screenshots in the git repo.
76-
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
77-
# For more information about the recommended setup visit:
78-
# https://docs.fastlane.tools/best-practices/source-control/#source-control
79-
80-
fastlane/report.xml
81-
fastlane/Preview.html
82-
fastlane/screenshots/**/*.png
83-
fastlane/test_output
84-
85-
# Code Injection
86-
#
87-
# After new code Injection tools there's a generated folder /iOSInjectionProject
88-
# https://github.com/johnno1962/injectionforxcode
89-
90-
iOSInjectionProject/
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
BSD 2-Clause License
22

3-
Copyright (c) 2020, Roger Oba
3+
Copyright (c) 2021, Roger Oba
44
All rights reserved.
55

66
Redistribution and use in source and binary forms, with or without

Package.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// swift-tools-version:5.4
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: "JSEN",
8+
products: [
9+
.library(name: "JSEN", targets: ["JSEN"]),
10+
],
11+
targets: [
12+
.target(
13+
name: "JSEN",
14+
dependencies: [],
15+
path: "Sources"
16+
),
17+
.testTarget(
18+
name: "JSENTests",
19+
dependencies: ["JSEN"],
20+
path: "Tests"
21+
),
22+
]
23+
)

README.md

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,162 @@
1-
# Open Source Template
1+
# JSEN
22

3+
> _/ˈdʒeɪsən/ JAY-sən_
34
4-
## License
5+
JSEN (JSON Swift Enum Notation) is a lightweight enum representation of a JSON, written in Swift.
56

6-
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.
7+
A JSON, as defined in the [ECMA-404 standard](https://www.json.org) , can be:
8+
9+
- A number
10+
- A boolean
11+
- A string
12+
- Null
13+
- An array of those things
14+
- A dictionary of those things
15+
16+
Thus, JSONs can be represented as a recursive enum (or `indirect enum`, in Swift), effectively creating a statically-typed JSON payload in Swift.
17+
18+
# Installation
19+
20+
Using Swift Package Manager:
21+
22+
```swift
23+
dependencies: [
24+
.package(name: "JSEN", url: "https://github.com/rogerluan/JSEN", .upToNextMajor(from: "1.0.0")),
25+
]
26+
```
27+
28+
# Usage
29+
30+
I think it's essential for the understanding of how simple this is, for you to visualize the JSEN declaration:
31+
32+
```swift
33+
/// A simple JSON value representation using enum cases.
34+
public enum JSEN : Equatable {
35+
/// An integer value.
36+
case int(Int)
37+
/// A floating point value.
38+
case double(Double)
39+
/// A string value.
40+
case string(String)
41+
/// A boolean value.
42+
case bool(Bool)
43+
/// An array value in which all elements are also JSEN values.
44+
indirect case array([JSEN])
45+
/// An object value, also known as dictionary, hash, and map.
46+
/// All values of this object are also JSEN values.
47+
indirect case dictionary([String:JSEN])
48+
/// A null value.
49+
case null
50+
}
51+
```
52+
53+
That's it.
54+
55+
###### `ExpressibleBy…Literal`
56+
57+
Now that you're familiar with JSEN, it provides a few syntactic sugary utilities, such as conformance to most `ExpressibleBy…Literal` protocols:
58+
59+
- `ExpressibleByIntegerLiteral` initializer returns an `.int(…)`.
60+
- `ExpressibleByFloatLiteral` initializer returns a `.double(…)`.
61+
- `ExpressibleByStringLiteral` initializer returns a `.string(…)`.
62+
- `ExpressibleByBooleanLiteral` initializer returns a `.bool(…)`.
63+
- `ExpressibleByArrayLiteral` initializer returns an `.array(…)` as long as its Elements are JSENs.
64+
- `ExpressibleByDictionaryLiteral` initializer returns an `.dictionary(…)` as long as its keys are Strings and Values JSENs.
65+
- `ExpressibleByNilLiteral` initializer returns a `.null`.
66+
67+
Conformance to `ExpressibleBy…Literal` protocols are great when you want to build a JSON structure like this:
68+
69+
```swift
70+
let request: [String:JSEN] = [
71+
"key": "value",
72+
"another_key": 42,
73+
]
74+
```
75+
76+
But what if you're not working with literals?
77+
78+
```swift
79+
let request: [String:JSEN] = [
80+
"amount": normalizedAmount // This won't compile
81+
]
82+
```
83+
84+
Enters the…
85+
86+
### `%` Suffix Operator
87+
88+
```swift
89+
let request: [String:JSEN] = [
90+
"amount": %normalizedAmount // This works!
91+
]
92+
```
93+
94+
The custom `%` suffix operator transforms any `Int`, `Double`, `String`, `Bool`, `[JSEN]` and `[String:JSEN]` values into its respective JSEN value.
95+
96+
97+
By design, no support was added to transform `Optional` into a `.null` to prevent misuse.
98+
99+
<details><summary>Click here to expand the reason why it could lead to mistakes</summary>
100+
<p>
101+
102+
To illustrate the possible problems around an `%optionalValue` operation, picture the following scenario:
103+
104+
```swift
105+
let request: [String:JSEN] = [
106+
"middle_name": %optionalString
107+
]
108+
109+
network.put(request)
110+
```
111+
112+
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).
113+
114+
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.
115+
116+
</p>
117+
</details>
118+
119+
### Conformance to Codable
120+
121+
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.
122+
123+
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:
124+
- First, it encodes the JSEN to `Data`, and attempts to decode that `Data` into the given type.
125+
- 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.
126+
127+
### Subscript Using KeyPath
128+
129+
Last, but not least, comes the `KeyPath` subscript.
130+
131+
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.
132+
133+
The subscript to JSEN allows the following syntax:
134+
135+
```swift
136+
let request: [String:JSEN] = [
137+
"1st": [
138+
"2nd": [
139+
"3rd": "Hello!"
140+
]
141+
]
142+
]
143+
print(request[keyPath: "1st.2nd.3rd"]) // "Hello!"
144+
```
145+
146+
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 :)
147+
148+
# Contributions
149+
150+
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! 😇
151+
152+
# References
153+
154+
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.
155+
156+
# License
157+
158+
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.
159+
160+
# Contact
161+
162+
Twitter: [@rogerluan_](https://twitter.com/rogerluan_)

Sources/JSEN+Codable.swift

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright © 2021 Roger Oba. All rights reserved.
2+
3+
import Foundation
4+
5+
extension JSEN : Encodable {
6+
public func encode(to encoder: Encoder) throws {
7+
var container = encoder.singleValueContainer()
8+
switch self {
9+
case .int(let int): try container.encode(int)
10+
case .double(let double): try container.encode(double)
11+
case .string(let string): try container.encode(string)
12+
case .bool(let bool): try container.encode(bool)
13+
case .array(let array): try container.encode(array)
14+
case .dictionary(let dictionary): try container.encode(dictionary)
15+
case .null: try container.encodeNil()
16+
}
17+
}
18+
}
19+
20+
extension JSEN : Decodable {
21+
public init(from decoder: Decoder) throws {
22+
let container = try decoder.singleValueContainer()
23+
if let value = try? container.decode(Int.self) {
24+
self = .int(value)
25+
} else if let value = try? container.decode(Double.self) {
26+
self = .double(value)
27+
} else if let value = try? container.decode(String.self) {
28+
self = .string(value)
29+
} else if let value = try? container.decode(Bool.self) {
30+
self = .bool(value)
31+
} else if let value = try? container.decode([JSEN].self) {
32+
self = .array(value)
33+
} else if let value = try? container.decode([String:JSEN].self) {
34+
self = .dictionary(value)
35+
} else if container.decodeNil() {
36+
self = .null
37+
} else {
38+
throw NSError(domain: "domain.codable.jsen", code: 1, userInfo: [ "message" : "Failed to decode JSEN into any known type." ])
39+
}
40+
}
41+
42+
/// Decodes **self** into the given type, if possible.
43+
///
44+
/// This method will attempt to decode to the given type by first encoding **self** to Data, and then attempting to decode that Data.
45+
/// 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
46+
/// decode into the given type using the resulting Data.
47+
///
48+
/// - Parameters:
49+
/// - type: the Decodable type to decode **self** into.
50+
/// - dumpingErrorOnFailure: whether the function should dump the error on the console, upon failure. Set true for debugging purposes. Defaults to false.
51+
/// - Returns: An instance of the given type, or nil if the decoding wasn't possible.
52+
public func decode<T : Decodable>(as type: T.Type, dumpingErrorOnFailure: Bool = false) -> T? {
53+
do {
54+
let data = try JSONEncoder().encode(self)
55+
return try JSONDecoder().decode(type, from: data)
56+
} catch {
57+
do {
58+
switch self {
59+
case .string(let string):
60+
guard let data = string.data(using: .utf8) else {
61+
// Should never happen
62+
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.")
63+
return nil
64+
}
65+
return try JSONDecoder().decode(type, from: data)
66+
default: throw error
67+
}
68+
} catch {
69+
if dumpingErrorOnFailure {
70+
dump(error)
71+
}
72+
return nil
73+
}
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)