From 811d373ba9f57d3b7e68b872e2b963fa41a15c0b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 29 Aug 2024 16:05:42 +0100 Subject: [PATCH 1/3] Require that the realtime client be Sendable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is preparation for storing a realtime client inside the Chat SDK’s Sendable client types. The Sendable requirement added here will be satisfied by ably-cocoa’s ARTRealtime once [1] is resolved. [1] https://github.com/ably/ably-cocoa/issues/1962 --- Example/AblyChatExample/Mocks/MockRealtime.swift | 6 ++++-- Sources/AblyChat/ChatClient.swift | 8 +++++--- Tests/AblyChatTests/Mocks/MockRealtime.swift | 6 ++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Example/AblyChatExample/Mocks/MockRealtime.swift b/Example/AblyChatExample/Mocks/MockRealtime.swift index 8aa0d015..3eddf059 100644 --- a/Example/AblyChatExample/Mocks/MockRealtime.swift +++ b/Example/AblyChatExample/Mocks/MockRealtime.swift @@ -1,12 +1,14 @@ import Ably /// A mock implementation of `ARTRealtimeProtocol`. It only exists so that we can construct an instance of `DefaultChatClient` without needing to create a proper `ARTRealtime` instance (which we can’t yet do because we don’t have a method for inserting an API key into the example app). TODO remove this once we start building the example app -class MockRealtime: NSObject, ARTRealtimeProtocol { +final class MockRealtime: NSObject, ARTRealtimeProtocol, Sendable { var device: ARTLocalDevice { fatalError("Not implemented") } - var clientId: String? + var clientId: String? { + fatalError("Not implemented") + } required init(options _: ARTClientOptions) {} diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index aa919a4d..31048120 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -4,12 +4,14 @@ public protocol ChatClient: AnyObject, Sendable { var rooms: any Rooms { get } var connection: any Connection { get } var clientID: String { get } - var realtime: any ARTRealtimeProtocol { get } + var realtime: RealtimeClient { get } var clientOptions: ClientOptions { get } } +public typealias RealtimeClient = any(ARTRealtimeProtocol & Sendable) + public final class DefaultChatClient: ChatClient { - public init(realtime _: ARTRealtimeProtocol, clientOptions _: ClientOptions?) { + public init(realtime _: RealtimeClient, clientOptions _: ClientOptions?) { // This one doesn’t do `fatalError`, so that I can call it in the example app } @@ -25,7 +27,7 @@ public final class DefaultChatClient: ChatClient { fatalError("Not yet implemented") } - public var realtime: any ARTRealtimeProtocol { + public var realtime: RealtimeClient { fatalError("Not yet implemented") } diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift index f49b06c7..d7f80771 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift @@ -2,12 +2,14 @@ import Ably import Foundation /// A mock implementation of `ARTRealtimeProtocol`. Copied from the class of the same name in the example app. We’ll figure out how to do mocking in tests properly in https://github.com/ably-labs/ably-chat-swift/issues/5. -class MockRealtime: NSObject, ARTRealtimeProtocol { +final class MockRealtime: NSObject, ARTRealtimeProtocol, Sendable { var device: ARTLocalDevice { fatalError("Not implemented") } - var clientId: String? + var clientId: String? { + fatalError("Not implemented") + } required init(options _: ARTClientOptions) {} From eb12e0741d37217d4ae66348a6c813e58176228c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 29 Aug 2024 15:03:09 +0100 Subject: [PATCH 2/3] Implement DefaultChatClient.rooms Part of #19. --- Example/AblyChatExample/ContentView.swift | 2 +- .../AblyChatExample/Mocks/MockRealtime.swift | 9 +++++ Sources/AblyChat/ChatClient.swift | 33 +++++++++---------- Sources/AblyChat/Rooms.swift | 21 ++++++++++++ Tests/AblyChatTests/AblyChatTests.swift | 8 ----- .../DefaultChatClientTests.swift | 27 +++++++++++++++ Tests/AblyChatTests/Mocks/MockRealtime.swift | 9 +++++ 7 files changed, 83 insertions(+), 26 deletions(-) delete mode 100644 Tests/AblyChatTests/AblyChatTests.swift create mode 100644 Tests/AblyChatTests/DefaultChatClientTests.swift diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 750c754d..a0de7edf 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -4,7 +4,7 @@ import SwiftUI struct ContentView: View { /// Just used to check that we can successfully import and use the AblyChat library. TODO remove this once we start building the library @State private var ablyChatClient = DefaultChatClient( - realtime: MockRealtime(key: ""), + realtime: MockRealtime.create(), clientOptions: ClientOptions() ) diff --git a/Example/AblyChatExample/Mocks/MockRealtime.swift b/Example/AblyChatExample/Mocks/MockRealtime.swift index 3eddf059..11951d3d 100644 --- a/Example/AblyChatExample/Mocks/MockRealtime.swift +++ b/Example/AblyChatExample/Mocks/MockRealtime.swift @@ -16,6 +16,15 @@ final class MockRealtime: NSObject, ARTRealtimeProtocol, Sendable { required init(token _: String) {} + /** + Creates an instance of MockRealtime. + + This exists to give a convenient way to create an instance, because `init` is marked as unavailable in `ARTRealtimeProtocol`. + */ + static func create() -> MockRealtime { + MockRealtime(key: "") + } + func time(_: @escaping ARTDateTimeCallback) { fatalError("Not implemented") } diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index 31048120..44ac63cf 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -10,28 +10,22 @@ public protocol ChatClient: AnyObject, Sendable { public typealias RealtimeClient = any(ARTRealtimeProtocol & Sendable) -public final class DefaultChatClient: ChatClient { - public init(realtime _: RealtimeClient, clientOptions _: ClientOptions?) { - // This one doesn’t do `fatalError`, so that I can call it in the example app +public actor DefaultChatClient: ChatClient { + public let realtime: RealtimeClient + public nonisolated let clientOptions: ClientOptions + public nonisolated let rooms: Rooms + + public init(realtime: RealtimeClient, clientOptions: ClientOptions?) { + self.realtime = realtime + self.clientOptions = clientOptions ?? .init() + rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions) } - public var rooms: any Rooms { + public nonisolated var connection: any Connection { fatalError("Not yet implemented") } - public var connection: any Connection { - fatalError("Not yet implemented") - } - - public var clientID: String { - fatalError("Not yet implemented") - } - - public var realtime: RealtimeClient { - fatalError("Not yet implemented") - } - - public var clientOptions: ClientOptions { + public nonisolated var clientID: String { fatalError("Not yet implemented") } } @@ -44,4 +38,9 @@ public struct ClientOptions: Sendable { self.logHandler = logHandler self.logLevel = logLevel } + + /// Used for comparing these instances in tests without having to make this Equatable, which I’m not yet sure makes sense (we’ll decide in https://github.com/ably-labs/ably-chat-swift/issues/10) + internal func isEqualForTestPurposes(_ other: ClientOptions) -> Bool { + logHandler === other.logHandler && logLevel == other.logLevel + } } diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift index f09478a4..390c721b 100644 --- a/Sources/AblyChat/Rooms.swift +++ b/Sources/AblyChat/Rooms.swift @@ -1,5 +1,26 @@ +import Ably + public protocol Rooms: AnyObject, Sendable { func get(roomID: String, options: RoomOptions) throws -> any Room func release(roomID: String) async throws var clientOptions: ClientOptions { get } } + +internal actor DefaultRooms: Rooms { + /// Exposed so that we can test it. + internal nonisolated let realtime: RealtimeClient + internal nonisolated let clientOptions: ClientOptions + + internal init(realtime: RealtimeClient, clientOptions: ClientOptions) { + self.realtime = realtime + self.clientOptions = clientOptions + } + + internal nonisolated func get(roomID _: String, options _: RoomOptions) throws -> any Room { + fatalError("Not yet implemented") + } + + internal func release(roomID _: String) async throws { + fatalError("Not yet implemented") + } +} diff --git a/Tests/AblyChatTests/AblyChatTests.swift b/Tests/AblyChatTests/AblyChatTests.swift deleted file mode 100644 index c8c5c0bc..00000000 --- a/Tests/AblyChatTests/AblyChatTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -@testable import AblyChat -import XCTest - -final class AblyChatTests: XCTestCase { - func testExample() throws { - XCTAssertNoThrow(DefaultChatClient(realtime: MockRealtime(key: ""), clientOptions: ClientOptions())) - } -} diff --git a/Tests/AblyChatTests/DefaultChatClientTests.swift b/Tests/AblyChatTests/DefaultChatClientTests.swift new file mode 100644 index 00000000..9798d50d --- /dev/null +++ b/Tests/AblyChatTests/DefaultChatClientTests.swift @@ -0,0 +1,27 @@ +@testable import AblyChat +import XCTest + +class DefaultChatClientTests: XCTestCase { + func test_init_withoutClientOptions() { + // Given: An instance of DefaultChatClient is created with nil clientOptions + let client = DefaultChatClient(realtime: MockRealtime.create(), clientOptions: nil) + + // Then: It uses the default client options + let defaultOptions = ClientOptions() + XCTAssertTrue(client.clientOptions.isEqualForTestPurposes(defaultOptions)) + } + + func test_rooms() throws { + // Given: An instance of DefaultChatClient + let realtime = MockRealtime.create() + let options = ClientOptions() + let client = DefaultChatClient(realtime: realtime, clientOptions: options) + + // Then: Its `rooms` property returns an instance of DefaultRooms with the same realtime client and client options + let rooms = client.rooms + + let defaultRooms = try XCTUnwrap(rooms as? DefaultRooms) + XCTAssertIdentical(defaultRooms.realtime, realtime) + XCTAssertTrue(defaultRooms.clientOptions.isEqualForTestPurposes(options)) + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift index d7f80771..d0f8c4a9 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift @@ -17,6 +17,15 @@ final class MockRealtime: NSObject, ARTRealtimeProtocol, Sendable { required init(token _: String) {} + /** + Creates an instance of MockRealtime. + + This exists to give a convenient way to create an instance, because `init` is marked as unavailable in `ARTRealtimeProtocol`. + */ + static func create() -> MockRealtime { + MockRealtime(key: "") + } + func time(_: @escaping ARTDateTimeCallback) { fatalError("Not implemented") } From 7d6acdec45ca5d91f584fc3e3f180dc2d2834ec2 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 29 Aug 2024 10:22:00 +0100 Subject: [PATCH 3/3] Implement the ability to fetch a room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #19. References to spec are based on [1] at commit aa7455d. I’ve decided to, for now, throw ably-cocoa’s ARTErrorInfo for consistency with JS; created #32 to revisit this later. We have decided (see [2]) that we’re going to try using actors as our mechanism for concurrency-safe management of mutable state. We accept that this will lead to more of the public API needing to be annotated as `async` (as has happened to Rooms.get here), which in some cases might lead to weird-looking API, and have chosen to accept this compromise in order to get the safety checking offered to us by the compiler, and because of developers’ aversion to writing "@unchecked Sendable". We might not have needed to make this compromise if we had access to Swift 6 / iOS 18’s Mutex type, which allows for synchronous management of mutable state in a way that the compiler is happy with. But, none of the decisions here need to be final; we can see how we feel about the API as it evolves and as our knowledge of the language grows. [1] https://github.com/ably/specification/pull/200 [2] https://github.com/ably-labs/ably-chat-swift/pull/33#discussion_r1736054462 --- Sources/AblyChat/Errors.swift | 67 +++++++++++++++++++++ Sources/AblyChat/Room.swift | 48 +++++++++++++++ Sources/AblyChat/RoomOptions.swift | 10 +-- Sources/AblyChat/Rooms.swift | 22 ++++++- Tests/AblyChatTests/DefaultRoomsTests.swift | 64 ++++++++++++++++++++ Tests/AblyChatTests/Helpers/Helpers.swift | 15 +++++ 6 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 Sources/AblyChat/Errors.swift create mode 100644 Tests/AblyChatTests/DefaultRoomsTests.swift create mode 100644 Tests/AblyChatTests/Helpers/Helpers.swift diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift new file mode 100644 index 00000000..8a3070cf --- /dev/null +++ b/Sources/AblyChat/Errors.swift @@ -0,0 +1,67 @@ +import Ably + +/** + The error domain used for the ``Ably.ARTErrorInfo`` error instances thrown by the Ably Chat SDK. + + See ``ErrorCode`` for the possible ``ARTErrorInfo.code`` values. + */ +public let errorDomain = "AblyChatErrorDomain" + +/** + The error codes for errors in the ``errorDomain`` error domain. + */ +public enum ErrorCode: Int { + /// ``Rooms.get(roomID:options:)`` was called with a different set of room options than was used on a previous call. You must first release the existing room instance using ``Rooms.release(roomID:)``. + /// + /// TODO this code is a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 + case inconsistentRoomOptions = 1 + + /// The ``ARTErrorInfo.statusCode`` that should be returned for this error. + internal var statusCode: Int { + // TODO: These are currently a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 + switch self { + case .inconsistentRoomOptions: + 400 + } + } +} + +/** + The errors thrown by the Chat SDK. + + This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription`. + */ +internal enum ChatError { + case inconsistentRoomOptions(requested: RoomOptions, existing: RoomOptions) + + /// The ``ARTErrorInfo.code`` that should be returned for this error. + internal var code: ErrorCode { + switch self { + case .inconsistentRoomOptions: + .inconsistentRoomOptions + } + } + + /// The ``ARTErrorInfo.localizedDescription`` that should be returned for this error. + internal var localizedDescription: String { + switch self { + case let .inconsistentRoomOptions(requested, existing): + "Rooms.get(roomID:options:) was called with a different set of room options than was used on a previous call. You must first release the existing room instance using Rooms.release(roomID:). Requested options: \(requested), existing options: \(existing)" + } + } +} + +internal extension ARTErrorInfo { + convenience init(chatError: ChatError) { + var userInfo: [String: Any] = [:] + // TODO: copied and pasted from implementation of -[ARTErrorInfo createWithCode:status:message:requestId:] because there’s no way to pass domain; revisit in https://github.com/ably-labs/ably-chat-swift/issues/32. Also the ARTErrorInfoStatusCode variable in ably-cocoa is not public. + userInfo["ARTErrorInfoStatusCode"] = chatError.code.statusCode + userInfo[NSLocalizedDescriptionKey] = chatError.localizedDescription + + self.init( + domain: errorDomain, + code: chatError.code.rawValue, + userInfo: userInfo + ) + } +} diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 286821ad..5e55d170 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -1,3 +1,5 @@ +import Ably + public protocol Room: AnyObject, Sendable { var roomID: String { get } var messages: any Messages { get } @@ -14,3 +16,49 @@ public protocol Room: AnyObject, Sendable { func detach() async throws var options: RoomOptions { get } } + +internal actor DefaultRoom: Room { + internal nonisolated let roomID: String + internal nonisolated let options: RoomOptions + + // Exposed for testing. + internal nonisolated let realtime: RealtimeClient + + internal init(realtime: RealtimeClient, roomID: String, options: RoomOptions) { + self.realtime = realtime + self.roomID = roomID + self.options = options + } + + public nonisolated var messages: any Messages { + fatalError("Not yet implemented") + } + + public nonisolated var presence: any Presence { + fatalError("Not yet implemented") + } + + public nonisolated var reactions: any RoomReactions { + fatalError("Not yet implemented") + } + + public nonisolated var typing: any Typing { + fatalError("Not yet implemented") + } + + public nonisolated var occupancy: any Occupancy { + fatalError("Not yet implemented") + } + + public nonisolated var status: any RoomStatus { + fatalError("Not yet implemented") + } + + public func attach() async throws { + fatalError("Not yet implemented") + } + + public func detach() async throws { + fatalError("Not yet implemented") + } +} diff --git a/Sources/AblyChat/RoomOptions.swift b/Sources/AblyChat/RoomOptions.swift index a927bfc8..ca7b5973 100644 --- a/Sources/AblyChat/RoomOptions.swift +++ b/Sources/AblyChat/RoomOptions.swift @@ -1,6 +1,6 @@ import Foundation -public struct RoomOptions: Sendable { +public struct RoomOptions: Sendable, Equatable { public var presence: PresenceOptions? public var typing: TypingOptions? public var reactions: RoomReactionsOptions? @@ -14,7 +14,7 @@ public struct RoomOptions: Sendable { } } -public struct PresenceOptions: Sendable { +public struct PresenceOptions: Sendable, Equatable { public var enter = true public var subscribe = true @@ -24,7 +24,7 @@ public struct PresenceOptions: Sendable { } } -public struct TypingOptions: Sendable { +public struct TypingOptions: Sendable, Equatable { public var timeout: TimeInterval = 10 public init(timeout: TimeInterval = 10) { @@ -32,10 +32,10 @@ public struct TypingOptions: Sendable { } } -public struct RoomReactionsOptions: Sendable { +public struct RoomReactionsOptions: Sendable, Equatable { public init() {} } -public struct OccupancyOptions: Sendable { +public struct OccupancyOptions: Sendable, Equatable { public init() {} } diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift index 390c721b..03b32d9f 100644 --- a/Sources/AblyChat/Rooms.swift +++ b/Sources/AblyChat/Rooms.swift @@ -1,7 +1,7 @@ import Ably public protocol Rooms: AnyObject, Sendable { - func get(roomID: String, options: RoomOptions) throws -> any Room + func get(roomID: String, options: RoomOptions) async throws -> any Room func release(roomID: String) async throws var clientOptions: ClientOptions { get } } @@ -11,13 +11,29 @@ internal actor DefaultRooms: Rooms { internal nonisolated let realtime: RealtimeClient internal nonisolated let clientOptions: ClientOptions + /// The set of rooms, keyed by room ID. + private var rooms: [String: DefaultRoom] = [:] + internal init(realtime: RealtimeClient, clientOptions: ClientOptions) { self.realtime = realtime self.clientOptions = clientOptions } - internal nonisolated func get(roomID _: String, options _: RoomOptions) throws -> any Room { - fatalError("Not yet implemented") + internal func get(roomID: String, options: RoomOptions) throws -> any Room { + // CHA-RC1b + if let existingRoom = rooms[roomID] { + if existingRoom.options != options { + throw ARTErrorInfo( + chatError: .inconsistentRoomOptions(requested: options, existing: existingRoom.options) + ) + } + + return existingRoom + } else { + let room = DefaultRoom(realtime: realtime, roomID: roomID, options: options) + rooms[roomID] = room + return room + } } internal func release(roomID _: String) async throws { diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift new file mode 100644 index 00000000..31fc83dd --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -0,0 +1,64 @@ +@testable import AblyChat +import XCTest + +class DefaultRoomsTests: XCTestCase { + // @spec CHA-RC1a + func test_get_returnsRoomWithGivenID() async throws { + // Given: an instance of DefaultRooms + let realtime = MockRealtime.create() + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init()) + + // When: get(roomID:options:) is called + let roomID = "basketball" + let options = RoomOptions() + let room = try await rooms.get(roomID: roomID, options: options) + + // Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options + let defaultRoom = try XCTUnwrap(room as? DefaultRoom) + XCTAssertIdentical(defaultRoom.realtime, realtime) + XCTAssertEqual(defaultRoom.roomID, roomID) + XCTAssertEqual(defaultRoom.options, options) + } + + // @spec CHA-RC1b + func test_get_returnsExistingRoomWithGivenID() async throws { + // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID + let realtime = MockRealtime.create() + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init()) + + let roomID = "basketball" + let options = RoomOptions() + let firstRoom = try await rooms.get(roomID: roomID, options: options) + + // When: get(roomID:options:) is called with the same room ID + let secondRoom = try await rooms.get(roomID: roomID, options: options) + + // Then: It returns the same room object + XCTAssertIdentical(secondRoom, firstRoom) + } + + // @spec CHA-RC1c + func test_get_throwsErrorWhenOptionsDoNotMatch() async throws { + // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options + let realtime = MockRealtime.create() + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init()) + + let roomID = "basketball" + let options = RoomOptions() + _ = try await rooms.get(roomID: roomID, options: options) + + // When: get(roomID:options:) is called with the same ID but different options + let differentOptions = RoomOptions(presence: .init(subscribe: false)) + + let caughtError: Error? + do { + _ = try await rooms.get(roomID: roomID, options: differentOptions) + caughtError = nil + } catch { + caughtError = error + } + + // Then: It throws an inconsistentRoomOptions error + try assertIsChatError(caughtError, withCode: .inconsistentRoomOptions) + } +} diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift new file mode 100644 index 00000000..669c23ed --- /dev/null +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -0,0 +1,15 @@ +import Ably +@testable import AblyChat +import XCTest + +/** + Asserts that a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code. + */ +func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, file: StaticString = #filePath, line: UInt = #line) throws { + let error = try XCTUnwrap(maybeError, "Expected an error", file: file, line: line) + let ablyError = try XCTUnwrap(error as? ARTErrorInfo, "Expected an ARTErrorInfo", file: file, line: line) + + XCTAssertEqual(ablyError.domain, AblyChat.errorDomain as String, file: file, line: line) + XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line) + XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line) +}