Skip to content

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
rogerluan committed Jun 12, 2021
1 parent ed7d60a commit 64299df
Show file tree
Hide file tree
Showing 16 changed files with 954 additions and 92 deletions.
93 changes: 5 additions & 88 deletions .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
2 changes: 1 addition & 1 deletion 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
Expand Down
23 changes: 23 additions & 0 deletions 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"
),
]
)
162 changes: 159 additions & 3 deletions 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.

<details><summary>Click here to expand the reason why it could lead to mistakes</summary>
<p>

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.

</p>
</details>

### 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_)
76 changes: 76 additions & 0 deletions 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<T : Decodable>(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
}
}
}
}

0 comments on commit 64299df

Please sign in to comment.