Repository: parse-community/Parse-Swift (swift)

Issue:
Parse Swift can't decode custom ParseCloud errors
Throwing a custom error code from a Parse Cloud function results in:

```
ParseError(code: ParseSwift.ParseError.Code.unknownError, message: "Error decoding parse-server response: Optional(<NSHTTPURLResponse: 0x100d74140> { URL: http://localhost:1337/1/jobs/test } { Status Code: 200, Headers {\n    \"Content-Type\" =     (\n        \"application/json\"\n    );\n} }) with error: The data couldn’t be read because it is missing. 
```

Failing case:

```
    func testCustomError() {

        let encoded: Data = "{\"error\":\"Error: Custom Error\",\"code\":2000}".data(using: .utf8)!

        MockURLProtocol.mockRequests { _ in
            return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0)
        }
        do {
            let cloud = Cloud(functionJobName: "test")
            _ = try cloud.startJob()
            XCTFail("Should have thrown ParseError")
        } catch {
            if let error = error as? ParseError {
                XCTAssertNotEqual(error.code, ParseError.Code.unknownError)
                XCTAssertEqual(error.code.rawValue, 2000)
            } else {
                XCTFail("Should have thrown ParseError")
            }
        }
    }
```

Proposed solution:

Add `.other` enum to error, and `intCode` property to Parse.Error

Pull request description:
Closes #164

@cbaker6 let me know your thoughts!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fba647b2..3b845ebd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
 
 __Improvements__
 - Ensure pipeline and fields are checked when comparing queries  ([#163](https://github.com/parse-community/Parse-Swift/pull/163)), thanks to [Corey Baker](https://github.com/cbaker6).
+- Allow custom error codes to be thrown from Cloud Functions ([#165](https://github.com/parse-community/Parse-Swift/pull/165)), thanks to [Daniel Blyth](https://github.com/dblythy).
 
 ### 1.8.1
 [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.8.0...1.8.1)
diff --git a/ParseSwift.xcodeproj/project.pbxproj b/ParseSwift.xcodeproj/project.pbxproj
index 79c7fc4da..c9288c8d3 100644
--- a/ParseSwift.xcodeproj/project.pbxproj
+++ b/ParseSwift.xcodeproj/project.pbxproj
@@ -385,6 +385,9 @@
 		91678710259BC5D600BB5B4E /* ParseCloudTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916786EF259BC59600BB5B4E /* ParseCloudTests.swift */; };
 		9167871A259BC5D600BB5B4E /* ParseCloudTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916786EF259BC59600BB5B4E /* ParseCloudTests.swift */; };
 		9194657824F16E330070296B /* ParseACLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9194657724F16E330070296B /* ParseACLTests.swift */; };
+		91B40651267A66ED00B129CD /* ParseErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B40650267A66ED00B129CD /* ParseErrorTests.swift */; };
+		91B40652267A66ED00B129CD /* ParseErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B40650267A66ED00B129CD /* ParseErrorTests.swift */; };
+		91B40653267A66ED00B129CD /* ParseErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B40650267A66ED00B129CD /* ParseErrorTests.swift */; };
 		91CB9537265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91CB9536265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift */; };
 		91CB9538265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91CB9536265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift */; };
 		91CB9539265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91CB9536265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift */; };
@@ -705,6 +708,7 @@
 		916786E1259B7DDA00BB5B4E /* ParseCloud.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseCloud.swift; sourceTree = "<group>"; };
 		916786EF259BC59600BB5B4E /* ParseCloudTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseCloudTests.swift; sourceTree = "<group>"; };
 		9194657724F16E330070296B /* ParseACLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseACLTests.swift; sourceTree = "<group>"; };
+		91B40650267A66ED00B129CD /* ParseErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseErrorTests.swift; sourceTree = "<group>"; };
 		91CB9536265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseAnanlyticsCombineTests.swift; sourceTree = "<group>"; };
 		F971F4F524DE381A006CB79B /* ParseEncoderExtraTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseEncoderExtraTests.swift; sourceTree = "<group>"; };
 		F97B45B424D9C6F200F4A88B /* ParseCoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParseCoding.swift; sourceTree = "<group>"; };
@@ -860,9 +864,9 @@
 				70DFEA892618E77800F8EB4B /* InitializeSDKTests.swift */,
 				4AA8076E1F794C1C008CD551 /* KeychainStoreTests.swift */,
 				9194657724F16E330070296B /* ParseACLTests.swift */,
-				7044C22C25C5E4E90011F6E7 /* ParseAnonymousCombineTests.swift */,
 				70170A4D2656EBA50070C905 /* ParseAnalyticsTests.swift */,
 				91CB9536265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift */,
+				7044C22C25C5E4E90011F6E7 /* ParseAnonymousCombineTests.swift */,
 				70A2D86A25B3ADB6001BEB7D /* ParseAnonymousTests.swift */,
 				7044C24225C5EA360011F6E7 /* ParseAppleCombineTests.swift */,
 				70C5502125B3D8F700B5DBC2 /* ParseAppleTests.swift */,
@@ -871,6 +875,7 @@
 				916786EF259BC59600BB5B4E /* ParseCloudTests.swift */,
 				7044C21F25C5E0160011F6E7 /* ParseConfigCombineTests.swift */,
 				70D1BE0625BB2BF400A42E7C /* ParseConfigTests.swift */,
+				91B40650267A66ED00B129CD /* ParseErrorTests.swift */,
 				89899DB426045DC4002E2043 /* ParseFacebookCombineTests.swift */,
 				89899CF32603CE9D002E2043 /* ParseFacebookTests.swift */,
 				7044C1F825C5CFAB0011F6E7 /* ParseFileCombineTests.swift */,
@@ -1749,6 +1754,7 @@
 				70C5504625B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */,
 				70110D5C2506ED0E0091CC1D /* ParseInstallationTests.swift in Sources */,
 				7016ED4025C4A25A00038648 /* ParseUserCombineTests.swift in Sources */,
+				91B40651267A66ED00B129CD /* ParseErrorTests.swift in Sources */,
 				705727B12593FF8800F0ADD5 /* ParseFileTests.swift in Sources */,
 				70BC0B33251903D1001556DB /* ParseGeoPointTests.swift in Sources */,
 				7003957625A0EE770052CB31 /* BatchUtilsTests.swift in Sources */,
@@ -1911,6 +1917,7 @@
 				70C5504825B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */,
 				709B98572556ECAA00507778 /* ParseACLTests.swift in Sources */,
 				7016ED4225C4A25A00038648 /* ParseUserCombineTests.swift in Sources */,
+				91B40653267A66ED00B129CD /* ParseErrorTests.swift in Sources */,
 				705727BC2593FF8C00F0ADD5 /* ParseFileTests.swift in Sources */,
 				709B984F2556ECAA00507778 /* AnyCodableTests.swift in Sources */,
 				7003957825A0EE770052CB31 /* BatchUtilsTests.swift in Sources */,
@@ -1972,6 +1979,7 @@
 				70C5504725B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */,
 				70F2E2BC254F283000B2EA5C /* ParseObjectTests.swift in Sources */,
 				7016ED4125C4A25A00038648 /* ParseUserCombineTests.swift in Sources */,
+				91B40652267A66ED00B129CD /* ParseErrorTests.swift in Sources */,
 				705727BB2593FF8B00F0ADD5 /* ParseFileTests.swift in Sources */,
 				70F2E2BD254F283000B2EA5C /* AnyDecodableTests.swift in Sources */,
 				7003957725A0EE770052CB31 /* BatchUtilsTests.swift in Sources */,
diff --git a/Sources/ParseSwift/Types/ParseError.swift b/Sources/ParseSwift/Types/ParseError.swift
index ba5dd9921..aca7dd7ec 100644
--- a/Sources/ParseSwift/Types/ParseError.swift
+++ b/Sources/ParseSwift/Types/ParseError.swift
@@ -12,12 +12,25 @@ import Foundation
  An object with a Parse code and message.
  */
 public struct ParseError: ParseType, Decodable, Swift.Error {
+    /// The value representing the error from the Parse Server.
     public let code: Code
+    /// The text representing the error from the Parse Server.
     public let message: String
+    /// An error value representing a custom error from the Parse Server.
+    public let otherCode: Int?
+    init(code: Code, message: String) {
+        self.code = code
+        self.message = message
+        self.otherCode = nil
+    }
 
     /// A textual representation of this error.
     public var localizedDescription: String {
-        return "ParseError code=\(code.rawValue) error=\(message)"
+        if let otherCode = otherCode {
+            return "ParseError code=\(code.rawValue) error=\(message) otherCode=\(otherCode)"
+        } else {
+            return "ParseError code=\(code.rawValue) error=\(message)"
+        }
     }
 
     enum CodingKeys: String, CodingKey {
@@ -347,6 +360,11 @@ public struct ParseError: ParseType, Decodable, Swift.Error {
          a non-2XX status code.
          */
         case xDomainRequest = 602
+
+        /**
+         Error code indicating any other custom error sent from the Parse Server.
+         */
+        case other
     }
 }
 
@@ -354,7 +372,13 @@ extension ParseError {
 
     public init(from decoder: Decoder) throws {
         let values = try decoder.container(keyedBy: CodingKeys.self)
-        code = try values.decode(Code.self, forKey: .code)
+        do {
+            code = try values.decode(Code.self, forKey: .code)
+            otherCode = nil
+        } catch {
+            code = .other
+            otherCode = try values.decode(Int.self, forKey: .code)
+        }
         message = try values.decode(String.self, forKey: .message)
     }
 


Test patch:
diff --git a/Tests/ParseSwiftTests/APICommandTests.swift b/Tests/ParseSwiftTests/APICommandTests.swift
index 9e3803e5b..601266558 100644
--- a/Tests/ParseSwiftTests/APICommandTests.swift
+++ b/Tests/ParseSwiftTests/APICommandTests.swift
@@ -177,7 +177,7 @@ class APICommandTests: XCTestCase {
         let errorKey = "error"
         let errorValue = "yarr"
         let codeKey = "code"
-        let codeValue = 100500
+        let codeValue = 100
         let responseDictionary: [String: Any] = [
             errorKey: errorValue,
             codeKey: codeValue
diff --git a/Tests/ParseSwiftTests/ParseCloudTests.swift b/Tests/ParseSwiftTests/ParseCloudTests.swift
index f10eb7ad0..89345e558 100644
--- a/Tests/ParseSwiftTests/ParseCloudTests.swift
+++ b/Tests/ParseSwiftTests/ParseCloudTests.swift
@@ -356,6 +356,31 @@ class ParseCloudTests: XCTestCase { // swiftlint:disable:this type_body_length
         }
     }
 
+    func testCustomError() {
+
+        guard let encoded: Data = "{\"error\":\"Error: Custom Error\",\"code\":2000}".data(using: .utf8) else {
+            XCTFail("Could not unwrap encoded data")
+            return
+        }
+
+        MockURLProtocol.mockRequests { _ in
+            return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0)
+        }
+        do {
+            let cloud = Cloud(functionJobName: "test")
+            _ = try cloud.runFunction()
+            XCTFail("Should have thrown ParseError")
+        } catch {
+            if let error = error as? ParseError {
+                XCTAssertEqual(error.code, .other)
+                XCTAssertEqual(error.message, "Error: Custom Error")
+                XCTAssertEqual(error.otherCode, 2000)
+            } else {
+                XCTFail("Should have thrown ParseError")
+            }
+        }
+    }
+
     func jobAsync(serverResponse: [String: String], callbackQueue: DispatchQueue) {
 
         let expectation1 = XCTestExpectation(description: "Logout user1")
diff --git a/Tests/ParseSwiftTests/ParseErrorTests.swift b/Tests/ParseSwiftTests/ParseErrorTests.swift
new file mode 100644
index 000000000..18bcd68bf
--- /dev/null
+++ b/Tests/ParseSwiftTests/ParseErrorTests.swift
@@ -0,0 +1,64 @@
+//
+//  ParseErrorTests.swift
+//  ParseSwift
+//
+//  Created by Corey Baker on 6/16/21.
+//  Copyright © 2021 Parse Community. All rights reserved.
+//
+
+import Foundation
+import XCTest
+@testable import ParseSwift
+
+class ParseErrorTests: XCTestCase {
+
+    override func setUpWithError() throws {
+        try super.setUpWithError()
+        guard let url = URL(string: "http://localhost:1337/1") else {
+            XCTFail("Should create valid URL")
+            return
+        }
+        ParseSwift.initialize(applicationId: "applicationId",
+                              clientKey: "clientKey",
+                              masterKey: "masterKey",
+                              serverURL: url,
+                              testing: true)
+    }
+
+    override func tearDownWithError() throws {
+        try super.tearDownWithError()
+        MockURLProtocol.removeAll()
+        #if !os(Linux) && !os(Android)
+        try KeychainStore.shared.deleteAll()
+        #endif
+        try ParseStorage.shared.deleteAll()
+    }
+
+    func testEncode() throws {
+        let code = -1
+        let message = "testing ParseError"
+        guard let encoded: Data = "{\"error\":\"\(message)\",\"code\":\(code)}".data(using: .utf8) else {
+            XCTFail("Should have unwrapped")
+            return
+        }
+        let decoded = try ParseCoding.jsonDecoder().decode(ParseError.self, from: encoded)
+        XCTAssertEqual(decoded.code.rawValue, code)
+        XCTAssertEqual(decoded.message, message)
+        XCTAssertEqual(decoded.localizedDescription, "ParseError code=\(code) error=\(message)")
+    }
+
+    func testEncodeOther() throws {
+        let code = 2000
+        let message = "testing ParseError"
+        guard let encoded = "{\"error\":\"\(message)\",\"code\":\(code)}".data(using: .utf8) else {
+            XCTFail("Should have unwrapped")
+            return
+        }
+        let decoded = try ParseCoding.jsonDecoder().decode(ParseError.self, from: encoded)
+        XCTAssertEqual(decoded.code, .other)
+        XCTAssertEqual(decoded.message, message)
+        XCTAssertEqual(decoded.localizedDescription,
+                       "ParseError code=\(ParseError.Code.other.rawValue) error=\(message) otherCode=\(code)")
+        XCTAssertEqual(decoded.otherCode, code)
+    }
+}