Skip to content
Draft
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
45 changes: 44 additions & 1 deletion Sources/ClerkKit/Domains/User/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,52 @@ extension User {
///
/// For example, if you want to use the `update(.init(firstName:))` method, you must enable the Name setting.
/// It can be found in the Email, phone, username > Personal information section in the Clerk Dashboard.
///
/// - Important: Passing `unsafeMetadata` through ``User/UpdateParams`` is deprecated.
/// When `unsafeMetadata` is provided with other profile fields, the SDK sends two
/// requests: one to update profile fields and one to update metadata. These requests
/// are not atomic; if the metadata request fails, profile changes may already be saved.
/// Prefer ``updateMetadata(unsafeMetadata:)`` for metadata updates.
@discardableResult @MainActor
public func update(_ params: User.UpdateParams) async throws -> User {
try await userService.update(params: params)
let service = userService

guard let desiredUnsafeMetadata = params.deprecatedUnsafeMetadata else {
return try await service.update(params: params)
}

let paramsWithoutUnsafeMetadata = params.withoutUnsafeMetadata
let hasProfileUpdates = paramsWithoutUnsafeMetadata.hasAnyField
let userAfterProfileUpdate: User =
if hasProfileUpdates {
try await service.update(params: paramsWithoutUnsafeMetadata)
} else {
try await service.reload()
}

let currentUnsafeMetadata = userAfterProfileUpdate.unsafeMetadata ?? .object([:])
let patch = currentUnsafeMetadata.mergePatch(against: desiredUnsafeMetadata)
if case let .object(patchObject) = patch, patchObject.isEmpty {
return userAfterProfileUpdate
}

return try await service.updateMetadata(params: .init(unsafeMetadata: patch))
}

/// Updates the user's unsafe metadata.
///
/// Values are merged into the existing unsafe metadata. Set a key to `JSON.null` to remove it.
@discardableResult @MainActor
public func updateMetadata(_ params: User.UpdateMetadataParams) async throws -> User {
try await userService.updateMetadata(params: params)
}

/// Updates the user's unsafe metadata.
///
/// Values are merged into the existing unsafe metadata. Set a key to `JSON.null` to remove it.
@discardableResult @MainActor
public func updateMetadata(unsafeMetadata: JSON) async throws -> User {
try await updateMetadata(.init(unsafeMetadata: unsafeMetadata))
}

/// Generates a fresh new set of backup codes for the user. Every time the method is called, it will replace the previously generated backup codes.
Expand Down
13 changes: 13 additions & 0 deletions Sources/ClerkKit/Domains/User/UserService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
protocol UserServiceProtocol: Sendable {
@MainActor func reload() async throws -> User
@MainActor func update(params: User.UpdateParams) async throws -> User
@MainActor func updateMetadata(params: User.UpdateMetadataParams) async throws -> User
@MainActor func createBackupCodes() async throws -> BackupCodeResource
@MainActor func createEmailAddress(emailAddress: String) async throws -> EmailAddress
@MainActor func createPhoneNumber(phoneNumber: String) async throws -> PhoneNumber
Expand Down Expand Up @@ -78,6 +79,18 @@ final class UserService: UserServiceProtocol {
return try await apiClient.send(request).value.response
}

@MainActor
func updateMetadata(params: User.UpdateMetadataParams) async throws -> User {
let request = Request<ClientResponse<User>>(
path: "/v1/me/metadata",
method: .patch,
query: [("_clerk_session_id", value: Clerk.shared.session?.id)],
body: params
)

return try await apiClient.send(request).value.response
}

@MainActor
func createBackupCodes() async throws -> BackupCodeResource {
let request = Request<ClientResponse<BackupCodeResource>>(
Expand Down
80 changes: 73 additions & 7 deletions Sources/ClerkKit/Domains/User/UserUpdateParams.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,42 @@
// Clerk
//

import Foundation

extension User {
public struct UpdateParams: Encodable, Sendable {
public init(
username: String? = nil,
firstName: String? = nil,
lastName: String? = nil,
primaryEmailAddressId: String? = nil,
primaryPhoneNumberId: String? = nil
) {
self.username = username
self.firstName = firstName
self.lastName = lastName
self.primaryEmailAddressId = primaryEmailAddressId
self.primaryPhoneNumberId = primaryPhoneNumberId
}

@available(
*, deprecated,
message: "Use User.updateMetadata(unsafeMetadata:) for metadata updates. Passing unsafeMetadata to update(_:) will be removed in a future major version."
)
public init(
username: String? = nil,
firstName: String? = nil,
lastName: String? = nil,
primaryEmailAddressId: String? = nil,
primaryPhoneNumberId: String? = nil,
unsafeMetadata: JSON? = nil
unsafeMetadata: JSON?
) {
self.username = username
self.firstName = firstName
self.lastName = lastName
self.primaryEmailAddressId = primaryEmailAddressId
self.primaryPhoneNumberId = primaryPhoneNumberId
self.unsafeMetadata = unsafeMetadata
deprecatedUnsafeMetadata = unsafeMetadata
}

/// The user's username.
Expand All @@ -36,10 +56,56 @@ extension User {
/// The unique identifier for the PhoneNumber that the user has set as primary.
public var primaryPhoneNumberId: String?

/**
Metadata that can be read and set from the Frontend API. One common use case for this attribute is to implement custom fields that will be attached to the User object.
Please note that there is also an unsafeMetadata attribute in the SignUp object. The value of that field will be automatically copied to the user's unsafe metadata once the sign up is complete.
*/
public var unsafeMetadata: JSON?
var deprecatedUnsafeMetadata: JSON?

/// Metadata that can be read and set from the Frontend API. One common use case
/// for this attribute is to implement custom fields that will be attached to the
/// User object.
@available(
*, deprecated,
message: "Use User.updateMetadata(unsafeMetadata:) for metadata updates. Passing unsafeMetadata to update(_:) will be removed in a future major version."
)
public var unsafeMetadata: JSON? {
get { deprecatedUnsafeMetadata }
set { deprecatedUnsafeMetadata = newValue }
}

private enum CodingKeys: String, CodingKey {
case username
case firstName
case lastName
case primaryEmailAddressId
case primaryPhoneNumberId
case deprecatedUnsafeMetadata = "unsafeMetadata"
}
}

public struct UpdateMetadataParams: Encodable, Sendable {
public init(unsafeMetadata: JSON) {
self.unsafeMetadata = unsafeMetadata
}

/// The unsafe metadata patch to merge into the current value.
public var unsafeMetadata: JSON
}
}

extension User.UpdateParams {
var hasAnyField: Bool {
var copy = self
copy.deprecatedUnsafeMetadata = nil

guard let data = try? JSONEncoder.clerkEncoder.encode(copy),
let encoded = try? JSONDecoder.clerkDecoder.decode(JSON.self, from: data),
case let .object(object) = encoded
else { return false }

return !object.isEmpty
}

var withoutUnsafeMetadata: Self {
var copy = self
copy.deprecatedUnsafeMetadata = nil
return copy
}
}
14 changes: 14 additions & 0 deletions Sources/ClerkKit/Mocks/MockServices/MockUserService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ package final class MockUserService: UserServiceProtocol {
/// Custom handler for the `update(params:)` method.
package nonisolated(unsafe) var updateHandler: ((User.UpdateParams) async throws -> User)?

/// Custom handler for the `updateMetadata(params:)` method.
package nonisolated(unsafe) var updateMetadataHandler: ((User.UpdateMetadataParams) async throws -> User)?

/// Custom handler for the `createBackupCodes()` method.
package nonisolated(unsafe) var createBackupCodesHandler: (() async throws -> BackupCodeResource)?

Expand Down Expand Up @@ -91,6 +94,7 @@ package final class MockUserService: UserServiceProtocol {
/// - getSessions: Optional implementation of the `getSessions(user:)` method.
/// - reload: Optional implementation of the `reload()` method.
/// - update: Optional implementation of the `update(params:)` method.
/// - updateMetadata: Optional implementation of the `updateMetadata(params:)` method.
/// - createBackupCodes: Optional implementation of the `createBackupCodes()` method.
/// - createEmailAddress: Optional implementation of the `createEmailAddress(emailAddress:)` method.
/// - createPhoneNumber: Optional implementation of the `createPhoneNumber(phoneNumber:)` method.
Expand Down Expand Up @@ -132,6 +136,7 @@ package final class MockUserService: UserServiceProtocol {
getSessions: ((User) async throws -> [Session])? = nil,
reload: (() async throws -> User)? = nil,
update: ((User.UpdateParams) async throws -> User)? = nil,
updateMetadata: ((User.UpdateMetadataParams) async throws -> User)? = nil,
createBackupCodes: (() async throws -> BackupCodeResource)? = nil,
createEmailAddress: ((String) async throws -> EmailAddress)? = nil,
createPhoneNumber: ((String) async throws -> PhoneNumber)? = nil,
Expand All @@ -152,6 +157,7 @@ package final class MockUserService: UserServiceProtocol {
getSessionsHandler = getSessions
reloadHandler = reload
updateHandler = update
updateMetadataHandler = updateMetadata
createBackupCodesHandler = createBackupCodes
createEmailAddressHandler = createEmailAddress
createPhoneNumberHandler = createPhoneNumber
Expand Down Expand Up @@ -195,6 +201,14 @@ package final class MockUserService: UserServiceProtocol {
return .mock
}

@MainActor
package func updateMetadata(params: User.UpdateMetadataParams) async throws -> User {
if let handler = updateMetadataHandler {
return try await handler(params)
}
return .mock
}

@MainActor
package func createBackupCodes() async throws -> BackupCodeResource {
if let handler = createBackupCodesHandler {
Expand Down
50 changes: 50 additions & 0 deletions Sources/ClerkKit/Utils/JSON+MergePatch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// JSON+MergePatch.swift
// Clerk
//

extension JSON {
func mergePatch(against desired: JSON) -> JSON {
guard case let .object(desiredObject) = desired else {
return desired
}

guard case let .object(currentObject) = self else {
return desired
}

return .object(Self.mergePatch(from: currentObject, to: desiredObject))
}

private static func mergePatch(
from current: [String: JSON],
to desired: [String: JSON]
) -> [String: JSON] {
var patch: [String: JSON] = [:]

for (key, desiredValue) in desired {
guard let currentValue = current[key] else {
patch[key] = desiredValue
continue
}

if currentValue == desiredValue {
continue
}

if case let .object(currentObject) = currentValue,
case let .object(desiredObject) = desiredValue
{
patch[key] = .object(mergePatch(from: currentObject, to: desiredObject))
} else {
patch[key] = desiredValue
}
}

for key in current.keys where desired[key] == nil {
patch[key] = .null
}

return patch
}
}
4 changes: 2 additions & 2 deletions Sources/ClerkKit/Utils/Version.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
import Foundation

extension Clerk {
public nonisolated static let sdkVersion: String = "1.1.3"
nonisolated static let apiVersion: String = "2025-11-10"
public nonisolated static let sdkVersion: String = "1.2.0"
nonisolated static let apiVersion: String = "2026-05-12"
}
Loading
Loading