From 75ad40f07d4e0b938e3afb80811244d6b7acd4ba Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Tue, 21 Nov 2023 12:53:43 -0800 Subject: [PATCH] Initial commit. --- .github/workflows/swift.yml | 41 + .gitignore | 94 ++ .../contents.xcworkspacedata | 7 + LICENSE | 211 +++ Package.swift | 29 + README.md | 76 + RELEASING.md | 9 + Sources/JSONSafeEncoder/Extensions.swift | 90 ++ Sources/JSONSafeEncoder/JSONError.swift | 38 + Sources/JSONSafeEncoder/JSONSafeEncoder.swift | 1295 +++++++++++++++++ Sources/JSONSafeEncoder/JSONValue.swift | 104 ++ Sources/JSONSafeEncoder/Version.swift | 17 + .../JSONSafeEncoderTests.swift | 170 +++ release.sh | 135 ++ 14 files changed, 2316 insertions(+) create mode 100644 .github/workflows/swift.yml create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 RELEASING.md create mode 100644 Sources/JSONSafeEncoder/Extensions.swift create mode 100644 Sources/JSONSafeEncoder/JSONError.swift create mode 100644 Sources/JSONSafeEncoder/JSONSafeEncoder.swift create mode 100644 Sources/JSONSafeEncoder/JSONValue.swift create mode 100644 Sources/JSONSafeEncoder/Version.swift create mode 100644 Tests/JSONSafeEncoderTests/JSONSafeEncoderTests.swift create mode 100755 release.sh diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..7fa5963 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,41 @@ +name: Swift + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + cancel_previous: + runs-on: ubuntu-latest + steps: + - uses: styfle/cancel-workflow-action@0.9.1 + with: + workflow_id: ${{ github.event.workflow.id }} + + build_and_test_spm_mac: + needs: cancel_previous + runs-on: macos-latest + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - uses: actions/checkout@v2 + - name: Build + run: swift build + - name: Run tests + run: swift test + + build_and_test_spm_linux: + needs: cancel_previous + runs-on: ubuntu-latest + steps: + - uses: swift-actions/setup-swift@v1 + with: + swift-version: "5.7" + - uses: actions/checkout@v2 + - name: Build + run: swift build + - name: Run tests + run: swift test --enable-test-discovery diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d51014 --- /dev/null +++ b/.gitignore @@ -0,0 +1,94 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +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/ +.DS_Store +Package.resolved +*.xcuserdatad +/.swiftpm/xcode/xcshareddata diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7b0a454 --- /dev/null +++ b/LICENSE @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +### Runtime Library Exception to the Apache 2.0 License: ### + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..0a4ea9a --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "JSONSafeEncoder", + platforms: [ + .macOS("10.15"), + .iOS("13.0"), + .tvOS("11.0"), + .watchOS("7.1") + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "JSONSafeEncoder", + targets: ["JSONSafeEncoder"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "JSONSafeEncoder"), + .testTarget( + name: "JSONSafeEncoderTests", + dependencies: ["JSONSafeEncoder"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0838f5 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# JSONSafeEncoder for Swift + +This library is a direct copy of JSONEncoder and it's associated types. It expands upon JSONEncoder's `nonConformingFloatEncodingStrategy` to give developers more options in how to handle NaN / Infinity / -Infinity values. + +## Why? + +When using Codable, it's an onerous task to make sure all floating point values do not contain NaN or Infinity solely for the purposes of JSON encoding. The options of what to do came down to either a custom number type (too heavy of an approach), or modifying the encoding process. + +Since _some_ options existed already, it seemed like the most prudent and less invasive choice. + +## What are the options? + +The following values now exist for the `nonConformingFloatEncodingStrategy` options: + +### `.zero` + +Outputs a `0` as the value when NaN/Infinity are encountered. This is the default. It's the safest option to use in conjunction with JSONDecoder, as the value to be assigned in the struct does not need to change in any way. + +### `.null` + +Outputs `null` when NaN/Infinity are encountered. This mimics how Javascript handles these values. + +### `.convertToString(...)` + +Convert NaN/Infinity to strings. This was present already, however without defaults or guidance. + +### `.throw` + +This is the original behavior and will throw an error when NaN/Infinity is encountered. + +### `.convertToStringDefaults` + +This operates similarly to `.convertToString(...)`, however it provides defaults to match languages like Python, etc. The defaults are `NaN`, `Infinity` and `-Infinity`. + +## Example + +```swift +struct TestStruct: Codable { + let myString: String + let myDouble: Double +} + +let test = TestStruct(myString: "this is a test", myDouble: Double.nan) + +let encoder = JSONSafeEncoder() +encoder.nonConformingFloatEncodingStrategy = .zero +encoder.outputFormatting = .prettyPrinted + +let json = try encoder.encode(test) +XCTAssertNotNil(json) + +let prettyString = String(data: json, encoding: .utf8)! +print(prettyString) + + +let decoder = JSONDecoder() +let newTest = try decoder.decode(TestStruct.self, from: json) + +XCTAssertEqual(newTest.myString, "this is a test") +XCTAssertEqual(newTest.myDouble, 0) +``` + +## License +``` +Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + +TWILIO NOTICE: Filenames and functionality have been modified from their original counterparts @ +https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONEncoder.swift + +Copyright (c) 2023 Twilio Inc. +``` + diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..5fb0b19 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,9 @@ +Releasing +========= + +Use `release.sh` to perform releases. This script will perform all the safety checks as well +as update Version.swfit, commit the change, and create tag + release. History since the last +released version will be used as the changelog for the release. + +ex: $ ./release.sh 1.1.1 + \ No newline at end of file diff --git a/Sources/JSONSafeEncoder/Extensions.swift b/Sources/JSONSafeEncoder/Extensions.swift new file mode 100644 index 0000000..76654d1 --- /dev/null +++ b/Sources/JSONSafeEncoder/Extensions.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/** -------------------------------------------------------------------------------------------- +TWILIO NOTICE: Filenames and functionality have been modified from their original counterparts @ +https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONEncoder.swift + +Copyright (c) 2023 Twilio Inc. +---------------------------------------------------------------------------------------------**/ + +import Foundation + +extension UInt8 { + internal static let _space = UInt8(ascii: " ") + internal static let _return = UInt8(ascii: "\r") + internal static let _newline = UInt8(ascii: "\n") + internal static let _tab = UInt8(ascii: "\t") + + internal static let _colon = UInt8(ascii: ":") + internal static let _comma = UInt8(ascii: ",") + + internal static let _openbrace = UInt8(ascii: "{") + internal static let _closebrace = UInt8(ascii: "}") + + internal static let _openbracket = UInt8(ascii: "[") + internal static let _closebracket = UInt8(ascii: "]") + + internal static let _quote = UInt8(ascii: "\"") + internal static let _backslash = UInt8(ascii: "\\") +} + +extension Array where Element == UInt8 { + internal static let _true = [UInt8(ascii: "t"), UInt8(ascii: "r"), UInt8(ascii: "u"), UInt8(ascii: "e")] + internal static let _false = [UInt8(ascii: "f"), UInt8(ascii: "a"), UInt8(ascii: "l"), UInt8(ascii: "s"), UInt8(ascii: "e")] + internal static let _null = [UInt8(ascii: "n"), UInt8(ascii: "u"), UInt8(ascii: "l"), UInt8(ascii: "l")] +} + +extension NSNumber { + static func fromJSONNumber(_ string: String) -> NSNumber? { + let decIndex = string.firstIndex(of: ".") + let expIndex = string.firstIndex(of: "e") + let isInteger = decIndex == nil && expIndex == nil + let isNegative = string.utf8[string.utf8.startIndex] == UInt8(ascii: "-") + let digitCount = string[string.startIndex..<(expIndex ?? string.endIndex)].count + + // Try Int64() or UInt64() first + if isInteger { + if isNegative { + if digitCount <= 19, let intValue = Int64(string) { + return NSNumber(value: intValue) + } + } else { + if digitCount <= 20, let uintValue = UInt64(string) { + return NSNumber(value: uintValue) + } + } + } + + var exp = 0 + + if let expIndex = expIndex { + let expStartIndex = string.index(after: expIndex) + if let parsed = Int(string[expStartIndex...]) { + exp = parsed + } + } + + // Decimal holds more digits of precision but a smaller exponent than Double + // so try that if the exponent fits and there are more digits than Double can hold + if digitCount > 17, exp >= -128, exp <= 127, let decimal = Decimal(string: string), decimal.isFinite { + return NSDecimalNumber(decimal: decimal) + } + + // Fall back to Double() for everything else + if let doubleValue = Double(string), doubleValue.isFinite { + return NSNumber(value: doubleValue) + } + + return nil + } +} diff --git a/Sources/JSONSafeEncoder/JSONError.swift b/Sources/JSONSafeEncoder/JSONError.swift new file mode 100644 index 0000000..f6b190c --- /dev/null +++ b/Sources/JSONSafeEncoder/JSONError.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/** -------------------------------------------------------------------------------------------- +TWILIO NOTICE: Filenames and functionality have been modified from their original counterparts @ +https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONEncoder.swift + +Copyright (c) 2023 Twilio Inc. +---------------------------------------------------------------------------------------------**/ + + +import Foundation +import CoreFoundation + +enum JSONError: Swift.Error, Equatable { + case cannotConvertInputDataToUTF8 + case unexpectedCharacter(ascii: UInt8, characterIndex: Int) + case unexpectedEndOfFile + case tooManyNestedArraysOrDictionaries(characterIndex: Int) + case invalidHexDigitSequence(String, index: Int) + case unexpectedEscapedCharacter(ascii: UInt8, in: String, index: Int) + case unescapedControlCharacterInString(ascii: UInt8, in: String, index: Int) + case expectedLowSurrogateUTF8SequenceAfterHighSurrogate(in: String, index: Int) + case couldNotCreateUnicodeScalarFromUInt32(in: String, index: Int, unicodeScalarValue: UInt32) + case numberWithLeadingZero(index: Int) + case numberIsNotRepresentableInSwift(parsed: String) + case singleFragmentFoundButNotAllowed + case invalidUTF8Sequence(Data, characterIndex: Int) +} diff --git a/Sources/JSONSafeEncoder/JSONSafeEncoder.swift b/Sources/JSONSafeEncoder/JSONSafeEncoder.swift new file mode 100644 index 0000000..e328f57 --- /dev/null +++ b/Sources/JSONSafeEncoder/JSONSafeEncoder.swift @@ -0,0 +1,1295 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/** -------------------------------------------------------------------------------------------- +TWILIO NOTICE: Filenames and functionality have been modified from their original counterparts @ +https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONEncoder.swift + +Copyright (c) 2023 Twilio Inc. +---------------------------------------------------------------------------------------------**/ + +import Foundation +import CoreFoundation + +/// A marker protocol used to determine whether a value is a `String`-keyed `Dictionary` +/// containing `Encodable` values (in which case it should be exempt from key conversion strategies). +/// +fileprivate protocol _JSONStringDictionaryEncodableMarker { } + +extension Dictionary: _JSONStringDictionaryEncodableMarker where Key == String, Value: Encodable { } + +//===----------------------------------------------------------------------===// +// JSON Encoder +//===----------------------------------------------------------------------===// + +/// `JSONSafeEncoder` facilitates the encoding of `Encodable` values into JSON. +open class JSONSafeEncoder { + // MARK: Options + + /// The formatting of the output JSON data. + public struct OutputFormatting: OptionSet { + /// The format's default value. + public let rawValue: UInt + + /// Creates an OutputFormatting value with the given raw value. + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + /// Produce human-readable JSON with indented output. + public static let prettyPrinted = OutputFormatting(rawValue: 1 << 0) + + /// Produce JSON with dictionary keys sorted in lexicographic order. + @available(macOS 10.13, iOS 11.0, watchOS 4.0, tvOS 11.0, *) + public static let sortedKeys = OutputFormatting(rawValue: 1 << 1) + + /// By default slashes get escaped ("/" → "\/", "http://apple.com/" → "http:\/\/apple.com\/") + /// for security reasons, allowing outputted JSON to be safely embedded within HTML/XML. + /// In contexts where this escaping is unnecessary, the JSON is known to not be embedded, + /// or is intended only for display, this option avoids this escaping. + public static let withoutEscapingSlashes = OutputFormatting(rawValue: 1 << 3) + } + + /// The strategy to use for encoding `Date` values. + public enum DateEncodingStrategy { + /// Defer to `Date` for choosing an encoding. This is the default strategy. + case deferredToDate + + /// Encode the `Date` as a UNIX timestamp (as a JSON number). + case secondsSince1970 + + /// Encode the `Date` as UNIX millisecond timestamp (as a JSON number). + case millisecondsSince1970 + + /// Encode the `Date` as an ISO-8601-formatted string (in RFC 3339 format). + @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) + case iso8601 + + /// Encode the `Date` as a string formatted by the given formatter. + case formatted(DateFormatter) + + /// Encode the `Date` as a custom value encoded by the given closure. + /// + /// If the closure fails to encode a value into the given encoder, the encoder will encode an empty automatic container in its place. + case custom((Date, Encoder) throws -> Void) + } + + /// The strategy to use for encoding `Data` values. + public enum DataEncodingStrategy { + /// Defer to `Data` for choosing an encoding. + case deferredToData + + /// Encoded the `Data` as a Base64-encoded string. This is the default strategy. + case base64 + + /// Encode the `Data` as a custom value encoded by the given closure. + /// + /// If the closure fails to encode a value into the given encoder, the encoder will encode an empty automatic container in its place. + case custom((Data, Encoder) throws -> Void) + } + + /// TWILIO MODIFICATION - Add additional strategies + /// The strategy to use for non-JSON-conforming floating-point values (IEEE 754 infinity and NaN). + public enum NonConformingFloatEncodingStrategy { + /// Throw upon encountering non-conforming values. This is the default strategy. + case `throw` + + /// Set the value to null, like Javascript does. + case `null` + + /// Set the value to zero, the safest option for Swift decoding. + case zero + + /// Encode the values using the given representation strings. + case convertToString(positiveInfinity: String, negativeInfinity: String, nan: String) + + /// Default strings, similar to Python + static var convertToStringDefaults = NonConformingFloatEncodingStrategy.convertToString( + positiveInfinity: "Infinity", + negativeInfinity: "-Infinity", + nan: "NaN" + ) + } + /// TWILIO MODIFICATION - End + + /// The strategy to use for automatically changing the value of keys before encoding. + public enum KeyEncodingStrategy { + /// Use the keys specified by each type. This is the default strategy. + case useDefaultKeys + + /// Convert from "camelCaseKeys" to "snake_case_keys" before writing a key to JSON payload. + /// + /// Capital characters are determined by testing membership in `CharacterSet.uppercaseLetters` and `CharacterSet.lowercaseLetters` (Unicode General Categories Lu and Lt). + /// The conversion to lower case uses `Locale.system`, also known as the ICU "root" locale. This means the result is consistent regardless of the current user's locale and language preferences. + /// + /// Converting from camel case to snake case: + /// 1. Splits words at the boundary of lower-case to upper-case + /// 2. Inserts `_` between words + /// 3. Lowercases the entire string + /// 4. Preserves starting and ending `_`. + /// + /// For example, `oneTwoThree` becomes `one_two_three`. `_oneTwoThree_` becomes `_one_two_three_`. + /// + /// - Note: Using a key encoding strategy has a nominal performance cost, as each string key has to be converted. + case convertToSnakeCase + + /// Provide a custom conversion to the key in the encoded JSON from the keys specified by the encoded types. + /// The full path to the current encoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before encoding. + /// If the result of the conversion is a duplicate key, then only one value will be present in the result. + case custom((_ codingPath: [CodingKey]) -> CodingKey) + + fileprivate static func _convertToSnakeCase(_ stringKey: String) -> String { + guard !stringKey.isEmpty else { return stringKey } + + var words: [Range] = [] + // The general idea of this algorithm is to split words on transition from lower to upper case, then on transition of >1 upper case characters to lowercase + // + // myProperty -> my_property + // myURLProperty -> my_url_property + // + // We assume, per Swift naming conventions, that the first character of the key is lowercase. + var wordStart = stringKey.startIndex + var searchRange = stringKey.index(after: wordStart)..1 capital letters. Turn those into a word, stopping at the capital before the lower case character. + let beforeLowerIndex = stringKey.index(before: lowerCaseRange.lowerBound) + words.append(upperCaseRange.lowerBound..(_ value: T) throws -> Data { + let value: JSONValue = try encodeAsJSONValue(value) + let writer = JSONValue.Writer(options: self.outputFormatting) + let bytes = writer.writeValue(value) + + return Data(bytes) + } + + func encodeAsJSONValue(_ value: T) throws -> JSONValue { + let encoder = JSONSafeEncoderImpl(options: self.options, codingPath: []) + guard let topLevel = try encoder.wrapEncodable(value, for: nil) else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: [], debugDescription: "Top-level \(T.self) did not encode any values.")) + } + + return topLevel + } +} + +// MARK: - _JSONSafeEncoder + +private enum JSONFuture { + case value(JSONValue) + case encoder(JSONSafeEncoderImpl) + case nestedArray(RefArray) + case nestedObject(RefObject) + + class RefArray { + private(set) var array: [JSONFuture] = [] + + init() { + self.array.reserveCapacity(10) + } + + @inline(__always) func append(_ element: JSONValue) { + self.array.append(.value(element)) + } + + @inline(__always) func append(_ encoder: JSONSafeEncoderImpl) { + self.array.append(.encoder(encoder)) + } + + @inline(__always) func appendArray() -> RefArray { + let array = RefArray() + self.array.append(.nestedArray(array)) + return array + } + + @inline(__always) func appendObject() -> RefObject { + let object = RefObject() + self.array.append(.nestedObject(object)) + return object + } + + var values: [JSONValue] { + self.array.map { (future) -> JSONValue in + switch future { + case .value(let value): + return value + case .nestedArray(let array): + return .array(array.values) + case .nestedObject(let object): + return .object(object.values) + case .encoder(let encoder): + return encoder.value ?? .object([:]) + } + } + } + } + + class RefObject { + private(set) var dict: [String: JSONFuture] = [:] + + init() { + self.dict.reserveCapacity(20) + } + + @inline(__always) func set(_ value: JSONValue, for key: String) { + self.dict[key] = .value(value) + } + + @inline(__always) func setArray(for key: String) -> RefArray { + switch self.dict[key] { + case .encoder: + preconditionFailure("For key \"\(key)\" an encoder has already been created.") + case .nestedObject: + preconditionFailure("For key \"\(key)\" a keyed container has already been created.") + case .nestedArray(let array): + return array + case .none, .value: + let array = RefArray() + dict[key] = .nestedArray(array) + return array + } + } + + @inline(__always) func setObject(for key: String) -> RefObject { + switch self.dict[key] { + case .encoder: + preconditionFailure("For key \"\(key)\" an encoder has already been created.") + case .nestedObject(let object): + return object + case .nestedArray: + preconditionFailure("For key \"\(key)\" a unkeyed container has already been created.") + case .none, .value: + let object = RefObject() + dict[key] = .nestedObject(object) + return object + } + } + + @inline(__always) func set(_ encoder: JSONSafeEncoderImpl, for key: String) { + switch self.dict[key] { + case .encoder: + preconditionFailure("For key \"\(key)\" an encoder has already been created.") + case .nestedObject: + preconditionFailure("For key \"\(key)\" a keyed container has already been created.") + case .nestedArray: + preconditionFailure("For key \"\(key)\" a unkeyed container has already been created.") + case .none, .value: + dict[key] = .encoder(encoder) + } + } + + var values: [String: JSONValue] { + self.dict.mapValues { (future) -> JSONValue in + switch future { + case .value(let value): + return value + case .nestedArray(let array): + return .array(array.values) + case .nestedObject(let object): + return .object(object.values) + case .encoder(let encoder): + return encoder.value ?? .object([:]) + } + } + } + } +} + +private class JSONSafeEncoderImpl { + let options: JSONSafeEncoder._Options + let codingPath: [CodingKey] + var userInfo: [CodingUserInfoKey: Any] { + options.userInfo + } + + var singleValue: JSONValue? + var array: JSONFuture.RefArray? + var object: JSONFuture.RefObject? + + var value: JSONValue? { + if let object = self.object { + return .object(object.values) + } + if let array = self.array { + return .array(array.values) + } + return self.singleValue + } + + init(options: JSONSafeEncoder._Options, codingPath: [CodingKey]) { + self.options = options + self.codingPath = codingPath + } +} + +extension JSONSafeEncoderImpl: Encoder { + func container(keyedBy _: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { + if let _ = object { + let container = JSONKeyedEncodingContainer(impl: self, codingPath: codingPath) + return KeyedEncodingContainer(container) + } + + guard self.singleValue == nil, self.array == nil else { + preconditionFailure() + } + + self.object = JSONFuture.RefObject() + let container = JSONKeyedEncodingContainer(impl: self, codingPath: codingPath) + return KeyedEncodingContainer(container) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + if let _ = array { + return JSONUnkeyedEncodingContainer(impl: self, codingPath: self.codingPath) + } + + guard self.singleValue == nil, self.object == nil else { + preconditionFailure() + } + + self.array = JSONFuture.RefArray() + return JSONUnkeyedEncodingContainer(impl: self, codingPath: self.codingPath) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + guard self.object == nil, self.array == nil else { + preconditionFailure() + } + + return JSONSingleValueEncodingContainer(impl: self, codingPath: self.codingPath) + } +} + +// this is a private protocol to implement convenience methods directly on the EncodingContainers + +extension JSONSafeEncoderImpl: _SpecialTreatmentEncoder { + var impl: JSONSafeEncoderImpl { + return self + } + + // untyped escape hatch. needed for `wrapObject` + func wrapUntyped(_ encodable: Encodable) throws -> JSONValue { + switch encodable { + case let date as Date: + return try self.wrapDate(date, for: nil) + case let data as Data: + return try self.wrapData(data, for: nil) + case let url as URL: + return .string(url.absoluteString) + case let decimal as Decimal: + return .number(decimal.description) + case let object as [String: Encodable]: // this emits a warning, but it works perfectly + return try self.wrapObject(object, for: nil) + default: + try encodable.encode(to: self) + return self.value ?? .object([:]) + } + } +} + +private protocol _SpecialTreatmentEncoder { + var codingPath: [CodingKey] { get } + var options: JSONSafeEncoder._Options { get } + var impl: JSONSafeEncoderImpl { get } +} + +extension _SpecialTreatmentEncoder { + @inline(__always) fileprivate func wrapFloat(_ float: F, for additionalKey: CodingKey?) throws -> JSONValue { + guard !float.isNaN, !float.isInfinite else { + /// TWILIO MODIFICATION - Replacement to expand handling options. + switch self.options.nonConformingFloatEncodingStrategy { + case .throw: + break + case .null: + return .null + case .zero: + return .number("0") + case .convertToString(let posInfString, let negInfString, let nanString): + switch float { + case F.infinity: + return .string(posInfString) + case -F.infinity: + return .string(negInfString) + default: + // must be nan in this case + return .string(nanString) + } + } + /// TWILIO MODIFICATION - End + /** + Original + if case .convertToString(let posInfString, let negInfString, let nanString) = self.options.nonConformingFloatEncodingStrategy { + switch float { + case F.infinity: + return .string(posInfString) + case -F.infinity: + return .string(negInfString) + default: + // must be nan in this case + return .string(nanString) + } + } + */ + + var path = self.codingPath + if let additionalKey = additionalKey { + path.append(additionalKey) + } + + throw EncodingError.invalidValue(float, .init( + codingPath: path, + debugDescription: "Unable to encode \(F.self).\(float) directly in JSON." + )) + } + + var string = float.description + if string.hasSuffix(".0") { + string.removeLast(2) + } + return .number(string) + } + + fileprivate func wrapEncodable(_ encodable: E, for additionalKey: CodingKey?) throws -> JSONValue? { + switch encodable { + case let date as Date: + return try self.wrapDate(date, for: additionalKey) + case let data as Data: + return try self.wrapData(data, for: additionalKey) + case let url as URL: + return .string(url.absoluteString) + case let decimal as Decimal: + return .number(decimal.description) + case let object as _JSONStringDictionaryEncodableMarker: + return try self.wrapObject(object as! [String: Encodable], for: additionalKey) + default: + let encoder = self.getEncoder(for: additionalKey) + try encodable.encode(to: encoder) + return encoder.value + } + } + + func wrapDate(_ date: Date, for additionalKey: CodingKey?) throws -> JSONValue { + switch self.options.dateEncodingStrategy { + case .deferredToDate: + let encoder = self.getEncoder(for: additionalKey) + try date.encode(to: encoder) + return encoder.value ?? .null + + case .secondsSince1970: + return .number(date.timeIntervalSince1970.description) + + case .millisecondsSince1970: + return .number((date.timeIntervalSince1970 * 1000).description) + + case .iso8601: + if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) { + return .string(_iso8601Formatter.string(from: date)) + } else { + fatalError("ISO8601DateFormatter is unavailable on this platform.") + } + + case .formatted(let formatter): + return .string(formatter.string(from: date)) + + case .custom(let closure): + let encoder = self.getEncoder(for: additionalKey) + try closure(date, encoder) + // The closure didn't encode anything. Return the default keyed container. + return encoder.value ?? .object([:]) + } + } + + func wrapData(_ data: Data, for additionalKey: CodingKey?) throws -> JSONValue { + switch self.options.dataEncodingStrategy { + case .deferredToData: + let encoder = self.getEncoder(for: additionalKey) + try data.encode(to: encoder) + return encoder.value ?? .null + + case .base64: + let base64 = data.base64EncodedString() + return .string(base64) + + case .custom(let closure): + let encoder = self.getEncoder(for: additionalKey) + try closure(data, encoder) + // The closure didn't encode anything. Return the default keyed container. + return encoder.value ?? .object([:]) + } + } + + func wrapObject(_ object: [String: Encodable], for additionalKey: CodingKey?) throws -> JSONValue { + var baseCodingPath = self.codingPath + if let additionalKey = additionalKey { + baseCodingPath.append(additionalKey) + } + var result = [String: JSONValue]() + result.reserveCapacity(object.count) + + try object.forEach { (key, value) in + var elemCodingPath = baseCodingPath + elemCodingPath.append(_JSONKey(stringValue: key, intValue: nil)) + let encoder = JSONSafeEncoderImpl(options: self.options, codingPath: elemCodingPath) + + result[key] = try encoder.wrapUntyped(value) + } + + return .object(result) + } + + fileprivate func getEncoder(for additionalKey: CodingKey?) -> JSONSafeEncoderImpl { + if let additionalKey = additionalKey { + var newCodingPath = self.codingPath + newCodingPath.append(additionalKey) + return JSONSafeEncoderImpl(options: self.options, codingPath: newCodingPath) + } + + return self.impl + } +} + +private struct JSONKeyedEncodingContainer: KeyedEncodingContainerProtocol, _SpecialTreatmentEncoder { + typealias Key = K + + let impl: JSONSafeEncoderImpl + let object: JSONFuture.RefObject + let codingPath: [CodingKey] + + private var firstValueWritten: Bool = false + fileprivate var options: JSONSafeEncoder._Options { + return self.impl.options + } + + init(impl: JSONSafeEncoderImpl, codingPath: [CodingKey]) { + self.impl = impl + self.object = impl.object! + self.codingPath = codingPath + } + + // used for nested containers + init(impl: JSONSafeEncoderImpl, object: JSONFuture.RefObject, codingPath: [CodingKey]) { + self.impl = impl + self.object = object + self.codingPath = codingPath + } + + private func _converted(_ key: Key) -> CodingKey { + switch self.options.keyEncodingStrategy { + case .useDefaultKeys: + return key + case .convertToSnakeCase: + let newKeyString = JSONSafeEncoder.KeyEncodingStrategy._convertToSnakeCase(key.stringValue) + return _JSONKey(stringValue: newKeyString, intValue: key.intValue) + case .custom(let converter): + return converter(codingPath + [key]) + } + } + + mutating func encodeNil(forKey key: Self.Key) throws { + self.object.set(.null, for: self._converted(key).stringValue) + } + + mutating func encode(_ value: Bool, forKey key: Self.Key) throws { + self.object.set(.bool(value), for: self._converted(key).stringValue) + } + + mutating func encode(_ value: String, forKey key: Self.Key) throws { + self.object.set(.string(value), for: self._converted(key).stringValue) + } + + mutating func encode(_ value: Double, forKey key: Self.Key) throws { + try encodeFloatingPoint(value, key: self._converted(key)) + } + + mutating func encode(_ value: Float, forKey key: Self.Key) throws { + try encodeFloatingPoint(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int8, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int16, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int32, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: Int64, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt8, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt16, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt32, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: UInt64, forKey key: Self.Key) throws { + try encodeFixedWidthInteger(value, key: self._converted(key)) + } + + mutating func encode(_ value: T, forKey key: Self.Key) throws where T: Encodable { + let convertedKey = self._converted(key) + let encoded = try self.wrapEncodable(value, for: convertedKey) + self.object.set(encoded ?? .object([:]), for: convertedKey.stringValue) + } + + mutating func nestedContainer(keyedBy _: NestedKey.Type, forKey key: Self.Key) -> + KeyedEncodingContainer where NestedKey: CodingKey + { + let convertedKey = self._converted(key) + let newPath = self.codingPath + [convertedKey] + let object = self.object.setObject(for: convertedKey.stringValue) + let nestedContainer = JSONKeyedEncodingContainer(impl: impl, object: object, codingPath: newPath) + return KeyedEncodingContainer(nestedContainer) + } + + mutating func nestedUnkeyedContainer(forKey key: Self.Key) -> UnkeyedEncodingContainer { + let convertedKey = self._converted(key) + let newPath = self.codingPath + [convertedKey] + let array = self.object.setArray(for: convertedKey.stringValue) + let nestedContainer = JSONUnkeyedEncodingContainer(impl: impl, array: array, codingPath: newPath) + return nestedContainer + } + + mutating func superEncoder() -> Encoder { + let newEncoder = self.getEncoder(for: _JSONKey.super) + self.object.set(newEncoder, for: _JSONKey.super.stringValue) + return newEncoder + } + + mutating func superEncoder(forKey key: Self.Key) -> Encoder { + let convertedKey = self._converted(key) + let newEncoder = self.getEncoder(for: convertedKey) + self.object.set(newEncoder, for: convertedKey.stringValue) + return newEncoder + } +} + +extension JSONKeyedEncodingContainer { + @inline(__always) private mutating func encodeFloatingPoint(_ float: F, key: CodingKey) throws { + let value = try self.wrapFloat(float, for: key) + self.object.set(value, for: key.stringValue) + } + + @inline(__always) private mutating func encodeFixedWidthInteger(_ value: N, key: CodingKey) throws { + self.object.set(.number(value.description), for: key.stringValue) + } +} + +private struct JSONUnkeyedEncodingContainer: UnkeyedEncodingContainer, _SpecialTreatmentEncoder { + let impl: JSONSafeEncoderImpl + let array: JSONFuture.RefArray + let codingPath: [CodingKey] + + var count: Int { + self.array.array.count + } + private var firstValueWritten: Bool = false + fileprivate var options: JSONSafeEncoder._Options { + return self.impl.options + } + + init(impl: JSONSafeEncoderImpl, codingPath: [CodingKey]) { + self.impl = impl + self.array = impl.array! + self.codingPath = codingPath + } + + // used for nested containers + init(impl: JSONSafeEncoderImpl, array: JSONFuture.RefArray, codingPath: [CodingKey]) { + self.impl = impl + self.array = array + self.codingPath = codingPath + } + + mutating func encodeNil() throws { + self.array.append(.null) + } + + mutating func encode(_ value: Bool) throws { + self.array.append(.bool(value)) + } + + mutating func encode(_ value: String) throws { + self.array.append(.string(value)) + } + + mutating func encode(_ value: Double) throws { + try encodeFloatingPoint(value) + } + + mutating func encode(_ value: Float) throws { + try encodeFloatingPoint(value) + } + + mutating func encode(_ value: Int) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int8) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int16) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int32) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int64) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt8) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt16) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt32) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt64) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: T) throws where T: Encodable { + let key = _JSONKey(stringValue: "Index \(self.count)", intValue: self.count) + let encoded = try self.wrapEncodable(value, for: key) + self.array.append(encoded ?? .object([:])) + } + + mutating func nestedContainer(keyedBy _: NestedKey.Type) -> + KeyedEncodingContainer where NestedKey: CodingKey + { + let newPath = self.codingPath + [_JSONKey(index: self.count)] + let object = self.array.appendObject() + let nestedContainer = JSONKeyedEncodingContainer(impl: impl, object: object, codingPath: newPath) + return KeyedEncodingContainer(nestedContainer) + } + + mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + let newPath = self.codingPath + [_JSONKey(index: self.count)] + let array = self.array.appendArray() + let nestedContainer = JSONUnkeyedEncodingContainer(impl: impl, array: array, codingPath: newPath) + return nestedContainer + } + + mutating func superEncoder() -> Encoder { + let encoder = self.getEncoder(for: _JSONKey(index: self.count)) + self.array.append(encoder) + return encoder + } +} + +extension JSONUnkeyedEncodingContainer { + @inline(__always) private mutating func encodeFixedWidthInteger(_ value: N) throws { + self.array.append(.number(value.description)) + } + + @inline(__always) private mutating func encodeFloatingPoint(_ float: F) throws { + let value = try self.wrapFloat(float, for: _JSONKey(index: self.count)) + self.array.append(value) + } +} + +private struct JSONSingleValueEncodingContainer: SingleValueEncodingContainer, _SpecialTreatmentEncoder { + let impl: JSONSafeEncoderImpl + let codingPath: [CodingKey] + + private var firstValueWritten: Bool = false + fileprivate var options: JSONSafeEncoder._Options { + return self.impl.options + } + + init(impl: JSONSafeEncoderImpl, codingPath: [CodingKey]) { + self.impl = impl + self.codingPath = codingPath + } + + mutating func encodeNil() throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = .null + } + + mutating func encode(_ value: Bool) throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = .bool(value) + } + + mutating func encode(_ value: Int) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int8) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int16) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int32) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Int64) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt8) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt16) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt32) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: UInt64) throws { + try encodeFixedWidthInteger(value) + } + + mutating func encode(_ value: Float) throws { + try encodeFloatingPoint(value) + } + + mutating func encode(_ value: Double) throws { + try encodeFloatingPoint(value) + } + + mutating func encode(_ value: String) throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = .string(value) + } + + mutating func encode(_ value: T) throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = try self.wrapEncodable(value, for: nil) + } + + func preconditionCanEncodeNewValue() { + precondition(self.impl.singleValue == nil, "Attempt to encode value through single value container when previously value already encoded.") + } +} + +extension JSONSingleValueEncodingContainer { + @inline(__always) private mutating func encodeFixedWidthInteger(_ value: N) throws { + self.preconditionCanEncodeNewValue() + self.impl.singleValue = .number(value.description) + } + + @inline(__always) private mutating func encodeFloatingPoint(_ float: F) throws { + self.preconditionCanEncodeNewValue() + let value = try self.wrapFloat(float, for: nil) + self.impl.singleValue = value + } +} + +extension JSONValue { + + fileprivate struct Writer { + let options: JSONSafeEncoder.OutputFormatting + + init(options: JSONSafeEncoder.OutputFormatting) { + self.options = options + } + + func writeValue(_ value: JSONValue) -> [UInt8] { + var bytes = [UInt8]() + if self.options.contains(.prettyPrinted) { + self.writeValuePretty(value, into: &bytes) + } + else { + self.writeValue(value, into: &bytes) + } + return bytes + } + + private func writeValue(_ value: JSONValue, into bytes: inout [UInt8]) { + switch value { + case .null: + bytes.append(contentsOf: [UInt8]._null) + case .bool(true): + bytes.append(contentsOf: [UInt8]._true) + case .bool(false): + bytes.append(contentsOf: [UInt8]._false) + case .string(let string): + self.encodeString(string, to: &bytes) + case .number(let string): + bytes.append(contentsOf: string.utf8) + case .array(let array): + var iterator = array.makeIterator() + bytes.append(._openbracket) + // we don't like branching, this is why we have this extra + if let first = iterator.next() { + self.writeValue(first, into: &bytes) + } + while let item = iterator.next() { + bytes.append(._comma) + self.writeValue(item, into:&bytes) + } + bytes.append(._closebracket) + case .object(let dict): + if #available(macOS 10.13, *), options.contains(.sortedKeys) { + let sorted = dict.sorted { $0.key < $1.key } + self.writeObject(sorted, into: &bytes) + } else { + self.writeObject(dict, into: &bytes) + } + } + } + + private func writeObject(_ object: Object, into bytes: inout [UInt8], depth: Int = 0) + where Object.Element == (key: String, value: JSONValue) + { + var iterator = object.makeIterator() + bytes.append(._openbrace) + if let (key, value) = iterator.next() { + self.encodeString(key, to: &bytes) + bytes.append(._colon) + self.writeValue(value, into: &bytes) + } + while let (key, value) = iterator.next() { + bytes.append(._comma) + // key + self.encodeString(key, to: &bytes) + bytes.append(._colon) + + self.writeValue(value, into: &bytes) + } + bytes.append(._closebrace) + } + + private func addInset(to bytes: inout [UInt8], depth: Int) { + bytes.append(contentsOf: [UInt8](repeating: ._space, count: depth * 2)) + } + + private func writeValuePretty(_ value: JSONValue, into bytes: inout [UInt8], depth: Int = 0) { + switch value { + case .null: + bytes.append(contentsOf: [UInt8]._null) + case .bool(true): + bytes.append(contentsOf: [UInt8]._true) + case .bool(false): + bytes.append(contentsOf: [UInt8]._false) + case .string(let string): + self.encodeString(string, to: &bytes) + case .number(let string): + bytes.append(contentsOf: string.utf8) + case .array(let array): + var iterator = array.makeIterator() + bytes.append(contentsOf: [._openbracket, ._newline]) + if let first = iterator.next() { + self.addInset(to: &bytes, depth: depth + 1) + self.writeValuePretty(first, into: &bytes, depth: depth + 1) + } + while let item = iterator.next() { + bytes.append(contentsOf: [._comma, ._newline]) + self.addInset(to: &bytes, depth: depth + 1) + self.writeValuePretty(item, into: &bytes, depth: depth + 1) + } + bytes.append(._newline) + self.addInset(to: &bytes, depth: depth) + bytes.append(._closebracket) + case .object(let dict): + if #available(macOS 10.13, *), options.contains(.sortedKeys) { + let sorted = dict.sorted { $0.key < $1.key } + self.writePrettyObject(sorted, into: &bytes, depth: depth) + } else { + self.writePrettyObject(dict, into: &bytes, depth: depth) + } + } + } + + private func writePrettyObject(_ object: Object, into bytes: inout [UInt8], depth: Int = 0) + where Object.Element == (key: String, value: JSONValue) + { + var iterator = object.makeIterator() + bytes.append(contentsOf: [._openbrace, ._newline]) + if let (key, value) = iterator.next() { + self.addInset(to: &bytes, depth: depth + 1) + self.encodeString(key, to: &bytes) + bytes.append(contentsOf: [._space, ._colon, ._space]) + self.writeValuePretty(value, into: &bytes, depth: depth + 1) + } + while let (key, value) = iterator.next() { + bytes.append(contentsOf: [._comma, ._newline]) + self.addInset(to: &bytes, depth: depth + 1) + // key + self.encodeString(key, to: &bytes) + bytes.append(contentsOf: [._space, ._colon, ._space]) + // value + self.writeValuePretty(value, into: &bytes, depth: depth + 1) + } + bytes.append(._newline) + self.addInset(to: &bytes, depth: depth) + bytes.append(._closebrace) + } + + private func encodeString(_ string: String, to bytes: inout [UInt8]) { + bytes.append(UInt8(ascii: "\"")) + let stringBytes = string.utf8 + var startCopyIndex = stringBytes.startIndex + var nextIndex = startCopyIndex + + while nextIndex != stringBytes.endIndex { + switch stringBytes[nextIndex] { + case 0 ..< 32, UInt8(ascii: "\""), UInt8(ascii: "\\"): + // All Unicode characters may be placed within the + // quotation marks, except for the characters that MUST be escaped: + // quotation mark, reverse solidus, and the control characters (U+0000 + // through U+001F). + // https://tools.ietf.org/html/rfc8259#section-7 + + // copy the current range over + bytes.append(contentsOf: stringBytes[startCopyIndex ..< nextIndex]) + switch stringBytes[nextIndex] { + case UInt8(ascii: "\""): // quotation mark + bytes.append(contentsOf: [._backslash, ._quote]) + case UInt8(ascii: "\\"): // reverse solidus + bytes.append(contentsOf: [._backslash, ._backslash]) + case 0x08: // backspace + bytes.append(contentsOf: [._backslash, UInt8(ascii: "b")]) + case 0x0C: // form feed + bytes.append(contentsOf: [._backslash, UInt8(ascii: "f")]) + case 0x0A: // line feed + bytes.append(contentsOf: [._backslash, UInt8(ascii: "n")]) + case 0x0D: // carriage return + bytes.append(contentsOf: [._backslash, UInt8(ascii: "r")]) + case 0x09: // tab + bytes.append(contentsOf: [._backslash, UInt8(ascii: "t")]) + default: + func valueToAscii(_ value: UInt8) -> UInt8 { + switch value { + case 0 ... 9: + return value + UInt8(ascii: "0") + case 10 ... 15: + return value - 10 + UInt8(ascii: "a") + default: + preconditionFailure() + } + } + bytes.append(UInt8(ascii: "\\")) + bytes.append(UInt8(ascii: "u")) + bytes.append(UInt8(ascii: "0")) + bytes.append(UInt8(ascii: "0")) + let first = stringBytes[nextIndex] / 16 + let remaining = stringBytes[nextIndex] % 16 + bytes.append(valueToAscii(first)) + bytes.append(valueToAscii(remaining)) + } + + nextIndex = stringBytes.index(after: nextIndex) + startCopyIndex = nextIndex + case UInt8(ascii: "/") where options.contains(.withoutEscapingSlashes) == false: + bytes.append(contentsOf: stringBytes[startCopyIndex ..< nextIndex]) + bytes.append(contentsOf: [._backslash, UInt8(ascii: "/")]) + nextIndex = stringBytes.index(after: nextIndex) + startCopyIndex = nextIndex + default: + nextIndex = stringBytes.index(after: nextIndex) + } + } + + // copy everything, that hasn't been copied yet + bytes.append(contentsOf: stringBytes[startCopyIndex ..< nextIndex]) + bytes.append(UInt8(ascii: "\"")) + } + } +} + + +//===----------------------------------------------------------------------===// +// Shared Key Types +//===----------------------------------------------------------------------===// + +internal struct _JSONKey: CodingKey { + public var stringValue: String + public var intValue: Int? + + public init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + public init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } + + public init(stringValue: String, intValue: Int?) { + self.stringValue = stringValue + self.intValue = intValue + } + + internal init(index: Int) { + self.stringValue = "Index \(index)" + self.intValue = index + } + + internal static let `super` = _JSONKey(stringValue: "super")! +} + +//===----------------------------------------------------------------------===// +// Shared ISO8601 Date Formatter +//===----------------------------------------------------------------------===// + +// NOTE: This value is implicitly lazy and _must_ be lazy. We're compiled against the latest SDK (w/ ISO8601DateFormatter), but linked against whichever Foundation the user has. ISO8601DateFormatter might not exist, so we better not hit this code path on an older OS. +@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) +internal var _iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = .withInternetDateTime + return formatter +}() + +//===----------------------------------------------------------------------===// +// Error Utilities +//===----------------------------------------------------------------------===// + +extension EncodingError { + /// Returns a `.invalidValue` error describing the given invalid floating-point value. + /// + /// + /// - parameter value: The value that was invalid to encode. + /// - parameter path: The path of `CodingKey`s taken to encode this value. + /// - returns: An `EncodingError` with the appropriate path and debug description. + fileprivate static func _invalidFloatingPointValue(_ value: T, at codingPath: [CodingKey]) -> EncodingError { + let valueDescription: String + if value == T.infinity { + valueDescription = "\(T.self).infinity" + } else if value == -T.infinity { + valueDescription = "-\(T.self).infinity" + } else { + valueDescription = "\(T.self).nan" + } + + let debugDescription = "Unable to encode \(valueDescription) directly in JSON. Use JSONSafeEncoder.NonConformingFloatEncodingStrategy.convertToString to specify how the value should be encoded." + return .invalidValue(value, EncodingError.Context(codingPath: codingPath, debugDescription: debugDescription)) + } +} diff --git a/Sources/JSONSafeEncoder/JSONValue.swift b/Sources/JSONSafeEncoder/JSONValue.swift new file mode 100644 index 0000000..1e89ccd --- /dev/null +++ b/Sources/JSONSafeEncoder/JSONValue.swift @@ -0,0 +1,104 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/** -------------------------------------------------------------------------------------------- +TWILIO NOTICE: Filenames and functionality have been modified from their original counterparts @ +https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONEncoder.swift + +Copyright (c) 2023 Twilio Inc. +---------------------------------------------------------------------------------------------**/ + +import Foundation +import CoreFoundation + +internal enum JSONValue: Equatable { + case string(String) + case number(String) + case bool(Bool) + case null + + case array([JSONValue]) + case object([String: JSONValue]) +} + +extension JSONValue { + var isValue: Bool { + switch self { + case .array, .object: + return false + case .null, .number, .string, .bool: + return true + } + } + + var isContainer: Bool { + switch self { + case .array, .object: + return true + case .null, .number, .string, .bool: + return false + } + } +} + +extension JSONValue { + var debugDataTypeDescription: String { + switch self { + case .array: + return "an array" + case .bool: + return "bool" + case .number: + return "a number" + case .string: + return "a string" + case .object: + return "a dictionary" + case .null: + return "null" + } + } +} + +private extension JSONValue { + func toObjcRepresentation(options: JSONSerialization.ReadingOptions) throws -> Any { + switch self { + case .array(let values): + let array = try values.map { try $0.toObjcRepresentation(options: options) } + if !options.contains(.mutableContainers) { + return array + } + return NSMutableArray(array: array, copyItems: false) + case .object(let object): + let dictionary = try object.mapValues { try $0.toObjcRepresentation(options: options) } + if !options.contains(.mutableContainers) { + return dictionary + } + return NSMutableDictionary(dictionary: dictionary, copyItems: false) + case .bool(let bool): + return NSNumber(value: bool) + case .number(let string): + guard let number = NSNumber.fromJSONNumber(string) else { + throw JSONError.numberIsNotRepresentableInSwift(parsed: string) + } + return number + case .null: + return NSNull() + case .string(let string): + if options.contains(.mutableLeaves) { + return NSMutableString(string: string) + } + return string + } + } +} + diff --git a/Sources/JSONSafeEncoder/Version.swift b/Sources/JSONSafeEncoder/Version.swift new file mode 100644 index 0000000..c97bf05 --- /dev/null +++ b/Sources/JSONSafeEncoder/Version.swift @@ -0,0 +1,17 @@ +// +// Version.swift +// JSONSafeEncoder +// +// Created by Brandon Sneed on 08/16/21. +// + +// DO NOT MODIFY THIS FILE BY HAND!! +// DO NOT MODIFY THIS FILE BY HAND!! +// DO NOT MODIFY THIS FILE BY HAND!! +// DO NOT MODIFY THIS FILE BY HAND!! + +// Use release.sh's automation. + +// BREAKING.FEATURE.FIX + +internal let __jsonsafeencoder_version = "0.9.9" diff --git a/Tests/JSONSafeEncoderTests/JSONSafeEncoderTests.swift b/Tests/JSONSafeEncoderTests/JSONSafeEncoderTests.swift new file mode 100644 index 0000000..5bcdbd3 --- /dev/null +++ b/Tests/JSONSafeEncoderTests/JSONSafeEncoderTests.swift @@ -0,0 +1,170 @@ +import XCTest +@testable import JSONSafeEncoder + +final class JSONSafeEncoderTests: XCTestCase { + func testRegularEncoding() throws { + struct TestStruct: Codable { + let myString: String + let myDouble: Double + } + + let test = TestStruct(myString: "this is a test", myDouble: 3.1) + + let encoder = JSONSafeEncoder() + encoder.outputFormatting = .prettyPrinted + + let json = try encoder.encode(test) + XCTAssertNotNil(json) + + let prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + + + let decoder = JSONDecoder() + let newTest = try decoder.decode(TestStruct.self, from: json) + + XCTAssertEqual(newTest.myString, "this is a test") + XCTAssertEqual(newTest.myDouble, 3.1) + } + + func testRegularEncodingThrows() throws { + struct TestStruct: Codable { + let myString: String + let myDouble: Double + } + + let test = TestStruct(myString: "this is a test", myDouble: Double.nan) + + let encoder = JSONSafeEncoder() + encoder.nonConformingFloatEncodingStrategy = .throw + encoder.outputFormatting = .prettyPrinted + + var didThrow = false + do { + let json = try encoder.encode(test) + + let prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + } catch { + didThrow = true + } + XCTAssertTrue(didThrow) + } + + func testRegularEncodingZeros() throws { + struct TestStruct: Codable { + let myString: String + let myDouble: Double + } + + let test = TestStruct(myString: "this is a test", myDouble: Double.nan) + + let encoder = JSONSafeEncoder() + encoder.nonConformingFloatEncodingStrategy = .zero + encoder.outputFormatting = .prettyPrinted + + let json = try encoder.encode(test) + XCTAssertNotNil(json) + + let prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + + + let decoder = JSONDecoder() + let newTest = try decoder.decode(TestStruct.self, from: json) + + XCTAssertEqual(newTest.myString, "this is a test") + XCTAssertEqual(newTest.myDouble, 0) + } + + func testRegularEncodingNulls() throws { + struct TestStruct: Codable { + let myString: String + let myDouble: Double? + } + + let test = TestStruct(myString: "this is a test", myDouble: Double.nan) + + let encoder = JSONSafeEncoder() + encoder.nonConformingFloatEncodingStrategy = .null + encoder.outputFormatting = .prettyPrinted + + let json = try encoder.encode(test) + XCTAssertNotNil(json) + + let prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + + let decoder = JSONDecoder() + let newTest = try decoder.decode(TestStruct.self, from: json) + + XCTAssertEqual(newTest.myString, "this is a test") + XCTAssertEqual(newTest.myDouble, nil) + } + + func testRegularEncodingStrings() throws { + struct TestStruct: Codable { + let myString: String + let myDouble: Double + } + + var test: TestStruct + var json: Data + var prettyString: String + let encoder = JSONSafeEncoder() + encoder.nonConformingFloatEncodingStrategy = .convertToString(positiveInfinity: "inf", negativeInfinity: "-inf", nan: "nan") + encoder.outputFormatting = .prettyPrinted + + test = TestStruct(myString: "this is a test", myDouble: Double.nan) + json = try encoder.encode(test) + XCTAssertNotNil(json) + prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + // not my favorite thing to do, but this one is a bit difficult to test. + XCTAssertTrue(prettyString.contains("nan")) + + test = TestStruct(myString: "this is a test", myDouble: Double.infinity) + json = try encoder.encode(test) + XCTAssertNotNil(json) + prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + // not my favorite thing to do, but this one is a bit difficult to test. + XCTAssertTrue(prettyString.contains("inf")) + + test = TestStruct(myString: "this is a test", myDouble: -Double.infinity) + json = try encoder.encode(test) + XCTAssertNotNil(json) + prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + // not my favorite thing to do, but this one is a bit difficult to test. + XCTAssertTrue(prettyString.contains("-inf")) + + // check the defaults we made + encoder.nonConformingFloatEncodingStrategy = .convertToStringDefaults + + test = TestStruct(myString: "this is a test", myDouble: Double.nan) + json = try encoder.encode(test) + XCTAssertNotNil(json) + prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + // not my favorite thing to do, but this one is a bit difficult to test. + XCTAssertTrue(prettyString.contains("NaN")) + + test = TestStruct(myString: "this is a test", myDouble: Double.infinity) + json = try encoder.encode(test) + XCTAssertNotNil(json) + prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + // not my favorite thing to do, but this one is a bit difficult to test. + XCTAssertTrue(prettyString.contains("Infinity")) + + test = TestStruct(myString: "this is a test", myDouble: -Double.infinity) + json = try encoder.encode(test) + XCTAssertNotNil(json) + prettyString = String(data: json, encoding: .utf8)! + print(prettyString) + // not my favorite thing to do, but this one is a bit difficult to test. + XCTAssertTrue(prettyString.contains("-Infinity")) + + } +} diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..21535a4 --- /dev/null +++ b/release.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +PROJECT_NAME="JSONSafeEncoder-Swift" +PRODUCT_NAME="JSONSafeEncoder" + +LOWER_PRODUCT_NAME="$(echo ${PRODUCT_NAME} | tr '[:upper:]' '[:lower:]')" + +vercomp () { + if [[ $1 == $2 ]] + then + return 0 + fi + local IFS=. + local i ver1=($1) ver2=($2) + # fill empty fields in ver1 with zeros + for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)) + do + ver1[i]=0 + done + for ((i=0; i<${#ver1[@]}; i++)) + do + if [[ -z ${ver2[i]} ]] + then + # fill empty fields in ver2 with zeros + ver2[i]=0 + fi + if ((10#${ver1[i]} > 10#${ver2[i]})) + then + return 1 + fi + if ((10#${ver1[i]} < 10#${ver2[i]})) + then + return 2 + fi + done + return 0 +} + +# check if `gh` tool is installed. +if ! command -v gh &> /dev/null +then + echo "Github CLI tool is required, but could not be found." + echo "Install it via: $ brew install gh" + exit 1 +fi + +# check if `gh` tool has auth access. +# command will return non-zero if not auth'd. +authd=$(gh auth status -t) +if [[ $? != 0 ]]; then + echo "ex: $ gh auth login" + exit 1 +fi + +# check that we're on the `main` branch +branch=$(git rev-parse --abbrev-ref HEAD) +if [ $branch != 'main' ] +then + echo "The 'main' must be the current branch to make a release." + echo "You are currently on: $branch" + exit 1 +fi + +versionFile="./sources/${PRODUCT_NAME}/Version.swift" + +# get last line in version.swift +versionLine=$(tail -n 1 $versionFile) +# split at the = +version=$(cut -d "=" -f2- <<< "$versionLine") +# remove quotes and spaces +version=$(sed "s/[' \"]//g" <<< "$version") + +echo "${PROJECT_NAME} current version: $version" + +# no args, so give usage. +if [ $# -eq 0 ] +then + echo "Release automation script" + echo "" + echo "Usage: $ ./release.sh " + echo " ex: $ ./release.sh \"1.0.2\"" + exit 0 +fi + +newVersion="${1%.*}.$((${1##*.}))" +echo "Preparing to release $newVersion..." + +vercomp $newVersion $version +case $? in + 0) op='=';; + 1) op='>';; + 2) op='<';; +esac + +if [ $op != '>' ] +then + echo "New version must be greater than previous version ($version)." + exit 1 +fi + +read -r -p "Are you sure you want to release $newVersion? [y/N] " response +case "$response" in + [yY][eE][sS]|[yY]) + ;; + *) + exit 1 + ;; +esac + +# get the commits since the last release... +# note: we do this here so the "Version x.x.x" commit doesn't show up in logs. +changelog=$(git log --pretty=format:"- (%an) %s" $(git describe --tags --abbrev=0 @^)..@) +tempFile=$(mktemp) +#write changelog to temp file. +echo -e "$changelog" >> $tempFile + +# update sources/Segment/Version.swift +# - remove last line... +sed -i '' -e '$ d' $versionFile +# - add new line w/ new version +echo "internal let __${LOWER_PRODUCT_NAME}_version = \"$newVersion\"" >> $versionFile + +# commit the version change. +git commit -am "Version $newVersion" && git push +# gh release will make both the tag and the release itself. +gh release create $newVersion -F $tempFile -t "Version $newVersion" + +# remove the tempfile. +rm $tempFile + +# build up the xcframework to upload to github +./build.sh + +# upload the release +gh release upload $newVersion ${PRODUCT_NAME}.xcframework.zip