Skip to content

Refactored Number Coding in FunctionsSerializer #14889

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 66 additions & 78 deletions FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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<CChar>?
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"
}
}
}
36 changes: 34 additions & 2 deletions FirebaseFunctions/Tests/Unit/FunctionsSerializerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading