Skip to content
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

Add user custom attributes #53

Merged
merged 4 commits into from
Jan 7, 2024
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
41 changes: 28 additions & 13 deletions src/internal/http/DescopeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ class DescopeClient: HTTPClient {
var user: UserResponse?
var firstSeen: Bool

mutating func setValues(from response: HTTPURLResponse) {
mutating func setValues(from data: Data, response: HTTPURLResponse) throws {
guard let url = response.url, let fields = response.allHeaderFields as? [String: String] else { return }
let cookies = HTTPCookie.cookies(withResponseHeaderFields: fields, for: url)
for cookie in cookies where !cookie.value.isEmpty {
Expand All @@ -421,18 +421,33 @@ class DescopeClient: HTTPClient {
}

struct UserResponse: JSONResponse {
var userId: String
var loginIds: [String]
var name: String?
var picture: String?
var email: String?
var verifiedEmail: Bool = false
var phone: String?
var verifiedPhone: Bool = false
var createdTime: Int
var givenName: String?
var middleName: String?
var familyName: String?
// use a nested struct so we can let the compiler generate decoding for most members
struct UserFields: Decodable {
var userId: String
var loginIds: [String]
var createdTime: Int
var email: String?
var verifiedEmail: Bool?
var phone: String?
var verifiedPhone: Bool?
var name: String?
var givenName: String?
var middleName: String?
var familyName: String?
var picture: String?
}

var userFields: UserFields
var customAttributes: [String: Any] = [:]

init(from decoder: Decoder) throws {
userFields = try UserFields(from: decoder)
}

mutating func setValues(from data: Data, response: HTTPURLResponse) throws {
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
customAttributes = json["customAttributes"] as? [String: Any] ?? [:]
}
}

struct MaskedAddress: JSONResponse {
Expand Down
6 changes: 3 additions & 3 deletions src/internal/http/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,19 +123,19 @@ class HTTPClient {
// JSON Response

protocol JSONResponse: Decodable {
mutating func setValues(from response: HTTPURLResponse)
mutating func setValues(from data: Data, response: HTTPURLResponse) throws
}

extension JSONResponse {
mutating func setValues(from response: HTTPURLResponse) {
mutating func setValues(from data: Data, response: HTTPURLResponse) throws {
// nothing by default
}
}

private func decodeJSON<T: JSONResponse>(data: Data, response: HTTPURLResponse) throws -> T {
do {
var val = try JSONDecoder().decode(T.self, from: data)
val.setValues(from: response)
try val.setValues(from: data, response: response)
return val
} catch {
throw DescopeError.decodeError.with(cause: error)
Expand Down
4 changes: 2 additions & 2 deletions src/internal/others/Internal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ extension Data {
}
self.init(base64Encoded: str, options: options)
}

func base64URLEncodedString(options: Base64EncodingOptions = []) -> String {
return base64EncodedString(options: options)
.replacingOccurrences(of: "+", with: "-")
Expand Down Expand Up @@ -73,7 +73,7 @@ class AuthorizationDelegate: NSObject, ASAuthorizationControllerDelegate {
completion?(.success(authorization))
completion = nil
}

func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
completion?(.failure(error))
completion = nil
Expand Down
36 changes: 22 additions & 14 deletions src/internal/routes/Shared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,42 @@ extension Route {
}
}

extension DescopeClient.UserResponse {
extension DescopeClient.UserResponse.UserFields {
func convert() -> DescopeUser {
let createdAt = Date(timeIntervalSince1970: TimeInterval(createdTime))
var me = DescopeUser(userId: userId, loginIds: loginIds, createdAt: createdAt, isVerifiedEmail: false, isVerifiedPhone: false)
var user = DescopeUser(userId: userId, loginIds: loginIds, createdAt: createdAt, isVerifiedEmail: false, isVerifiedPhone: false)
if let name, !name.isEmpty {
me.name = name
}
if let picture, let url = URL(string: picture) {
me.picture = url
user.name = name
}
if let email, !email.isEmpty {
me.email = email
me.isVerifiedEmail = verifiedEmail
user.email = email
user.isVerifiedEmail = verifiedEmail ?? false
}
if let phone, !phone.isEmpty {
me.phone = phone
me.isVerifiedPhone = verifiedPhone
user.phone = phone
user.isVerifiedPhone = verifiedPhone ?? false
}
if let givenName, !givenName.isEmpty {
me.givenName = givenName
user.givenName = givenName
}
if let middleName, !middleName.isEmpty {
me.middleName = middleName
user.middleName = middleName
}
if let familyName, !familyName.isEmpty {
me.familyName = familyName
user.familyName = familyName
}
if let picture, let url = URL(string: picture) {
Dismissed Show dismissed Hide dismissed
user.picture = url
}
return me
return user
}
}

extension DescopeClient.UserResponse {
func convert() -> DescopeUser {
var user = userFields.convert()
user.customAttributes = customAttributes
return user
}
}

Expand Down
90 changes: 79 additions & 11 deletions src/types/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import Foundation
/// In the code above we check that there's an active ``DescopeSession`` in the shared
/// session manager. If so we ask the Descope server for the latest user details and
/// then update the ``DescopeSession`` with them.
public struct DescopeUser: Codable, Equatable {
public struct DescopeUser {

/// The unique identifier for the user in Descope.
///
/// This value never changes after the user is created, and it always matches
Expand All @@ -45,12 +45,6 @@ public struct DescopeUser: Codable, Equatable {
/// The time at which the user was created in Descope.
public var createdAt: Date

/// The user's full name.
public var name: String?

/// The user's profile picture.
public var picture: URL?

/// The user's email address.
///
/// If this is non-nil and the ``isVerifiedEmail`` flag is `true` then this email address
Expand All @@ -71,6 +65,9 @@ public struct DescopeUser: Codable, Equatable {
/// for this user. If ``phone`` is `nil` then this is always `false`.
public var isVerifiedPhone: Bool

/// The user's full name.
public var name: String?

/// The user's given name.
public var givenName: String?

Expand All @@ -80,19 +77,27 @@ 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) {
/// The user's profile picture.
public var picture: URL?

/// A mapping of any custom attributes associated with this user. The custom attributes
/// are managed via the Descope console.
public var customAttributes: [String: Any]

public init(userId: String, loginIds: [String], createdAt: Date, email: String? = nil, isVerifiedEmail: Bool = false, phone: String? = nil, isVerifiedPhone: Bool = false, name: String? = nil, givenName: String? = nil, middleName: String? = nil, familyName: String? = nil, picture: URL? = nil, customAttributes: [String: Any] = [:]) {
self.userId = userId
self.loginIds = loginIds
self.createdAt = createdAt
self.name = name
self.picture = picture
self.email = email
self.isVerifiedEmail = isVerifiedEmail
self.phone = phone
self.isVerifiedPhone = isVerifiedPhone
self.name = name
self.givenName = givenName
self.middleName = middleName
self.familyName = familyName
self.picture = picture
self.customAttributes = customAttributes
}
}

Expand All @@ -111,3 +116,66 @@ extension DescopeUser: CustomStringConvertible {
return "DescopeUser(id: \"\(userId)\"\(extras))"
}
}

extension DescopeUser: Equatable {
public static func == (lhs: DescopeUser, rhs: DescopeUser) -> Bool {
let lhca = lhs.customAttributes as NSDictionary
let rhca = rhs.customAttributes as NSDictionary
return lhs.userId == rhs.userId && lhs.loginIds == rhs.loginIds &&
lhs.createdAt == rhs.createdAt && lhs.picture == rhs.picture &&
lhs.email == rhs.email && lhs.isVerifiedEmail == rhs.isVerifiedEmail &&
lhs.phone == rhs.phone && lhs.isVerifiedPhone == rhs.isVerifiedPhone &&
lhs.name == rhs.name && lhs.givenName == rhs.givenName &&
lhs.middleName == rhs.middleName && lhs.familyName == rhs.familyName &&
lhca.isEqual(to: rhca)
}
}

// Unfortunately we can't rely on the compiler for automatic conformance to Codable because
// the customAttributes dictionary isn't serializable
extension DescopeUser: Codable {
enum CodingKeys: CodingKey {
case userId, loginIds, createdAt, email, isVerifiedEmail, phone, isVerifiedPhone, name, givenName, middleName, familyName, picture, 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([String].self, forKey: .loginIds)
createdAt = try values.decode(Date.self, forKey: .createdAt)
email = try values.decodeIfPresent(String.self, forKey: .email)
isVerifiedEmail = try values.decode(Bool.self, forKey: .isVerifiedEmail)
phone = try values.decodeIfPresent(String.self, forKey: .phone)
isVerifiedPhone = try values.decode(Bool.self, forKey: .isVerifiedPhone)
name = try values.decodeIfPresent(String.self, forKey: .name)
givenName = try values.decodeIfPresent(String.self, forKey: .givenName)
middleName = try values.decodeIfPresent(String.self, forKey: .middleName)
familyName = try values.decodeIfPresent(String.self, forKey: .familyName)
picture = try values.decodeIfPresent(URL.self, forKey: .picture)
if let value = try values.decodeIfPresent(String.self, forKey: .customAttributes), let json = try? JSONSerialization.jsonObject(with: Data(value.utf8)) {
customAttributes = json as? [String: Any] ?? [:]
} else {
customAttributes = [:]
}
}

public func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(userId, forKey: .userId)
try values.encode(loginIds, forKey: .loginIds)
try values.encode(createdAt, forKey: .createdAt)
try values.encodeIfPresent(email, forKey: .email)
try values.encode(isVerifiedEmail, forKey: .isVerifiedEmail)
try values.encodeIfPresent(phone, forKey: .phone)
try values.encode(isVerifiedPhone, forKey: .isVerifiedPhone)
try values.encodeIfPresent(name, forKey: .name)
try values.encodeIfPresent(givenName, forKey: .givenName)
try values.encodeIfPresent(middleName, forKey: .middleName)
try values.encodeIfPresent(familyName, forKey: .familyName)
try values.encodeIfPresent(picture, forKey: .picture)
// check before trying to serialize to prevent a runtime exception from being triggered
if JSONSerialization.isValidJSONObject(customAttributes), let data = try? JSONSerialization.data(withJSONObject: customAttributes), let value = String(bytes: data, encoding: .utf8) {
try values.encode(value, forKey: .customAttributes)
}
}
}
2 changes: 1 addition & 1 deletion test/http/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ private struct MockResponse: JSONResponse, Equatable {
static let json: [String: Any] = ["id": instance.id, "st": instance.st]
static let headers: [String: String] = ["hd": instance.hd!]

mutating func setValues(from response: HTTPURLResponse) {
mutating func setValues(from data: Data, response: HTTPURLResponse) throws {
guard let headers = response.allHeaderFields as? [String: String] else { return }
for (name, value) in headers where name == "hd" {
hd = value
Expand Down
10 changes: 10 additions & 0 deletions test/mocks/MockDescope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation
@testable import DescopeKit

extension DescopeSDK {
static func mock(projectId: String = "projId") -> DescopeSDK {
var config = DescopeConfig(projectId: projectId, logger: DescopeLogger())
config.networkClient = MockHTTP.networkClient
return DescopeSDK(config: config)
}
}
4 changes: 4 additions & 0 deletions test/mocks/MockHTTP.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ extension MockHTTP {
responses.append((statusCode, nil, nil, error, validator))
}

static func push(statusCode: Int = 200, body: String, headers: [String: String]? = nil, validator: RequestValidator? = nil) {
responses.append((statusCode, Data(body.utf8), headers, nil, validator))
}

static func push(statusCode: Int = 200, json: [String: Any], headers: [String: String]? = nil, validator: RequestValidator? = nil) {
guard let data = try? JSONSerialization.data(withJSONObject: json) else { preconditionFailure("Failed to serialize JSON") }
responses.append((statusCode, data, headers, nil, validator))
Expand Down
10 changes: 4 additions & 6 deletions test/routes/AccessKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,15 @@ private let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiYXIiLCJuYW1l

class TestAccessKey: XCTestCase {
func testTokenDecoding() async throws {
var config = DescopeConfig(projectId: "foo")
config.networkClient = MockHTTP.networkClient
let descope = DescopeSDK(config: config)

let descope = DescopeSDK.mock()

MockHTTP.push(json: ["sessionJwt": jwt]) { request in
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "Bearer foo:bar")
XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "Bearer projId:key")
XCTAssertEqual(request.httpBody, Data("{}".utf8))
}

let token = try await descope.accessKey.exchange(accessKey: "bar")
let token = try await descope.accessKey.exchange(accessKey: "key")
XCTAssertEqual(jwt, token.jwt)
XCTAssertEqual("bar", token.id)
XCTAssertEqual("foo", token.projectId)
Expand Down
Loading