diff --git a/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift b/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift index 00415cfa341..6220f031252 100644 --- a/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift +++ b/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift @@ -14,17 +14,10 @@ import Foundation -private enum Constants { - static let longType = "type.googleapis.com/google.protobuf.Int64Value" - static let unsignedLongType = "type.googleapis.com/google.protobuf.UInt64Value" - static let dateType = "type.googleapis.com/google.protobuf.Timestamp" -} - extension FunctionsSerializer { enum Error: Swift.Error { case unsupportedType(typeName: String) - case unknownNumberType(charValue: String, number: NSNumber) - case invalidValueForType(value: String, requestedType: String) + case failedToParseWrappedNumber(value: String, type: String) } } @@ -41,8 +34,8 @@ final class FunctionsSerializer: Sendable { func encode(_ object: Any) throws -> Any { if object is NSNull { return object - } else if object is NSNumber { - return try encodeNumber(object as! NSNumber) + } else if let number = object as? NSNumber { + return wrapNumberIfNeeded(number) } else if object is NSString { return object } else if let dict = object as? NSDictionary { @@ -70,16 +63,8 @@ final class FunctionsSerializer: Sendable { func decode(_ object: Any) throws -> Any { // Return these types as is. PORTING NOTE: Moved from the bottom of the func for readability. if let dict = object as? NSDictionary { - if let requestedType = dict["@type"] as? String { - guard let value = dict["value"] as? String else { - // Seems like we should throw here - but this maintains compatibility. - return dict - } - if let result = try decodeWrappedType(requestedType, value) { - return result - } - - // Treat unknown types as dictionaries, so we don't crash old clients when we add types. + if let wrappedNumber = WrappedNumber(from: dict) { + return try unwrapNumber(wrappedNumber) } let decoded = NSMutableDictionary() @@ -106,73 +91,76 @@ final class FunctionsSerializer: Sendable { String(describing: type(of: value)) } - private func encodeNumber(_ number: NSNumber) throws -> AnyObject { - // Recover the underlying type of the number, using the method described here: - // http://stackoverflow.com/questions/2518761/get-type-of-nsnumber - let cType = number.objCType - - // Type Encoding values taken from - // https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/ - // Articles/ocrtTypeEncodings.html - switch cType[0] { - case CChar("q".utf8.first!): - // "long long" might be larger than JS supports, so make it a string. - return ["@type": Constants.longType, "value": "\(number)"] as AnyObject - - case CChar("Q".utf8.first!): - // "unsigned long long" might be larger than JS supports, so make it a string. - return ["@type": Constants.unsignedLongType, - "value": "\(number)"] as AnyObject - - case CChar("i".utf8.first!), - CChar("s".utf8.first!), - CChar("l".utf8.first!), - CChar("I".utf8.first!), - CChar("S".utf8.first!): - // If it"s an integer that isn"t too long, so just use the number. - return number - - case CChar("f".utf8.first!), CChar("d".utf8.first!): - // It"s a float/double that"s not too large. - return number - - case CChar("B".utf8.first!), CChar("c".utf8.first!), CChar("C".utf8.first!): - // Boolean values are weird. - // - // On arm64, objCType of a BOOL-valued NSNumber will be "c", even though @encode(BOOL) - // returns "B". "c" is the same as @encode(signed char). Unfortunately this means that - // legitimate usage of signed chars is impossible, but this should be rare. - // - // Just return Boolean values as-is. - return number - + private func wrapNumberIfNeeded(_ number: NSNumber) -> Any { + switch String(cString: number.objCType) { + case "q": + // "long long" might be larger than JS supports, so make it a string: + return WrappedNumber(type: .long, value: "\(number)").encoded + case "Q": + // "unsigned long long" might be larger than JS supports, so make it a string: + return WrappedNumber(type: .unsignedLong, value: "\(number)").encoded default: - // All documented codes should be handled above, so this shouldn"t happen. - throw Error.unknownNumberType(charValue: String(cType[0]), number: number) + // All other types should fit JS limits, so return the number as is: + return number } } - private func decodeWrappedType(_ type: String, _ value: String) throws -> AnyObject? { - switch type { - case Constants.longType: - let formatter = NumberFormatter() - guard let n = formatter.number(from: value) else { - throw Error.invalidValueForType(value: value, requestedType: type) + private func unwrapNumber(_ wrapped: WrappedNumber) throws(Error) -> any Numeric { + switch wrapped.type { + case .long: + guard let n = Int(wrapped.value) else { + throw .failedToParseWrappedNumber( + value: wrapped.value, + type: wrapped.type.rawValue + ) + } + return n + case .unsignedLong: + guard let n = UInt(wrapped.value) else { + throw .failedToParseWrappedNumber( + value: wrapped.value, + type: wrapped.type.rawValue + ) } return n + } + } +} + +// MARK: - WrappedNumber + +extension FunctionsSerializer { + private struct WrappedNumber { + let type: NumberType + let value: String + + // When / if objects are encoded / decoded using `Codable`, + // these two `init`s and `encoded` won’t be needed anymore: + + init(type: NumberType, value: String) { + self.type = type + self.value = value + } - case Constants.unsignedLongType: - // NSNumber formatter doesn't handle unsigned long long, so we have to parse it. - let str = (value as NSString).utf8String - var endPtr: UnsafeMutablePointer? - let returnValue = UInt64(strtoul(str, &endPtr, 10)) - guard String(returnValue) == value else { - throw Error.invalidValueForType(value: value, requestedType: type) + init?(from dictionary: NSDictionary) { + guard + let typeString = dictionary["@type"] as? String, + let type = NumberType(rawValue: typeString), + let value = dictionary["value"] as? String + else { + return nil } - return NSNumber(value: returnValue) - default: - return nil + self.init(type: type, value: value) + } + + var encoded: [String: String] { + ["@type": type.rawValue, "value": value] + } + + enum NumberType: String { + case long = "type.googleapis.com/google.protobuf.Int64Value" + case unsignedLong = "type.googleapis.com/google.protobuf.UInt64Value" } } } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsSerializerTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsSerializerTests.swift index 7fe77fd4dd9..b15448e92cc 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsSerializerTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsSerializerTests.swift @@ -98,7 +98,7 @@ class FunctionsSerializerTests: XCTestCase { let dictLowLong = ["@type": typeString, "value": badVal] do { _ = try serializer.decode(dictLowLong) as? NSNumber - } catch let FunctionsSerializer.Error.invalidValueForType(value, type) { + } catch let FunctionsSerializer.Error.failedToParseWrappedNumber(value, type) { XCTAssertEqual(value, badVal) XCTAssertEqual(type, typeString) return @@ -136,7 +136,7 @@ class FunctionsSerializerTests: XCTestCase { let coded = ["@type": typeString, "value": tooHighVal] do { _ = try serializer.decode(coded) as? NSNumber - } catch let FunctionsSerializer.Error.invalidValueForType(value, type) { + } catch let FunctionsSerializer.Error.failedToParseWrappedNumber(value, type) { XCTAssertEqual(value, tooHighVal) XCTAssertEqual(type, typeString) return @@ -283,6 +283,38 @@ class FunctionsSerializerTests: XCTestCase { try assert(serializer.decode(input), throwsUnsupportedTypeErrorWithName: "CustomObject") } + + // If the object can be decoded as a wrapped number, all other properties are ignored: + func testDecodeValidWrappedNumberWithUnsupportedExtra() throws { + let input = [ + "@type": "type.googleapis.com/google.protobuf.Int64Value", + "value": "1234567890", + "extra": CustomObject(), + ] as NSDictionary + + XCTAssertEqual(NSNumber(1_234_567_890), try serializer.decode(input) as? NSNumber) + } + + // If the object is not a valid wrapped number, it’s processed as a generic array: + func testDecodeWrappedNumberWithUnsupportedValue() throws { + let input = [ + "@type": "type.googleapis.com/google.protobuf.Int64Value", + "value": CustomObject(), + ] as NSDictionary + + try assert(serializer.decode(input), throwsUnsupportedTypeErrorWithName: "CustomObject") + } + + // If the object is not a valid wrapped number, it’s processed as a generic array: + func testDecodeInvalidWrappedNumberWithUnsupportedExtra() throws { + let input = [ + "@type": "CUSTOM_TYPE", + "value": "1234567890", + "extra": CustomObject(), + ] as NSDictionary + + try assert(serializer.decode(input), throwsUnsupportedTypeErrorWithName: "CustomObject") + } } // MARK: - Utilities