From c381bd6949f04709b03dbb2d3b17f8d6e7042b45 Mon Sep 17 00:00:00 2001 From: itaihanski Date: Thu, 4 Jan 2024 17:21:04 +0200 Subject: [PATCH] Add user custom attributes --- src/internal/http/DescopeClient.swift | 54 ++++++++++++ src/internal/others/Internal.swift | 116 +++++++++++++++++++++++++- src/internal/routes/Shared.swift | 1 + src/types/User.swift | 75 ++++++++++++++++- test/http/DescopeClient.swift | 93 +++++++++++++++++++++ test/types/User.swift | 16 ++++ 6 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 test/http/DescopeClient.swift create mode 100644 test/types/User.swift diff --git a/src/internal/http/DescopeClient.swift b/src/internal/http/DescopeClient.swift index 365c4a3..39a87fe 100644 --- a/src/internal/http/DescopeClient.swift +++ b/src/internal/http/DescopeClient.swift @@ -433,6 +433,60 @@ class DescopeClient: HTTPClient { var givenName: String? var middleName: String? var familyName: String? + var customAttributes: [String: Any] = [:] + + enum CodingKeys: CodingKey { + case userId + case loginIds + case createdTime + case name + case picture + case email + case verifiedEmail + case phone + case verifiedPhone + case givenName + case middleName + case familyName + case customAttributes + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + userId = try values.decode(String.self, forKey: .userId) + loginIds = try values.decode(Array.self, forKey: .loginIds) + createdTime = try values.decode(Int.self, forKey: .createdTime) + if let value = try? values.decode(String?.self, forKey: .name) { + name = value + } + if let value = try? values.decode(String?.self, forKey: .picture) { + picture = value + } + if let value = try? values.decode(String?.self, forKey: .email) { + email = value + } + if let value = try? values.decode(Bool.self, forKey: .verifiedEmail) { + verifiedEmail = value + } + if let value = try? values.decode(String?.self, forKey: .phone) { + phone = value + } + if let value = try? values.decode(Bool?.self, forKey: .verifiedPhone) { + verifiedPhone = value + } + if let value = try? values.decode(String?.self, forKey: .givenName) { + givenName = value + } + if let value = try? values.decode(String?.self, forKey: .middleName) { + middleName = value + } + if let value = try? values.decode(String?.self, forKey: .familyName) { + familyName = value + } + if let value = try? values.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: .customAttributes) { + customAttributes = decodeJson(container: value) + } + } } struct MaskedAddress: JSONResponse { diff --git a/src/internal/others/Internal.swift b/src/internal/others/Internal.swift index 42470dc..af0f9eb 100644 --- a/src/internal/others/Internal.swift +++ b/src/internal/others/Internal.swift @@ -29,7 +29,7 @@ extension Data { } self.init(base64Encoded: str, options: options) } - + func base64URLEncodedString(options: Base64EncodingOptions = []) -> String { return base64EncodedString(options: options) .replacingOccurrences(of: "+", with: "-") @@ -73,9 +73,121 @@ class AuthorizationDelegate: NSObject, ASAuthorizationControllerDelegate { completion?(.success(authorization)) completion = nil } - + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { completion?(.failure(error)) completion = nil } } + +// JSON + +struct JSONCodingKeys: CodingKey { + var stringValue: String + var intValue: Int? + + init(stringValue: String) { + self.stringValue = stringValue + } + + init(intValue: Int) { + self.init(stringValue: "\(intValue)") + self.intValue = intValue + } +} + +func decodeJson(container: KeyedDecodingContainer) -> [String: Any] { + var decoded: [String: Any] = [:] + for key in container.allKeys { + if let boolValue = try? container.decode(Bool.self, forKey: key) { + decoded[key.stringValue] = boolValue + } else if let intValue = try? container.decode(Int.self, forKey: key) { + decoded[key.stringValue] = intValue + } else if let doubleValue = try? container.decode(Double.self, forKey: key) { + decoded[key.stringValue] = doubleValue + } else if let stringValue = try? container.decode(String.self, forKey: key) { + decoded[key.stringValue] = stringValue + } else if let nestedContainer = try? container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) { + decoded[key.stringValue] = decodeJson(container: nestedContainer) + } else if var nestedUnkeyedContainer = try? container.nestedUnkeyedContainer(forKey: key) { + decoded[key.stringValue] = decodeJson(unkeydContainer: &nestedUnkeyedContainer) + } + } + return decoded +} + + +func decodeJson(unkeydContainer: inout UnkeyedDecodingContainer) -> [Any] { + var decoded: [Any] = [] + while unkeydContainer.isAtEnd == false { + if let value = try? unkeydContainer.decode(Bool.self) { + decoded.append(value) + } else if let value = try? unkeydContainer.decode(Int.self) { + decoded.append(value) + } else if let value = try? unkeydContainer.decode(Double.self) { + decoded.append(value) + } else if let value = try? unkeydContainer.decode(String.self) { + decoded.append(value) + } else if let _ = try? unkeydContainer.decode(String?.self) { + continue // Skip over `null` values + } else if let nestedContainer = try? unkeydContainer.nestedContainer(keyedBy: JSONCodingKeys.self) { + decoded.append(decodeJson(container: nestedContainer)) + } else if var nestedUnkeyedContainer = try? unkeydContainer.nestedUnkeyedContainer() { + decoded.append(decodeJson(unkeydContainer: &nestedUnkeyedContainer)) + } + } + return decoded +} + +extension Dictionary { + func encodeJson(container: inout KeyedEncodingContainer) throws { + try forEach({ (key, value) in + let encodingKey = JSONCodingKeys(stringValue: key) + switch value { + case let value as Bool: + try container.encode(value, forKey: encodingKey) + case let value as Int: + try container.encode(value, forKey: encodingKey) + case let value as Double: + try container.encode(value, forKey: encodingKey) + case let value as String: + try container.encode(value, forKey: encodingKey) + case let value as String?: + try container.encode(value, forKey: encodingKey) + case let value as [String: Any]: + var nestedContainer = container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: encodingKey) + try value.encodeJson(container: &nestedContainer) + case let value as [Any]: + var nestedUnkeyedContainer = container.nestedUnkeyedContainer(forKey: encodingKey) + try value.encodeJson(container: &nestedUnkeyedContainer) + default: + throw DescopeError.encodeError.with(message: "Invalid JSON value in dict: \(key): \(value)") + } + }) + } +} + +extension Array { + func encodeJson(container: inout UnkeyedEncodingContainer) throws { + for value in self { + switch value { + case let value as Bool: + try container.encode(value) + case let value as Int: + try container.encode(value) + case let value as Double: + try container.encode(value) + case let value as String: + try container.encode(value) + case let value as [String: Any]: + var nestedContainer = container.nestedContainer(keyedBy: JSONCodingKeys.self) + try value.encodeJson(container: &nestedContainer) + case let value as [Any]: + var nestedUnkeyedContainer = container.nestedUnkeyedContainer() + try value.encodeJson(container: &nestedUnkeyedContainer) + default: + throw DescopeError.encodeError.with(message: "Invalid JSON value in array: \(value)") + } + } + } +} diff --git a/src/internal/routes/Shared.swift b/src/internal/routes/Shared.swift index 8ed828f..1756add 100644 --- a/src/internal/routes/Shared.swift +++ b/src/internal/routes/Shared.swift @@ -38,6 +38,7 @@ extension DescopeClient.UserResponse { if let familyName, !familyName.isEmpty { me.familyName = familyName } + me.customAttributes = customAttributes return me } } diff --git a/src/types/User.swift b/src/types/User.swift index d77ed91..9db90a6 100644 --- a/src/types/User.swift +++ b/src/types/User.swift @@ -80,7 +80,11 @@ public struct DescopeUser: Codable, Equatable { /// The user's family name. public var familyName: String? - public init(userId: String, loginIds: [String], createdAt: Date, name: String? = nil, picture: URL? = nil, email: String? = nil, isVerifiedEmail: Bool = false, phone: String? = nil, isVerifiedPhone: Bool = false, givenName: String? = nil, middleName: String? = nil, familyName: String? = nil) { + /// A mapping of any custom attributes associated with this user. + /// User custom attributes are managed via the Descope console. + public var customAttributes: [String: Any] + + public init(userId: String, loginIds: [String], createdAt: Date, name: String? = nil, picture: URL? = nil, email: String? = nil, isVerifiedEmail: Bool = false, phone: String? = nil, isVerifiedPhone: Bool = false, givenName: String? = nil, middleName: String? = nil, familyName: String? = nil, customAttributes: [String: Any] = [:]) { self.userId = userId self.loginIds = loginIds self.createdAt = createdAt @@ -93,6 +97,75 @@ public struct DescopeUser: Codable, Equatable { self.givenName = givenName self.middleName = middleName self.familyName = familyName + self.customAttributes = customAttributes + } + + enum CodingKeys: CodingKey { + case userId + case loginIds + case createdAt + case name + case picture + case email + case isVerifiedEmail + case phone + case isVerifiedPhone + case givenName + case middleName + case familyName + case customAttributes + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + userId = try values.decode(String.self, forKey: .userId) + loginIds = try values.decode(Array.self, forKey: .loginIds) + createdAt = try values.decode(Date.self, forKey: .createdAt) + name = try values.decode(String?.self, forKey: .name) + picture = try values.decode(URL?.self, forKey: .picture) + email = try values.decode(String?.self, forKey: .email) + isVerifiedEmail = try values.decode(Bool.self, forKey: .isVerifiedEmail) + phone = try values.decode(String?.self, forKey: .phone) + isVerifiedPhone = try values.decode(Bool.self, forKey: .isVerifiedPhone) + givenName = try values.decode(String?.self, forKey: .givenName) + middleName = try values.decode(String?.self, forKey: .middleName) + familyName = try values.decode(String?.self, forKey: .familyName) + let value = try values.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: .customAttributes) + customAttributes = decodeJson(container: value) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(userId, forKey: .userId) + try container.encode(loginIds, forKey: .loginIds) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(name, forKey: .name) + try container.encode(picture, forKey: .picture) + try container.encode(email, forKey: .email) + try container.encode(isVerifiedEmail, forKey: .isVerifiedEmail) + try container.encode(phone, forKey: .phone) + try container.encode(isVerifiedPhone, forKey: .isVerifiedPhone) + try container.encode(givenName, forKey: .givenName) + try container.encode(middleName, forKey: .middleName) + try container.encode(familyName, forKey: .familyName) + var nestedContainer = container.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: .customAttributes) + try customAttributes.encodeJson(container: &nestedContainer) + } + + public static func == (lhs: DescopeUser, rhs: DescopeUser) -> Bool { + return lhs.userId == rhs.userId && + lhs.loginIds == rhs.loginIds && + lhs.createdAt == rhs.createdAt && + lhs.name == rhs.name && + lhs.picture == rhs.picture && + lhs.email == rhs.email && + lhs.isVerifiedEmail == rhs.isVerifiedEmail && + lhs.phone == rhs.phone && + lhs.isVerifiedPhone == rhs.isVerifiedPhone && + lhs.givenName == rhs.givenName && + lhs.middleName == rhs.middleName && + lhs.familyName == rhs.familyName + // lhs.customAttributes == rhs.customAttributes } } diff --git a/test/http/DescopeClient.swift b/test/http/DescopeClient.swift new file mode 100644 index 0000000..0c6d927 --- /dev/null +++ b/test/http/DescopeClient.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import DescopeKit + +class TestResponses: XCTestCase { + func testUserResponse() throws { + let jsonString = """ + { + "userId": "userId", + "loginIds": ["loginId"], + "name": "name", + "picture": "picture", + "email": "email", + "verifiedEmail": true, + "phone": "phone", + "createdTime": 123, + "middleName": "middleName", + "familyName": "familyName", + "customAttributes": { + "a": "yes", + "b": true, + "c": 1, + "d": null, + "unnecessaryArray": [ + "yes", + true, + null, + 1, + { + "a": "yes", + "b": true, + "c": 1, + "d": null + } + ] + } + } + """ + guard let data = jsonString.data(using: .utf8) else { return XCTFail("Couldn't get data from json string")} + guard let userResponse = try? JSONDecoder().decode(DescopeClient.UserResponse.self, from: data) else { return XCTFail("Couldn't decode") } + XCTAssertEqual("userId", userResponse.userId) + XCTAssertFalse(userResponse.verifiedPhone) + XCTAssertNil(userResponse.givenName) + + // customAttributes + try checkDictionary(userResponse.customAttributes) + + // customAttributes.unnecessaryArray + guard let array = userResponse.customAttributes["unnecessaryArray"] as? Array else { return XCTFail("Couldn't get custom attirubte value as array") } + XCTAssertEqual(4, array.count) // null value omitted + try checkArray(array) + + // customAttributes.unnecessaryArray[3] + guard let dict = array[3] as? [String: Any] else { return XCTFail("Couldn't get custom attirubte value as array") } + try checkDictionary(dict) + + // convert to DescopeUser and check again + let user = userResponse.convert() + XCTAssertEqual("userId", user.userId) + XCTAssertFalse(user.isVerifiedPhone) + XCTAssertNil(user.givenName) + + // customAttributes + try checkDictionary(user.customAttributes) + + // customAttributes.unnecessaryArray + guard let array = user.customAttributes["unnecessaryArray"] as? Array else { return XCTFail("Couldn't get custom attirubte value as array") } + XCTAssertEqual(4, array.count) // null value omitted + try checkArray(array) + + // customAttributes.unnecessaryArray[3] + guard let dict = array[3] as? [String: Any] else { return XCTFail("Couldn't get custom attirubte value as array") } + try checkDictionary(dict) + } + + func checkDictionary(_ dict: [String:Any]) throws { + guard let aValue = dict["a"] as? String else { return XCTFail("Couldn't get custom attirubte value as String") } + XCTAssertEqual("yes", aValue) + guard let bValue = dict["b"] as? Bool else { return XCTFail("Couldn't get custom attirubte value as Bool") } + XCTAssertTrue(bValue) + guard let cValue = dict["c"] as? Int else { return XCTFail("Couldn't get custom attirubte value as Int") } + XCTAssertEqual(1, cValue) + XCTAssertNil(dict["d"]) + } + + func checkArray(_ array: [Any]) throws { + guard let aValue = array[0] as? String else { return XCTFail("Couldn't get custom attirubte value as string") } + XCTAssertEqual("yes", aValue) + guard let bValue = array[1] as? Bool else { return XCTFail("Couldn't get custom attirubte value as bool") } + XCTAssertTrue(bValue) + guard let cValue = array[2] as? Int else { return XCTFail("Couldn't get custom attirubte value as int") } + XCTAssertEqual(1, cValue) + } +} diff --git a/test/types/User.swift b/test/types/User.swift new file mode 100644 index 0000000..bd47526 --- /dev/null +++ b/test/types/User.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import DescopeKit + +class TestUser: XCTestCase { + func testUserEncoding() throws { + let user = DescopeUser(userId: "userId", loginIds: ["loginId"], createdAt: Date(), email: "email", isVerifiedEmail: true, customAttributes: ["a": "yes"]) + let encodedUser = try JSONEncoder().encode(user) + let decodedUser = try JSONDecoder().decode(DescopeUser.self, from: encodedUser) + XCTAssertEqual(user, decodedUser) + XCTAssertTrue(decodedUser.isVerifiedEmail) + XCTAssertFalse(decodedUser.isVerifiedPhone) + guard let aValue = decodedUser.customAttributes["a"] as? String else { return XCTFail("Couldn't get custom attirubte value as String") } + XCTAssertEqual("yes", aValue) + } + +}