Fixed a breaking issue and a few other minor bugs

Fixed a busted version comparison
Fixed an issue where the config dump population wasn't setting the 'created' timestamp for contacts
Fixed an issue where the 'SyncPushTokensJob' could run logic on the wrong thread
Fixed a bug where the 'scroll to bottom' button wouldn't initial be visible in some cases
Fixed a bug where the 'scroll to bottom' button would fade out when there were subsequent pages
Fixed a bug where an open group image might not get downloaded in some cases
Fixed an issue where we would incorrectly append a wildcard character to the end of a search term that ended in a quotation mark
Finished refactoring the OpenGroupAPI to use PreparedSendData
pull/751/head
Morgan Pretty 1 year ago
parent 53a5db0ea5
commit d8ae9669c8

@ -6417,7 +6417,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 409; CURRENT_PROJECT_VERSION = 410;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6489,7 +6489,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 409; CURRENT_PROJECT_VERSION = 410;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
@ -6554,7 +6554,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 409; CURRENT_PROJECT_VERSION = 410;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6628,7 +6628,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 409; CURRENT_PROJECT_VERSION = 410;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
@ -7536,7 +7536,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 409; CURRENT_PROJECT_VERSION = 410;
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -7607,7 +7607,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 409; CURRENT_PROJECT_VERSION = 410;
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

@ -2088,21 +2088,20 @@ extension ConversationVC:
cancelStyle: .alert_text, cancelStyle: .alert_text,
onConfirm: { [weak self] _ in onConfirm: { [weak self] _ in
Storage.shared Storage.shared
.readPublisherFlatMap { db -> AnyPublisher<Void, Error> in .readPublisher { db in
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
throw StorageError.objectNotFound throw StorageError.objectNotFound
} }
return OpenGroupAPI return try OpenGroupAPI
.userBanAndDeleteAllMessages( .preparedUserBanAndDeleteAllMessages(
db, db,
sessionId: cellViewModel.authorId, sessionId: cellViewModel.authorId,
in: openGroup.roomToken, in: openGroup.roomToken,
on: openGroup.server on: openGroup.server
) )
.map { _ in () }
.eraseToAnyPublisher()
} }
.flatMap { OpenGroupAPI.send(data: $0) }
.subscribe(on: DispatchQueue.global(qos: .userInitiated)) .subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sinkUntilComplete( .sinkUntilComplete(

@ -1632,10 +1632,19 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
unreadCountView.isHidden = (unreadCount == 0) unreadCountView.isHidden = (unreadCount == 0)
} }
public func updateScrollToBottom() { public func updateScrollToBottom(force: Bool = false) {
// The initial scroll can trigger this logic but we already mark the initially focused message // Don't update the scroll button until we have actually setup the initial scroll position to avoid
// as read so don't run the below until the user actually scrolls after the initial layout // any odd flickering or incorrect appearance
guard self.didFinishInitialLayout else { return } guard self.didFinishInitialLayout || force else { return }
// If we have a 'loadNewer' item in the interaction data then there are subsequent pages and the
// 'scrollToBottom' actions should always be visible to allow the user to jump to the bottom (without
// this the button will fade out as the user gets close to the bottom of the current page)
guard !self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) else {
self.scrollButton.alpha = 1
self.unreadCountView.alpha = 1
return
}
// Calculate the target opacity for the scroll button // Calculate the target opacity for the scroll button
let contentOffsetY: CGFloat = tableView.contentOffset.y let contentOffsetY: CGFloat = tableView.contentOffset.y
@ -1848,17 +1857,13 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
animated: (self.didFinishInitialLayout && isAnimated) animated: (self.didFinishInitialLayout && isAnimated)
) )
// Need to explicitly call 'scrollViewDidScroll' here as it won't get triggered
// by 'scrollToRow' if a scroll doesn't occur (eg. if there is less than 1 screen
// of messages)
self.scrollViewDidScroll(self.tableView)
// If we haven't finished the initial layout then we want to delay the highlight/markRead slightly // If we haven't finished the initial layout then we want to delay the highlight/markRead slightly
// so it doesn't look buggy with the push transition and we know for sure the correct visible cells // so it doesn't look buggy with the push transition and we know for sure the correct visible cells
// have been loaded // have been loaded
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in
self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo) self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo)
self?.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour) self?.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour)
self?.updateScrollToBottom(force: true)
} }
self.shouldHighlightNextScrollToInteraction = false self.shouldHighlightNextScrollToInteraction = false

@ -193,6 +193,7 @@ extension SyncPushTokensJob {
return Fail(error: error) return Fail(error: error)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete( .sinkUntilComplete(
receiveCompletion: { result in receiveCompletion: { result in
switch result { switch result {

@ -651,8 +651,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC
.map { part -> String in .map { part -> String in
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
let partRange = (part.index(after: part.startIndex)..<part.index(before: part.endIndex)) return part.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
return String(part[partRange])
} }
.forEach { part in .forEach { part in
// Highlight all ranges of the text (Note: The search logic only finds results that start // Highlight all ranges of the text (Note: The search logic only finds results that start

@ -111,7 +111,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration {
} }
return Int32(allThreads[data.contact.id]?.pinnedPriority ?? 0) return Int32(allThreads[data.contact.id]?.pinnedPriority ?? 0)
}() }(),
created: allThreads[data.contact.id]?.creationDateTimestamp
) )
}, },
in: conf in: conf

@ -114,7 +114,7 @@ public enum ConfigurationSyncJob: JobExecutor {
/// in the same order, this means we can just `zip` the two arrays as it will take the smaller of the two and /// in the same order, this means we can just `zip` the two arrays as it will take the smaller of the two and
/// correctly align the response to the change /// correctly align the response to the change
zip(response.responses, pendingConfigChanges) zip(response.responses, pendingConfigChanges)
.compactMap { (subResponse: Codable, change: SessionUtil.OutgoingConfResult) in .compactMap { (subResponse: Decodable, change: SessionUtil.OutgoingConfResult) in
/// If the request wasn't successful then just ignore it (the next time we sync this config we will try /// If the request wasn't successful then just ignore it (the next time we sync this config we will try
/// to send the changes again) /// to send the changes again)
guard guard

@ -4,41 +4,22 @@ import Foundation
import Combine import Combine
import SessionUtilitiesKit import SessionUtilitiesKit
internal extension OpenGroupAPI { public extension OpenGroupAPI {
struct BatchRequest: Encodable { internal struct BatchRequest: Encodable {
let requests: [Child] let requests: [Child]
init(requests: [Info]) { init(requests: [ErasedPreparedSendData]) {
self.requests = requests.map { $0.child } self.requests = requests.map { Child(request: $0) }
} }
// MARK: - Encodable
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
try container.encode(requests) try container.encode(requests)
} }
// MARK: - BatchRequest.Info
struct Info {
public let endpoint: any EndpointType
public let responseType: Codable.Type
fileprivate let child: Child
public init<T: Encodable, E: EndpointType, R: Codable>(request: Request<T, E>, responseType: R.Type) {
self.endpoint = request.endpoint
self.responseType = HTTP.BatchSubResponse<R>.self
self.child = Child(request: request)
}
public init<T: Encodable, E: EndpointType>(request: Request<T, E>) {
self.init(
request: request,
responseType: NoResponse.self
)
}
}
// MARK: - BatchRequest.Child // MARK: - BatchRequest.Child
struct Child: Encodable { struct Child: Encodable {
@ -51,76 +32,43 @@ internal extension OpenGroupAPI {
case bytes case bytes
} }
let method: HTTPMethod let request: ErasedPreparedSendData
let path: String
let headers: [String: String]?
/// The `jsonBodyEncoder` is used to avoid having to make `Child` a generic type (haven't found a good way
/// to keep `Child` encodable using protocols unfortunately so need this work around)
private let jsonBodyEncoder: ((inout KeyedEncodingContainer<CodingKeys>, CodingKeys) throws -> ())?
private let b64: String?
private let bytes: [UInt8]?
internal init<T: Encodable, E: EndpointType>(request: Request<T, E>) {
self.method = request.method
self.path = request.urlPathAndParamsString
self.headers = (request.headers.isEmpty ? nil : request.headers.toHTTPHeaders())
// Note: Need to differentiate between JSON, b64 string and bytes body values to ensure
// they are encoded correctly so the server knows how to handle them
switch request.body {
case let bodyString as String:
self.jsonBodyEncoder = nil
self.b64 = bodyString
self.bytes = nil
case let bodyBytes as [UInt8]:
self.jsonBodyEncoder = nil
self.b64 = nil
self.bytes = bodyBytes
default:
self.jsonBodyEncoder = { [body = request.body] container, key in
try container.encodeIfPresent(body, forKey: key)
}
self.b64 = nil
self.bytes = nil
}
}
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self) try request.encodeForBatchRequest(to: encoder)
try container.encode(method, forKey: .method)
try container.encode(path, forKey: .path)
try container.encodeIfPresent(headers, forKey: .headers)
try jsonBodyEncoder?(&container, .json)
try container.encodeIfPresent(b64, forKey: .b64)
try container.encodeIfPresent(bytes, forKey: .bytes)
} }
} }
} }
}
struct BatchResponse: Decodable {
// MARK: - Convenience let info: ResponseInfoType
let data: [Endpoint: Decodable]
internal extension AnyPublisher where Output == HTTP.BatchResponse, Failure == Error {
func map<E: EndpointType>( public subscript(position: Endpoint) -> Decodable? {
requests: [OpenGroupAPI.BatchRequest.Info], get { return data[position] }
toHashMapFor endpointType: E.Type }
) -> AnyPublisher<(info: ResponseInfoType, data: [E: Codable]), Error> {
return self public var count: Int { data.count }
.map { result -> (info: ResponseInfoType, data: [E: Codable]) in public var keys: Dictionary<Endpoint, Decodable>.Keys { data.keys }
( public var values: Dictionary<Endpoint, Decodable>.Values { data.values }
info: result.info,
data: result.responses.enumerated() // MARK: - Initialization
.reduce(into: [:]) { prev, next in
guard let endpoint: E = requests[next.offset].endpoint as? E else { return } internal init(
info: ResponseInfoType,
prev[endpoint] = next.element data: [Endpoint: Decodable]
} ) {
) self.info = info
} self.data = data
.eraseToAnyPublisher() }
public init(from decoder: Decoder) throws {
#if DEBUG
preconditionFailure("The `OpenGroupAPI.BatchResponse` type cannot be decoded directly, this is simply here to allow for `PreparedSendData<OpenGroupAPI.BatchResponse>` support")
#else
data = [:]
#endif
}
} }
} }

@ -26,13 +26,13 @@ public enum OpenGroupAPI {
/// - Messages (includes additions and deletions) /// - Messages (includes additions and deletions)
/// - Inbox for the server /// - Inbox for the server
/// - Outbox for the server /// - Outbox for the server
public static func poll( public static func preparedPoll(
_ db: Database, _ db: Database,
server: String, server: String,
hasPerformedInitialPoll: Bool, hasPerformedInitialPoll: Bool,
timeSinceLastPoll: TimeInterval, timeSinceLastPoll: TimeInterval,
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { ) throws -> PreparedSendData<BatchResponse> {
let lastInboxMessageId: Int64 = (try? OpenGroup let lastInboxMessageId: Int64 = (try? OpenGroup
.select(.inboxLatestMessageId) .select(.inboxLatestMessageId)
.filter(OpenGroup.Columns.server == server) .filter(OpenGroup.Columns.server == server)
@ -51,26 +51,23 @@ public enum OpenGroupAPI {
.asRequest(of: Capability.Variant.self) .asRequest(of: Capability.Variant.self)
.fetchSet(db)) .fetchSet(db))
.defaulting(to: []) .defaulting(to: [])
let openGroupRooms: [OpenGroup] = (try? OpenGroup
.filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init
.filter(OpenGroup.Columns.isActive == true)
.filter(OpenGroup.Columns.roomToken != "")
.fetchAll(db))
.defaulting(to: [])
// Generate the requests let preparedRequests: [ErasedPreparedSendData] = [
let requestResponseType: [BatchRequest.Info] = [ try preparedCapabilities(
BatchRequest.Info( db,
request: Request<NoBody, Endpoint>( server: server,
server: server, using: dependencies
endpoint: .capabilities
),
responseType: Capabilities.self
) )
] ].appending(
.appending(
// Per-room requests // Per-room requests
contentsOf: (try? OpenGroup contentsOf: try openGroupRooms
.filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init .flatMap { openGroup -> [ErasedPreparedSendData] in
.filter(OpenGroup.Columns.isActive == true)
.filter(OpenGroup.Columns.roomToken != "")
.fetchAll(db))
.defaulting(to: [])
.flatMap { openGroup -> [BatchRequest.Info] in
let shouldRetrieveRecentMessages: Bool = ( let shouldRetrieveRecentMessages: Bool = (
openGroup.sequenceNumber == 0 || ( openGroup.sequenceNumber == 0 || (
// If it's the first poll for this launch and it's been longer than // If it's the first poll for this launch and it's been longer than
@ -82,26 +79,27 @@ public enum OpenGroupAPI {
) )
return [ return [
BatchRequest.Info( try preparedRoomPollInfo(
request: Request<NoBody, Endpoint>( db,
server: server, lastUpdated: openGroup.infoUpdates,
endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates) for: openGroup.roomToken,
), on: openGroup.server,
responseType: RoomPollInfo.self using: dependencies
), ),
BatchRequest.Info( (shouldRetrieveRecentMessages ?
request: Request<NoBody, Endpoint>( try preparedRecentMessages(
server: server, db,
endpoint: (shouldRetrieveRecentMessages ? in: openGroup.roomToken,
.roomMessagesRecent(openGroup.roomToken) : on: openGroup.server,
.roomMessagesSince(openGroup.roomToken, seqNo: openGroup.sequenceNumber) using: dependencies
), ) :
queryParameters: [ try preparedMessagesSince(
.updateTypes: UpdateTypes.reaction.rawValue, db,
.reactors: "5" seqNo: openGroup.sequenceNumber,
] in: openGroup.roomToken,
), on: openGroup.server,
responseType: [Failable<Message>].self using: dependencies
)
) )
] ]
} }
@ -112,83 +110,73 @@ public enum OpenGroupAPI {
!capabilities.contains(.blind) ? [] : !capabilities.contains(.blind) ? [] :
[ [
// Inbox // Inbox
BatchRequest.Info( (lastInboxMessageId == 0 ?
request: Request<NoBody, Endpoint>( try preparedInbox(db, on: server, using: dependencies) :
server: server, try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies)
endpoint: (lastInboxMessageId == 0 ?
.inbox :
.inboxSince(id: lastInboxMessageId)
)
),
responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages
), ),
// Outbox // Outbox
BatchRequest.Info( (lastOutboxMessageId == 0 ?
request: Request<NoBody, Endpoint>( try preparedOutbox(db, on: server, using: dependencies) :
server: server, try preparedOutboxSince(db, id: lastOutboxMessageId, on: server, using: dependencies)
endpoint: (lastOutboxMessageId == 0 ? ),
.outbox :
.outboxSince(id: lastOutboxMessageId)
)
),
responseType: [DirectMessage]?.self // 'outboxSince' will return a `304` with an empty response if no messages
)
] ]
) )
) )
return OpenGroupAPI.batch(db, server: server, requests: requestResponseType, using: dependencies) return try OpenGroupAPI.preparedBatch(
db,
server: server,
requests: preparedRequests,
using: dependencies
)
} }
/// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one /// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one
/// ///
/// Requests are performed independently, that is, if one fails the others will still be attempted - there is no guarantee on the order in which requests will be /// Requests are performed independently, that is, if one fails the others will still be attempted - there is no guarantee on the order in which
/// carried out (for sequential, related requests invoke via `/sequence` instead) /// requests will be carried out (for sequential, related requests invoke via `/sequence` instead)
/// ///
/// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body. /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided
private static func batch( /// with the request body.
private static func preparedBatch(
_ db: Database, _ db: Database,
server: String, server: String,
requests: [BatchRequest.Info], requests: [ErasedPreparedSendData],
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { ) throws -> PreparedSendData<BatchResponse> {
let responseTypes = requests.map { $0.responseType } return try OpenGroupAPI
.prepareSendData(
return OpenGroupAPI
.send(
db, db,
request: Request( request: Request(
method: .post, method: .post,
server: server, server: server,
endpoint: Endpoint.batch, endpoint: .batch,
body: BatchRequest(requests: requests) body: BatchRequest(requests: requests)
), ),
responseType: BatchResponse.self,
using: dependencies using: dependencies
) )
.decoded(as: responseTypes, using: dependencies)
.map(requests: requests, toHashMapFor: Endpoint.self)
} }
/// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests if the previous request /// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests
/// returned a non-`2xx` response /// if the previous request returned a non-`2xx` response
/// ///
/// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the ban fails (e.g. because /// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the
/// permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the `/batch` endpoint; requests that are not /// ban fails (e.g. because permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the
/// carried out because of an earlier failure will have a response code of `412` (Precondition Failed)." /// `/batch` endpoint; requests that are not carried out because of an earlier failure will have a response code of `412` (Precondition Failed)."
/// ///
/// Like `/batch`, responses are returned in the same order as requests, but unlike `/batch` there may be fewer elements in the response list (if requests were /// Like `/batch`, responses are returned in the same order as requests, but unlike `/batch` there may be fewer elements in the response
/// stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final response value /// list (if requests were stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final
private static func sequence( /// response value
private static func preparedSequence(
_ db: Database, _ db: Database,
server: String, server: String,
requests: [BatchRequest.Info], requests: [ErasedPreparedSendData],
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { ) throws -> PreparedSendData<BatchResponse> {
let responseTypes = requests.map { $0.responseType } return try OpenGroupAPI
.prepareSendData(
return OpenGroupAPI
.send(
db, db,
request: Request( request: Request(
method: .post, method: .post,
@ -196,18 +184,17 @@ public enum OpenGroupAPI {
endpoint: Endpoint.sequence, endpoint: Endpoint.sequence,
body: BatchRequest(requests: requests) body: BatchRequest(requests: requests)
), ),
responseType: BatchResponse.self,
using: dependencies using: dependencies
) )
.decoded(as: responseTypes, using: dependencies)
.map(requests: requests, toHashMapFor: Endpoint.self)
} }
// MARK: - Capabilities // MARK: - Capabilities
/// Return the list of server features/capabilities /// Return the list of server features/capabilities
/// ///
/// Optionally takes a `required` parameter containing a comma-separated list of capabilites; if any are not satisfied a 412 (Precondition Failed) response /// Optionally takes a `required` parameter containing a comma-separated list of capabilites; if any are not satisfied a 412 (Precondition Failed)
/// will be returned with missing requested capabilities in the `missing` key /// response will be returned with missing requested capabilities in the `missing` key
/// ///
/// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch`
/// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}`
@ -253,11 +240,6 @@ public enum OpenGroupAPI {
} }
/// Returns the details of a single room /// Returns the details of a single room
///
/// **Note:** This is the direct request to retrieve a room so should only be called from either the `poll()` or `joinRoom()` methods, in order to call
/// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo`
/// method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead")
public static func preparedRoom( public static func preparedRoom(
_ db: Database, _ db: Database,
for roomToken: String, for roomToken: String,
@ -280,11 +262,6 @@ public enum OpenGroupAPI {
/// ///
/// The endpoint polls room metadata for this room, always including the instantaneous room details (such as the user's permission and current /// The endpoint polls room metadata for this room, always including the instantaneous room details (such as the user's permission and current
/// number of active users), and including the full room metadata if the room's info_updated counter has changed from the provided value /// number of active users), and including the full room metadata if the room's info_updated counter has changed from the provided value
///
/// **Note:** This is the direct request to retrieve room updates so should be retrieved automatically from the `poll()` method, in order to call
/// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo`
/// method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead")
public static func preparedRoomPollInfo( public static func preparedRoomPollInfo(
_ db: Database, _ db: Database,
lastUpdated: Int64, lastUpdated: Int64,
@ -305,51 +282,33 @@ public enum OpenGroupAPI {
} }
public typealias CapabilitiesAndRoomResponse = ( public typealias CapabilitiesAndRoomResponse = (
info: ResponseInfoType, capabilities: (info: ResponseInfoType, data: Capabilities),
data: ( room: (info: ResponseInfoType, data: Room)
capabilities: (info: ResponseInfoType, data: Capabilities),
room: (info: ResponseInfoType, data: Room)
)
) )
/// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those
/// methods for the documented behaviour of each method /// methods for the documented behaviour of each method
public static func capabilitiesAndRoom( public static func preparedCapabilitiesAndRoom(
_ db: Database, _ db: Database,
for roomToken: String, for roomToken: String,
on server: String, on server: String,
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<CapabilitiesAndRoomResponse, Error> { ) throws -> PreparedSendData<CapabilitiesAndRoomResponse> {
let requestResponseType: [BatchRequest.Info] = [ return try OpenGroupAPI
// Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) .preparedSequence(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .capabilities
),
responseType: Capabilities.self
),
// And the room info
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .room(roomToken)
),
responseType: Room.self
)
]
return OpenGroupAPI
.sequence(
db, db,
server: server, server: server,
requests: requestResponseType, requests: [
// Get the latest capabilities for the server (in case it's a new server or the
// cached ones are stale)
preparedCapabilities(db, server: server, using: dependencies),
preparedRoom(db, for: roomToken, on: server, using: dependencies)
],
using: dependencies using: dependencies
) )
.tryMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> CapabilitiesAndRoomResponse in .map { (info: ResponseInfoType, response: BatchResponse) -> CapabilitiesAndRoomResponse in
let maybeCapabilities: HTTP.BatchSubResponse<Capabilities>? = (data[.capabilities] as? HTTP.BatchSubResponse<Capabilities>) let maybeCapabilities: HTTP.BatchSubResponse<Capabilities>? = (response[.capabilities] as? HTTP.BatchSubResponse<Capabilities>)
let maybeRoomResponse: Codable? = data let maybeRoomResponse: Decodable? = response.data
.first(where: { key, _ in .first(where: { key, _ in
switch key { switch key {
case .room: return true case .room: return true
@ -367,53 +326,34 @@ public enum OpenGroupAPI {
else { throw HTTPError.parsingFailed } else { throw HTTPError.parsingFailed }
return ( return (
info: info, capabilities: (info: capabilitiesInfo, data: capabilities),
data: ( room: (info: roomInfo, data: room)
capabilities: (info: capabilitiesInfo, data: capabilities),
room: (info: roomInfo, data: room)
)
) )
} }
.eraseToAnyPublisher()
} }
/// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those
/// methods for the documented behaviour of each method /// methods for the documented behaviour of each method
public static func capabilitiesAndRooms( public static func preparedCapabilitiesAndRooms(
_ db: Database, _ db: Database,
on server: String, on server: String,
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> { ) throws -> PreparedSendData<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room]))> {
let requestResponseType: [BatchRequest.Info] = [ return try OpenGroupAPI
// Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) .preparedSequence(
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .capabilities
),
responseType: Capabilities.self
),
// And the room info
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .rooms
),
responseType: [Room].self
)
]
return OpenGroupAPI
.sequence(
db, db,
server: server, server: server,
requests: requestResponseType, requests: [
// Get the latest capabilities for the server (in case it's a new server or the
// cached ones are stale)
preparedCapabilities(db, server: server, using: dependencies),
preparedRooms(db, server: server, using: dependencies)
],
using: dependencies using: dependencies
) )
.tryMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> (capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])) in .map { (info: ResponseInfoType, response: BatchResponse) -> (capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])) in
let maybeCapabilities: HTTP.BatchSubResponse<Capabilities>? = (data[.capabilities] as? HTTP.BatchSubResponse<Capabilities>) let maybeCapabilities: HTTP.BatchSubResponse<Capabilities>? = (response[.capabilities] as? HTTP.BatchSubResponse<Capabilities>)
let maybeRooms: HTTP.BatchSubResponse<[Room]>? = data let maybeRooms: HTTP.BatchSubResponse<[Room]>? = response.data
.first(where: { key, _ in .first(where: { key, _ in
switch key { switch key {
case .rooms: return true case .rooms: return true
@ -434,7 +374,6 @@ public enum OpenGroupAPI {
rooms: (info: roomsInfo, data: rooms) rooms: (info: roomsInfo, data: rooms)
) )
} }
.eraseToAnyPublisher()
} }
// MARK: - Messages // MARK: - Messages
@ -528,6 +467,7 @@ public enum OpenGroupAPI {
) )
} }
/// Remove a message by its message id
public static func preparedMessageDelete( public static func preparedMessageDelete(
_ db: Database, _ db: Database,
id: Int64, id: Int64,
@ -548,62 +488,75 @@ public enum OpenGroupAPI {
) )
} }
/// **Note:** This is the direct request to retrieve recent messages so should be retrieved automatically from the `poll()` method, in order to call /// Retrieves recent messages posted to this room
/// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` ///
/// method to ensure things are processed correctly /// Returns the most recent limit messages (100 if no limit is given). This only returns extant messages, and always returns the latest
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") /// versions: that is, deleted message indicators and pre-editing versions of messages are not returned. Messages are returned in order
/// from most recent to least recent
public static func preparedRecentMessages( public static func preparedRecentMessages(
_ db: Database, _ db: Database,
in roomToken: String, in roomToken: String,
on server: String, on server: String,
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) throws -> PreparedSendData<[Message]> { ) throws -> PreparedSendData<[Failable<Message>]> {
return try OpenGroupAPI return try OpenGroupAPI
.prepareSendData( .prepareSendData(
db, db,
request: Request<NoBody, Endpoint>( request: Request<NoBody, Endpoint>(
server: server, server: server,
endpoint: .roomMessagesRecent(roomToken) endpoint: .roomMessagesRecent(roomToken),
queryParameters: [
.updateTypes: UpdateTypes.reaction.rawValue,
.reactors: "5"
]
), ),
responseType: [Message].self, responseType: [Failable<Message>].self,
using: dependencies using: dependencies
) )
} }
/// **Note:** This is the direct request to retrieve recent messages before a given message and is currently unused, in order to call this directly /// Retrieves messages from the room preceding a given id.
/// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` ///
/// method to ensure things are processed correctly /// This endpoint is intended to be used with .../recent to allow a client to retrieve the most recent messages and then walk backwards
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") /// through batches of ever-older messages. As with .../recent, messages are returned in order from most recent to least recent.
///
/// As with .../recent, this endpoint does not include deleted messages and always returns the current version, for edited messages.
public static func preparedMessagesBefore( public static func preparedMessagesBefore(
_ db: Database, _ db: Database,
messageId: Int64, messageId: Int64,
in roomToken: String, in roomToken: String,
on server: String, on server: String,
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) throws -> PreparedSendData<[Message]> { ) throws -> PreparedSendData<[Failable<Message>]> {
return try OpenGroupAPI return try OpenGroupAPI
.prepareSendData( .prepareSendData(
db, db,
request: Request<NoBody, Endpoint>( request: Request<NoBody, Endpoint>(
server: server, server: server,
endpoint: .roomMessagesBefore(roomToken, id: messageId) endpoint: .roomMessagesBefore(roomToken, id: messageId),
queryParameters: [
.updateTypes: UpdateTypes.reaction.rawValue,
.reactors: "5"
]
), ),
responseType: [Message].self, responseType: [Failable<Message>].self,
using: dependencies using: dependencies
) )
} }
/// **Note:** This is the direct request to retrieve messages since a given message `seqNo` so should be retrieved automatically from the /// Retrieves message updates from a room. This is the main message polling endpoint in SOGS.
/// `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the ///
/// `OpenGroupManager.handleMessages` method to ensure things are processed correctly /// This endpoint retrieves new, edited, and deleted messages or message reactions posted to this room since the given message
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") /// sequence counter. Returns limit messages at a time (100 if no limit is given). Returned messages include any new messages, updates
/// to existing messages (i.e. edits), and message deletions made to the room since the given update id. Messages are returned in "update"
/// order, that is, in the order in which the change was applied to the room, from oldest the newest.
public static func preparedMessagesSince( public static func preparedMessagesSince(
_ db: Database, _ db: Database,
seqNo: Int64, seqNo: Int64,
in roomToken: String, in roomToken: String,
on server: String, on server: String,
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) throws -> PreparedSendData<[Message]> { ) throws -> PreparedSendData<[Failable<Message>]> {
return try OpenGroupAPI return try OpenGroupAPI
.prepareSendData( .prepareSendData(
db, db,
@ -612,10 +565,10 @@ public enum OpenGroupAPI {
endpoint: .roomMessagesSince(roomToken, seqNo: seqNo), endpoint: .roomMessagesSince(roomToken, seqNo: seqNo),
queryParameters: [ queryParameters: [
.updateTypes: UpdateTypes.reaction.rawValue, .updateTypes: UpdateTypes.reaction.rawValue,
.reactors: "20" .reactors: "5"
] ]
), ),
responseType: [Message].self, responseType: [Failable<Message>].self,
using: dependencies using: dependencies
) )
} }
@ -655,6 +608,7 @@ public enum OpenGroupAPI {
// MARK: - Reactions // MARK: - Reactions
/// Returns the list of all reactors who have added a particular reaction to a particular message.
public static func preparedReactors( public static func preparedReactors(
_ db: Database, _ db: Database,
emoji: String, emoji: String,
@ -682,6 +636,10 @@ public enum OpenGroupAPI {
) )
} }
/// Adds a reaction to the given message in this room. The user must have read access in the room.
///
/// Reactions are short strings of 1-12 unicode codepoints, typically emoji (or character sequences to produce an emoji variant,
/// such as 👨🏿🦰, which is composed of 4 unicode "characters" but usually renders as a single emoji "Man: Dark Skin Tone, Red Hair").
public static func preparedReactionAdd( public static func preparedReactionAdd(
_ db: Database, _ db: Database,
emoji: String, emoji: String,
@ -709,6 +667,8 @@ public enum OpenGroupAPI {
) )
} }
/// Removes a reaction from a post this room. The user must have read access in the room. This only removes the user's own reaction
/// but does not affect the reactions of other users.
public static func preparedReactionDelete( public static func preparedReactionDelete(
_ db: Database, _ db: Database,
emoji: String, emoji: String,
@ -736,6 +696,9 @@ public enum OpenGroupAPI {
) )
} }
/// Removes all reactions of all users from a post in this room. The calling must have moderator permissions in the room. This endpoint
/// can either remove a single reaction (e.g. remove all 🍆 reactions) by specifying it after the message id (following a /), or remove all
/// reactions from the post by not including the /<reaction> suffix of the URL.
public static func preparedReactionDeleteAll( public static func preparedReactionDeleteAll(
_ db: Database, _ db: Database,
emoji: String, emoji: String,
@ -842,6 +805,12 @@ public enum OpenGroupAPI {
// MARK: - Files // MARK: - Files
/// Uploads a file to a room.
///
/// Takes the request as binary in the body and takes other properties (specifically the suggested filename) via submitted headers.
///
/// The user must have upload and posting permissions for the room. The file will have a default lifetime of 1 hour, which is extended
/// to 15 days (by default) when a post referencing the uploaded file is posted or edited.
public static func preparedUploadFile( public static func preparedUploadFile(
_ db: Database, _ db: Database,
bytes: [UInt8], bytes: [UInt8],
@ -871,6 +840,10 @@ public enum OpenGroupAPI {
) )
} }
/// Retrieves a file uploaded to the room.
///
/// Retrieves a file via its numeric id from the room, returning the file content directly as the binary response body. The file's suggested
/// filename (as provided by the uploader) is provided in the Content-Disposition header, if available.
public static func preparedDownloadFile( public static func preparedDownloadFile(
_ db: Database, _ db: Database,
fileId: String, fileId: String,
@ -895,10 +868,7 @@ public enum OpenGroupAPI {
/// Retrieves all of the user's current DMs (up to limit) /// Retrieves all of the user's current DMs (up to limit)
/// ///
/// **Note:** This is the direct request to retrieve DMs for a specific Open Group so should be retrieved automatically from the `poll()` /// **Note:** `inbox` will return a `304` with an empty response if no messages (hence the optional return type)
/// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the
/// `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead")
public static func preparedInbox( public static func preparedInbox(
_ db: Database, _ db: Database,
on server: String, on server: String,
@ -918,10 +888,7 @@ public enum OpenGroupAPI {
/// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages /// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages
/// ///
/// **Note:** This is the direct request to retrieve messages requests for a specific Open Group since a given messages so should be retrieved /// **Note:** `inboxSince` will return a `304` with an empty response if no messages (hence the optional return type)
/// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response
/// of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead")
public static func preparedInboxSince( public static func preparedInboxSince(
_ db: Database, _ db: Database,
id: Int64, id: Int64,
@ -968,10 +935,7 @@ public enum OpenGroupAPI {
/// Retrieves all of the user's sent DMs (up to limit) /// Retrieves all of the user's sent DMs (up to limit)
/// ///
/// **Note:** This is the direct request to retrieve DMs sent by the user for a specific Open Group so should be retrieved automatically /// **Note:** `outbox` will return a `304` with an empty response if no messages (hence the optional return type)
/// from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of
/// this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead")
public static func preparedOutbox( public static func preparedOutbox(
_ db: Database, _ db: Database,
on server: String, on server: String,
@ -991,10 +955,7 @@ public enum OpenGroupAPI {
/// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages /// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages
/// ///
/// **Note:** This is the direct request to retrieve messages requests sent by the user for a specific Open Group since a given messages so /// **Note:** `outboxSince` will return a `304` with an empty response if no messages (hence the optional return type)
/// should be retrieved automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure
/// to route the response of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead")
public static func preparedOutboxSince( public static func preparedOutboxSince(
_ db: Database, _ db: Database,
id: Int64, id: Int64,
@ -1207,52 +1168,35 @@ public enum OpenGroupAPI {
/// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those /// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those
/// methods for the documented behaviour of each method /// methods for the documented behaviour of each method
public static func userBanAndDeleteAllMessages( public static func preparedUserBanAndDeleteAllMessages(
_ db: Database, _ db: Database,
sessionId: String, sessionId: String,
in roomToken: String, in roomToken: String,
on server: String, on server: String,
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: ResponseInfoType]), Error> { ) throws -> PreparedSendData<BatchResponse> {
let banRequestBody: UserBanRequest = UserBanRequest( return try OpenGroupAPI
rooms: [roomToken], .preparedSequence(
global: nil,
timeout: nil
)
// Generate the requests
let requestResponseType: [BatchRequest.Info] = [
BatchRequest.Info(
request: Request<UserBanRequest, Endpoint>(
method: .post,
server: server,
endpoint: .userBan(sessionId),
body: banRequestBody
)
),
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
method: .delete,
server: server,
endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId)
)
)
]
return OpenGroupAPI
.sequence(
db, db,
server: server, server: server,
requests: requestResponseType, requests: [
preparedUserBan(
db,
sessionId: sessionId,
from: [roomToken],
on: server,
using: dependencies
),
preparedMessagesDeleteAll(
db,
sessionId: sessionId,
in: roomToken,
on: server,
using: dependencies
)
],
using: dependencies using: dependencies
) )
.map { info, data -> (info: ResponseInfoType, data: [Endpoint: ResponseInfoType]) in
(
info,
data.compactMapValues { ($0 as? BatchSubResponseType)?.responseInfo }
)
}
.eraseToAnyPublisher()
} }
// MARK: - Authentication // MARK: - Authentication
@ -1388,6 +1332,9 @@ public enum OpenGroupAPI {
// MARK: - Convenience // MARK: - Convenience
/// Takes the reuqest information and generates a signed `PreparedSendData<R>` pbject which is ready for sending to the API, this
/// method is mainly here so we can separate the preparation of a request, which requires access to the database for signing, from the
/// actual sending of the reuqest to ensure we don't run into any unexpected blocking of the database write thread
private static func prepareSendData<T: Encodable, R: Decodable>( private static func prepareSendData<T: Encodable, R: Decodable>(
_ db: Database, _ db: Database,
request: Request<T, Endpoint>, request: Request<T, Endpoint>,
@ -1411,56 +1358,15 @@ public enum OpenGroupAPI {
} }
return PreparedSendData( return PreparedSendData(
request: signedRequest, request: request,
endpoint: request.endpoint, urlRequest: signedRequest,
server: request.server,
publicKey: publicKey, publicKey: publicKey,
responseType: responseType, responseType: responseType,
timeout: timeout timeout: timeout
) )
} }
private static func send<T: Encodable>( /// This method takes in the `PreparedSendData<R>` and actually sends it to the API
_ db: Database,
request: Request<T, Endpoint>,
forceBlinded: Bool = false,
timeout: TimeInterval = HTTP.defaultTimeout,
using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
let urlRequest: URLRequest
do {
urlRequest = try request.generateUrlRequest()
}
catch {
return Fail(error: error)
.eraseToAnyPublisher()
}
let maybePublicKey: String? = try? OpenGroup
.select(.publicKey)
.filter(OpenGroup.Columns.server == request.server.lowercased())
.asRequest(of: String.self)
.fetchOne(db)
guard let publicKey: String = maybePublicKey else {
return Fail(error: OpenGroupAPIError.noPublicKey)
.eraseToAnyPublisher()
}
// Attempt to sign the request with the new auth
guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, forceBlinded: forceBlinded, using: dependencies) else {
return Fail(error: OpenGroupAPIError.signingFailed)
.eraseToAnyPublisher()
}
// We want to avoid blocking the db write thread so we dispatch the API call to a different thread
return Just(())
.setFailureType(to: Error.self)
.flatMap { dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey, timeout: timeout) }
.eraseToAnyPublisher()
}
public static func send<R>( public static func send<R>(
data: PreparedSendData<R>?, data: PreparedSendData<R>?,
using dependencies: SMKDependencies = SMKDependencies() using dependencies: SMKDependencies = SMKDependencies()

@ -282,13 +282,9 @@ public final class OpenGroupManager {
} }
.flatMap { _ in .flatMap { _ in
dependencies.storage dependencies.storage
.readPublisherFlatMap { db in .readPublisher { db in
// Note: The initial request for room info and it's capabilities should NOT be try OpenGroupAPI
// authenticated (this is because if the server requires blinding and the auth .preparedCapabilitiesAndRoom(
// headers aren't blinded it will error - these endpoints do support unauthenticated
// retrieval so doing so prevents the error)
OpenGroupAPI
.capabilitiesAndRoom(
db, db,
for: roomToken, for: roomToken,
on: targetServer, on: targetServer,
@ -296,7 +292,8 @@ public final class OpenGroupManager {
) )
} }
} }
.flatMap { response -> Future<Void, Error> in .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.flatMap { info, response -> Future<Void, Error> in
Future<Void, Error> { resolver in Future<Void, Error> { resolver in
dependencies.storage.write { db in dependencies.storage.write { db in
// Add the new open group to libSession // Add the new open group to libSession
@ -312,14 +309,14 @@ public final class OpenGroupManager {
// Store the capabilities first // Store the capabilities first
OpenGroupManager.handleCapabilities( OpenGroupManager.handleCapabilities(
db, db,
capabilities: response.data.capabilities.data, capabilities: response.capabilities.data,
on: targetServer on: targetServer
) )
// Then the room // Then the room
try OpenGroupManager.handlePollInfo( try OpenGroupManager.handlePollInfo(
db, db,
pollInfo: OpenGroupAPI.RoomPollInfo(room: response.data.room.data), pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data),
publicKey: publicKey, publicKey: publicKey,
for: roomToken, for: roomToken,
on: targetServer, on: targetServer,
@ -1024,17 +1021,18 @@ public final class OpenGroupManager {
// Try to retrieve the default rooms 8 times // Try to retrieve the default rooms 8 times
let publisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.storage let publisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.storage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.capabilitiesAndRooms( try OpenGroupAPI.preparedCapabilitiesAndRooms(
db, db,
on: OpenGroupAPI.defaultServer, on: OpenGroupAPI.defaultServer,
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
.retry(8) .retry(8)
.map { response in .map { info, response in
dependencies.storage.writeAsync { db in dependencies.storage.writeAsync { db in
// Store the capabilities first // Store the capabilities first
OpenGroupManager.handleCapabilities( OpenGroupManager.handleCapabilities(
@ -1204,6 +1202,12 @@ public final class OpenGroupManager {
.shareReplay(1) .shareReplay(1)
.eraseToAnyPublisher() .eraseToAnyPublisher()
// Automatically subscribe for the roomImage download (want to download regardless of
// whether the upstream subscribes)
publisher
.subscribe(on: dependencies.subscribeQueue)
.sinkUntilComplete()
dependencies.mutableCache.mutate { cache in dependencies.mutableCache.mutate { cache in
cache.groupImagePublishers[threadId] = publisher cache.groupImagePublishers[threadId] = publisher
} }

@ -4,28 +4,48 @@ import Foundation
import Combine import Combine
import SessionUtilitiesKit import SessionUtilitiesKit
// MARK: - ErasedPreparedSendData
public protocol ErasedPreparedSendData {
var endpoint: OpenGroupAPI.Endpoint { get }
var batchResponseTypes: [Decodable.Type] { get }
func encodeForBatchRequest(to encoder: Encoder) throws
}
// MARK: - PreparedSendData<R>
public extension OpenGroupAPI { public extension OpenGroupAPI {
struct PreparedSendData<R> { struct PreparedSendData<R>: ErasedPreparedSendData {
internal let request: URLRequest internal let request: URLRequest
internal let endpoint: Endpoint
internal let server: String internal let server: String
internal let publicKey: String internal let publicKey: String
internal let originalType: Decodable.Type internal let originalType: Decodable.Type
internal let responseType: R.Type internal let responseType: R.Type
internal let timeout: TimeInterval internal let timeout: TimeInterval
internal let responseConverter: ((ResponseInfoType, Any) throws -> R) fileprivate let responseConverter: ((ResponseInfoType, Any) throws -> R)
internal init( // The following types are needed for `BatchRequest` handling
request: URLRequest, private let method: HTTPMethod
endpoint: Endpoint, private let path: String
server: String, public let endpoint: Endpoint
fileprivate let batchEndpoints: [Endpoint]
public let batchResponseTypes: [Decodable.Type]
/// The `jsonBodyEncoder` is used to simplify the encoding for `BatchRequest`
private let jsonBodyEncoder: ((inout KeyedEncodingContainer<BatchRequest.Child.CodingKeys>, BatchRequest.Child.CodingKeys) throws -> ())?
private let b64: String?
private let bytes: [UInt8]?
internal init<T: Encodable>(
request: Request<T, Endpoint>,
urlRequest: URLRequest,
publicKey: String, publicKey: String,
responseType: R.Type, responseType: R.Type,
timeout: TimeInterval timeout: TimeInterval
) where R: Decodable { ) where R: Decodable {
self.request = request self.request = urlRequest
self.endpoint = endpoint self.server = request.server
self.server = server
self.publicKey = publicKey self.publicKey = publicKey
self.originalType = responseType self.originalType = responseType
self.responseType = responseType self.responseType = responseType
@ -35,26 +55,101 @@ public extension OpenGroupAPI {
return validResponse return validResponse
} }
// The following data is needed in this type for handling batch requests
self.method = request.method
self.endpoint = request.endpoint
self.path = request.urlPathAndParamsString
self.batchEndpoints = ((request.body as? BatchRequest)?
.requests
.map { $0.request.endpoint })
.defaulting(to: [])
self.batchResponseTypes = ((request.body as? BatchRequest)?
.requests
.flatMap { $0.request.batchResponseTypes })
.defaulting(to: [HTTP.BatchSubResponse<R>.self])
// Note: Need to differentiate between JSON, b64 string and bytes body values to ensure
// they are encoded correctly so the server knows how to handle them
switch request.body {
case let bodyString as String:
self.jsonBodyEncoder = nil
self.b64 = bodyString
self.bytes = nil
case let bodyBytes as [UInt8]:
self.jsonBodyEncoder = nil
self.b64 = nil
self.bytes = bodyBytes
default:
self.jsonBodyEncoder = { [body = request.body] container, key in
try container.encodeIfPresent(body, forKey: key)
}
self.b64 = nil
self.bytes = nil
}
} }
private init<U: Decodable>( private init<U: Decodable>(
request: URLRequest, request: URLRequest,
endpoint: Endpoint,
server: String, server: String,
publicKey: String, publicKey: String,
originalType: U.Type, originalType: U.Type,
responseType: R.Type, responseType: R.Type,
timeout: TimeInterval, timeout: TimeInterval,
responseConverter: @escaping (ResponseInfoType, Any) throws -> R responseConverter: @escaping (ResponseInfoType, Any) throws -> R,
method: HTTPMethod,
endpoint: Endpoint,
path: String,
batchEndpoints: [Endpoint],
batchResponseTypes: [Decodable.Type],
jsonBodyEncoder: ((inout KeyedEncodingContainer<BatchRequest.Child.CodingKeys>, BatchRequest.Child.CodingKeys) throws -> ())?,
b64: String?,
bytes: [UInt8]?
) { ) {
self.request = request self.request = request
self.endpoint = endpoint
self.server = server self.server = server
self.publicKey = publicKey self.publicKey = publicKey
self.originalType = originalType self.originalType = originalType
self.responseType = responseType self.responseType = responseType
self.timeout = timeout self.timeout = timeout
self.responseConverter = responseConverter self.responseConverter = responseConverter
// The following data is needed in this type for handling batch requests
self.method = method
self.endpoint = endpoint
self.path = path
self.batchEndpoints = batchEndpoints
self.batchResponseTypes = batchResponseTypes
self.jsonBodyEncoder = jsonBodyEncoder
self.b64 = b64
self.bytes = bytes
}
// MARK: - ErasedPreparedSendData
public func encodeForBatchRequest(to encoder: Encoder) throws {
var container: KeyedEncodingContainer<BatchRequest.Child.CodingKeys> = encoder.container(keyedBy: BatchRequest.Child.CodingKeys.self)
// Exclude request signature headers (not used for sub-requests)
let batchRequestHeaders: [String: String] = (request.allHTTPHeaderFields ?? [:])
.filter { key, _ in
key.lowercased() != HTTPHeader.sogsPubKey.lowercased() &&
key.lowercased() != HTTPHeader.sogsTimestamp.lowercased() &&
key.lowercased() != HTTPHeader.sogsNonce.lowercased() &&
key.lowercased() != HTTPHeader.sogsSignature.lowercased()
}
if !batchRequestHeaders.isEmpty {
try container.encode(batchRequestHeaders, forKey: .headers)
}
try container.encode(method, forKey: .method)
try container.encode(path, forKey: .path)
try jsonBodyEncoder?(&container, .json)
try container.encodeIfPresent(b64, forKey: .b64)
try container.encodeIfPresent(bytes, forKey: .bytes)
} }
} }
} }
@ -63,7 +158,6 @@ public extension OpenGroupAPI.PreparedSendData {
func map<O>(transform: @escaping (ResponseInfoType, R) throws -> O) -> OpenGroupAPI.PreparedSendData<O> { func map<O>(transform: @escaping (ResponseInfoType, R) throws -> O) -> OpenGroupAPI.PreparedSendData<O> {
return OpenGroupAPI.PreparedSendData( return OpenGroupAPI.PreparedSendData(
request: request, request: request,
endpoint: endpoint,
server: server, server: server,
publicKey: publicKey, publicKey: publicKey,
originalType: originalType, originalType: originalType,
@ -73,7 +167,15 @@ public extension OpenGroupAPI.PreparedSendData {
let validResponse: R = try responseConverter(info, response) let validResponse: R = try responseConverter(info, response)
return try transform(info, validResponse) return try transform(info, validResponse)
} },
method: method,
endpoint: endpoint,
path: path,
batchEndpoints: batchEndpoints,
batchResponseTypes: batchResponseTypes,
jsonBodyEncoder: jsonBodyEncoder,
b64: b64,
bytes: bytes
) )
} }
} }
@ -90,6 +192,22 @@ public extension Publisher where Output == (ResponseInfoType, Data?), Failure ==
// Depending on the 'originalType' we need to process the response differently // Depending on the 'originalType' we need to process the response differently
let targetData: Any = try { let targetData: Any = try {
switch preparedData.originalType { switch preparedData.originalType {
case is OpenGroupAPI.BatchResponse.Type:
let responses: [Decodable] = try HTTP.BatchResponse.decodingResponses(
from: maybeData,
as: preparedData.batchResponseTypes,
requireAllResults: true,
using: dependencies
)
return OpenGroupAPI.BatchResponse(
info: responseInfo,
data: Swift.zip(preparedData.batchEndpoints, responses)
.reduce(into: [:]) { result, next in
result[next.0] = next.1
}
)
case is NoResponse.Type: return NoResponse() case is NoResponse.Type: return NoResponse()
case is Optional<Data>.Type: return maybeData as Any case is Optional<Data>.Type: return maybeData as Any
case is Data.Type: return try maybeData ?? { throw HTTPError.parsingFailed }() case is Data.Type: return try maybeData ?? { throw HTTPError.parsingFailed }()

@ -8,7 +8,7 @@ import SessionUtilitiesKit
extension OpenGroupAPI { extension OpenGroupAPI {
public final class Poller { public final class Poller {
typealias PollResponse = (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: Codable]) typealias PollResponse = (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: Decodable])
private let server: String private let server: String
private var timer: Timer? = nil private var timer: Timer? = nil
@ -122,7 +122,7 @@ extension OpenGroupAPI {
let server: String = self.server let server: String = self.server
return dependencies.storage return dependencies.storage
.readPublisherFlatMap { db -> AnyPublisher<(Int64, PollResponse), Error> in .readPublisher { db -> (Int64, PreparedSendData<BatchResponse>) in
let failureCount: Int64 = (try? OpenGroup let failureCount: Int64 = (try? OpenGroup
.filter(OpenGroup.Columns.server == server) .filter(OpenGroup.Columns.server == server)
.select(max(OpenGroup.Columns.pollFailureCount)) .select(max(OpenGroup.Columns.pollFailureCount))
@ -130,22 +130,27 @@ extension OpenGroupAPI {
.fetchOne(db)) .fetchOne(db))
.defaulting(to: 0) .defaulting(to: 0)
return OpenGroupAPI return (
.poll( failureCount,
db, try OpenGroupAPI
server: server, .preparedPoll(
hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, db,
timeSinceLastPoll: ( server: server,
dependencies.cache.timeSinceLastPoll[server] ?? hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true,
dependencies.cache.getTimeSinceLastOpen(using: dependencies) timeSinceLastPoll: (
), dependencies.cache.timeSinceLastPoll[server] ??
using: dependencies dependencies.cache.getTimeSinceLastOpen(using: dependencies)
) ),
.map { response in (failureCount, response) } using: dependencies
.eraseToAnyPublisher() )
)
}
.flatMap { failureCount, sendData in
OpenGroupAPI.send(data: sendData, using: dependencies)
.map { info, response in (failureCount, info, response) }
} }
.handleEvents( .handleEvents(
receiveOutput: { [weak self] failureCount, response in receiveOutput: { [weak self] failureCount, info, response in
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else { guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
// If this was a background poll and the background poll is no longer valid // If this was a background poll and the background poll is no longer valid
// then just stop // then just stop
@ -155,7 +160,8 @@ extension OpenGroupAPI {
self?.isPolling = false self?.isPolling = false
self?.handlePollResponse( self?.handlePollResponse(
response, info: info,
response: response,
failureCount: failureCount, failureCount: failureCount,
using: dependencies using: dependencies
) )
@ -363,12 +369,13 @@ extension OpenGroupAPI {
} }
private func handlePollResponse( private func handlePollResponse(
_ response: PollResponse, info: ResponseInfoType,
response: BatchResponse,
failureCount: Int64, failureCount: Int64,
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
) { ) {
let server: String = self.server let server: String = self.server
let validResponses: [OpenGroupAPI.Endpoint: Codable] = response.data let validResponses: [OpenGroupAPI.Endpoint: Decodable] = response.data
.filter { endpoint, data in .filter { endpoint, data in
switch endpoint { switch endpoint {
case .capabilities: case .capabilities:
@ -467,7 +474,7 @@ extension OpenGroupAPI {
return (capabilities, groups) return (capabilities, groups)
} }
let changedResponses: [OpenGroupAPI.Endpoint: Codable] = validResponses let changedResponses: [OpenGroupAPI.Endpoint: Decodable] = validResponses
.filter { endpoint, data in .filter { endpoint, data in
switch endpoint { switch endpoint {
case .capabilities: case .capabilities:

@ -287,6 +287,12 @@ internal extension SessionUtil {
contact.approved_me = updatedContact.didApproveMe contact.approved_me = updatedContact.didApproveMe
contact.blocked = updatedContact.isBlocked contact.blocked = updatedContact.isBlocked
// If we were given a `created` timestamp then set it to the min between the current
// setting and the value (as long as the current setting isn't `0`)
if let created: Int64 = info.created.map({ Int64(floor($0)) }) {
contact.created = (contact.created > 0 ? min(contact.created, created) : created)
}
// Store the updated contact (needs to happen before variables go out of scope) // Store the updated contact (needs to happen before variables go out of scope)
contacts_set(conf, &contact) contacts_set(conf, &contact)
} }
@ -494,17 +500,20 @@ extension SessionUtil {
let contact: Contact? let contact: Contact?
let profile: Profile? let profile: Profile?
let priority: Int32? let priority: Int32?
let created: TimeInterval?
init( init(
id: String, id: String,
contact: Contact? = nil, contact: Contact? = nil,
profile: Profile? = nil, profile: Profile? = nil,
priority: Int32? = nil priority: Int32? = nil,
created: TimeInterval? = nil
) { ) {
self.id = id self.id = id
self.contact = contact self.contact = contact
self.profile = profile self.profile = profile
self.priority = priority self.priority = priority
self.created = created
} }
} }
} }

@ -1111,8 +1111,8 @@ public extension SessionThreadViewModel {
/// Step 1 - Keep any "quoted" sections as stand-alone search /// Step 1 - Keep any "quoted" sections as stand-alone search
/// Step 2 - Separate any words outside of quotes /// Step 2 - Separate any words outside of quotes
/// Step 3 - Join the different search term parts with 'OR" (include results for each individual term) /// Step 3 - Join the different search term parts with 'OR" (include results for each individual term)
/// Step 4 - Append a wild-card character to the final word /// Step 4 - Append a wild-card character to the final word (as long as the last word doesn't end in a quote)
return searchTerm return standardQuotes(searchTerm)
.split(separator: "\"") .split(separator: "\"")
.enumerated() .enumerated()
.flatMap { index, value -> [String] in .flatMap { index, value -> [String] in
@ -1127,6 +1127,13 @@ public extension SessionThreadViewModel {
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
} }
static func standardQuotes(_ term: String) -> String {
// Apple like to use the special '' quote characters when typing so replace them with normal ones
return term
.replacingOccurrences(of: "", with: "\"")
.replacingOccurrences(of: "", with: "\"")
}
static func pattern(_ db: Database, searchTerm: String) throws -> FTS5Pattern { static func pattern(_ db: Database, searchTerm: String) throws -> FTS5Pattern {
return try pattern(db, searchTerm: searchTerm, forTable: Interaction.self) return try pattern(db, searchTerm: searchTerm, forTable: Interaction.self)
} }
@ -1134,9 +1141,16 @@ public extension SessionThreadViewModel {
static func pattern<T>(_ db: Database, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { static func pattern<T>(_ db: Database, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible {
// Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to // Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to
// add a prefix one // add a prefix one
let rawPattern: String = searchTermParts(searchTerm) let rawPattern: String = {
.joined(separator: " OR ") let result: String = searchTermParts(searchTerm)
.appending("*") .joined(separator: " OR ")
// If the last character is a quotation mark then assume the user doesn't want to append
// a wildcard character
guard !standardQuotes(searchTerm).hasSuffix("\"") else { return result }
return "\(result)*"
}()
let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*" let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*"
/// There are cases where creating a pattern can fail, we want to try and recover from those cases /// There are cases where creating a pattern can fail, we want to try and recover from those cases

@ -24,47 +24,11 @@ class BatchRequestInfoSpec: QuickSpec {
describe("a BatchRequest.Child") { describe("a BatchRequest.Child") {
var request: OpenGroupAPI.BatchRequest! var request: OpenGroupAPI.BatchRequest!
context("when initializing") {
it("sets the headers to nil if there aren't any") {
request = OpenGroupAPI.BatchRequest(
requests: [
OpenGroupAPI.BatchRequest.Info(
request: Request<NoBody, OpenGroupAPI.Endpoint>(
server: "testServer",
endpoint: .batch
)
)
]
)
expect(request.requests.first?.headers).to(beNil())
}
it("converts the headers to HTTP headers") {
request = OpenGroupAPI.BatchRequest(
requests: [
OpenGroupAPI.BatchRequest.Info(
request: Request<NoBody, OpenGroupAPI.Endpoint>(
method: .get,
server: "testServer",
endpoint: .batch,
queryParameters: [:],
headers: [.authorization: "testAuth"],
body: nil
)
)
]
)
expect(request.requests.first?.headers).to(equal(["Authorization": "testAuth"]))
}
}
context("when encoding") { context("when encoding") {
it("successfully encodes a string body") { it("successfully encodes a string body") {
request = OpenGroupAPI.BatchRequest( request = OpenGroupAPI.BatchRequest(
requests: [ requests: [
OpenGroupAPI.BatchRequest.Info( OpenGroupAPI.PreparedSendData<NoResponse>(
request: Request<String, OpenGroupAPI.Endpoint>( request: Request<String, OpenGroupAPI.Endpoint>(
method: .get, method: .get,
server: "testServer", server: "testServer",
@ -72,21 +36,25 @@ class BatchRequestInfoSpec: QuickSpec {
queryParameters: [:], queryParameters: [:],
headers: [:], headers: [:],
body: "testBody" body: "testBody"
) ),
urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!),
publicKey: "",
responseType: NoResponse.self,
timeout: 0
) )
] ]
) )
let childRequestData: Data = try! JSONEncoder().encode(request.requests[0]) let requestData: Data = try! JSONEncoder().encode(request)
let childRequestString: String? = String(data: childRequestData, encoding: .utf8) let requestString: String? = String(data: requestData, encoding: .utf8)
expect(childRequestString) expect(requestString)
.to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"b64\":\"testBody\"}")) .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"b64\":\"testBody\"}]"))
} }
it("successfully encodes a byte body") { it("successfully encodes a byte body") {
request = OpenGroupAPI.BatchRequest( request = OpenGroupAPI.BatchRequest(
requests: [ requests: [
OpenGroupAPI.BatchRequest.Info( OpenGroupAPI.PreparedSendData<NoResponse>(
request: Request<[UInt8], OpenGroupAPI.Endpoint>( request: Request<[UInt8], OpenGroupAPI.Endpoint>(
method: .get, method: .get,
server: "testServer", server: "testServer",
@ -94,21 +62,25 @@ class BatchRequestInfoSpec: QuickSpec {
queryParameters: [:], queryParameters: [:],
headers: [:], headers: [:],
body: [1, 2, 3] body: [1, 2, 3]
) ),
urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!),
publicKey: "",
responseType: NoResponse.self,
timeout: 0
) )
] ]
) )
let childRequestData: Data = try! JSONEncoder().encode(request.requests[0]) let requestData: Data = try! JSONEncoder().encode(request)
let childRequestString: String? = String(data: childRequestData, encoding: .utf8) let requestString: String? = String(data: requestData, encoding: .utf8)
expect(childRequestString) expect(requestString)
.to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"bytes\":[1,2,3]}")) .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"bytes\":[1,2,3]}]"))
} }
it("successfully encodes a JSON body") { it("successfully encodes a JSON body") {
request = OpenGroupAPI.BatchRequest( request = OpenGroupAPI.BatchRequest(
requests: [ requests: [
OpenGroupAPI.BatchRequest.Info( OpenGroupAPI.PreparedSendData<NoResponse>(
request: Request<TestType, OpenGroupAPI.Endpoint>( request: Request<TestType, OpenGroupAPI.Endpoint>(
method: .get, method: .get,
server: "testServer", server: "testServer",
@ -116,64 +88,93 @@ class BatchRequestInfoSpec: QuickSpec {
queryParameters: [:], queryParameters: [:],
headers: [:], headers: [:],
body: TestType(stringValue: "testValue") body: TestType(stringValue: "testValue")
) ),
urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!),
publicKey: "",
responseType: NoResponse.self,
timeout: 0
) )
] ]
) )
let childRequestData: Data = try! JSONEncoder().encode(request.requests[0]) let requestData: Data = try! JSONEncoder().encode(request)
let childRequestString: String? = String(data: childRequestData, encoding: .utf8) let requestString: String? = String(data: requestData, encoding: .utf8)
expect(childRequestString) expect(requestString)
.to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}")) .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}]"))
}
it("strips authentication headers") {
let httpRequest: Request<NoBody, OpenGroupAPI.Endpoint> = Request<NoBody, OpenGroupAPI.Endpoint>(
method: .get,
server: "testServer",
endpoint: .batch,
queryParameters: [:],
headers: [
"TestHeader": "Test",
HTTPHeader.sogsPubKey: "A",
HTTPHeader.sogsTimestamp: "B",
HTTPHeader.sogsNonce: "C",
HTTPHeader.sogsSignature: "D"
],
body: nil
)
request = OpenGroupAPI.BatchRequest(
requests: [
OpenGroupAPI.PreparedSendData<NoResponse>(
request: httpRequest,
urlRequest: try! httpRequest.generateUrlRequest(),
publicKey: "",
responseType: NoResponse.self,
timeout: 0
)
]
)
let requestData: Data = try! JSONEncoder().encode(request)
let requestString: String? = String(data: requestData, encoding: .utf8)
expect(requestString)
.toNot(contain([
HTTPHeader.sogsPubKey,
HTTPHeader.sogsTimestamp,
HTTPHeader.sogsNonce,
HTTPHeader.sogsSignature
]))
} }
} }
}
// MARK: - BatchRequest.Info
describe("a BatchRequest.Info") {
var request: Request<TestType, OpenGroupAPI.Endpoint>!
beforeEach { it("does not strip non authentication headers") {
request = Request( let httpRequest: Request<NoBody, OpenGroupAPI.Endpoint> = Request<NoBody, OpenGroupAPI.Endpoint>(
method: .get, method: .get,
server: "testServer", server: "testServer",
endpoint: .batch, endpoint: .batch,
queryParameters: [:], queryParameters: [:],
headers: [:], headers: [
body: TestType(stringValue: "testValue") "TestHeader": "Test",
HTTPHeader.sogsPubKey: "A",
HTTPHeader.sogsTimestamp: "B",
HTTPHeader.sogsNonce: "C",
HTTPHeader.sogsSignature: "D"
],
body: nil
) )
} request = OpenGroupAPI.BatchRequest(
requests: [
it("initializes correctly when given a request") { OpenGroupAPI.PreparedSendData<NoResponse>(
let requestInfo: OpenGroupAPI.BatchRequest.Info = OpenGroupAPI.BatchRequest.Info( request: httpRequest,
request: request urlRequest: try! httpRequest.generateUrlRequest(),
publicKey: "",
responseType: NoResponse.self,
timeout: 0
)
]
) )
expect(requestInfo.endpoint.path).to(equal(request.endpoint.path)) let requestData: Data = try! JSONEncoder().encode(request)
expect(requestInfo.responseType == HTTP.BatchSubResponse<NoResponse>.self).to(beTrue()) let requestString: String? = String(data: requestData, encoding: .utf8)
}
it("initializes correctly when given a request and a response type") {
let requestInfo: OpenGroupAPI.BatchRequest.Info = OpenGroupAPI.BatchRequest.Info(
request: request,
responseType: TestType.self
)
expect(requestInfo.endpoint.path).to(equal(request.endpoint.path))
expect(requestInfo.responseType == HTTP.BatchSubResponse<TestType>.self).to(beTrue())
}
}
// MARK: - Convenience
// MARK: --Decodable
describe("a Decodable") {
it("decodes correctly") {
let jsonData: Data = "{\"stringValue\":\"testValue\"}".data(using: .utf8)!
let result: TestType? = try? TestType.decoded(from: jsonData)
expect(result).to(equal(TestType(stringValue: "testValue"))) expect(requestString)
.to(contain("\"TestHeader\":\"Test\""))
} }
} }
} }

@ -28,7 +28,7 @@ class OpenGroupAPISpec: QuickSpec {
var disposables: [AnyCancellable] = [] var disposables: [AnyCancellable] = []
var response: (ResponseInfoType, Codable)? = nil var response: (ResponseInfoType, Codable)? = nil
var pollResponse: (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: Codable])? var pollResponse: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)?
var error: Error? var error: Error?
describe("an OpenGroupAPI") { describe("an OpenGroupAPI") {
@ -186,8 +186,8 @@ class OpenGroupAPISpec: QuickSpec {
it("generates the correct request") { it("generates the correct request") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -195,6 +195,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -221,8 +222,8 @@ class OpenGroupAPISpec: QuickSpec {
it("retrieves recent messages if there was no last message") { it("retrieves recent messages if there was no last message") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -230,6 +231,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -250,8 +252,8 @@ class OpenGroupAPISpec: QuickSpec {
} }
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -259,6 +261,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -279,8 +282,8 @@ class OpenGroupAPISpec: QuickSpec {
} }
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -288,6 +291,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -308,8 +312,8 @@ class OpenGroupAPISpec: QuickSpec {
} }
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: true, hasPerformedInitialPoll: true,
@ -317,6 +321,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -340,8 +345,8 @@ class OpenGroupAPISpec: QuickSpec {
it("does not call the inbox and outbox endpoints") { it("does not call the inbox and outbox endpoints") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -349,6 +354,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -439,8 +445,8 @@ class OpenGroupAPISpec: QuickSpec {
it("includes the inbox and outbox endpoints") { it("includes the inbox and outbox endpoints") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -448,6 +454,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -466,8 +473,8 @@ class OpenGroupAPISpec: QuickSpec {
it("retrieves recent inbox messages if there was no last message") { it("retrieves recent inbox messages if there was no last message") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: true, hasPerformedInitialPoll: true,
@ -475,6 +482,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -495,8 +503,8 @@ class OpenGroupAPISpec: QuickSpec {
} }
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: true, hasPerformedInitialPoll: true,
@ -504,6 +512,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -519,8 +528,8 @@ class OpenGroupAPISpec: QuickSpec {
it("retrieves recent outbox messages if there was no last message") { it("retrieves recent outbox messages if there was no last message") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: true, hasPerformedInitialPoll: true,
@ -528,6 +537,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -548,8 +558,8 @@ class OpenGroupAPISpec: QuickSpec {
} }
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: true, hasPerformedInitialPoll: true,
@ -557,6 +567,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -609,8 +620,8 @@ class OpenGroupAPISpec: QuickSpec {
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -618,6 +629,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -639,8 +651,8 @@ class OpenGroupAPISpec: QuickSpec {
it("errors when no data is returned") { it("errors when no data is returned") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -648,6 +660,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -668,8 +681,8 @@ class OpenGroupAPISpec: QuickSpec {
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -677,6 +690,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -697,8 +711,8 @@ class OpenGroupAPISpec: QuickSpec {
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -706,6 +720,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -726,8 +741,8 @@ class OpenGroupAPISpec: QuickSpec {
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -735,6 +750,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -787,8 +803,8 @@ class OpenGroupAPISpec: QuickSpec {
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.poll( try OpenGroupAPI.preparedPoll(
db, db,
server: "testserver", server: "testserver",
hasPerformedInitialPoll: false, hasPerformedInitialPoll: false,
@ -796,6 +812,7 @@ class OpenGroupAPISpec: QuickSpec {
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in pollResponse = result }) .handleEvents(receiveOutput: { result in pollResponse = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -985,17 +1002,18 @@ class OpenGroupAPISpec: QuickSpec {
} }
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
var response: OpenGroupAPI.CapabilitiesAndRoomResponse? var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)?
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.capabilitiesAndRoom( try OpenGroupAPI.preparedCapabilitiesAndRoom(
db, db,
for: "testRoom", for: "testRoom",
on: "testserver", on: "testserver",
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in response = result }) .handleEvents(receiveOutput: { result in response = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -1040,18 +1058,18 @@ class OpenGroupAPISpec: QuickSpec {
} }
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
var response: OpenGroupAPI.CapabilitiesAndRoomResponse? var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)?
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI try OpenGroupAPI.preparedCapabilitiesAndRoom(
.capabilitiesAndRoom( db,
db, for: "testRoom",
for: "testRoom", on: "testserver",
on: "testserver", using: dependencies
using: dependencies )
)
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in response = result }) .handleEvents(receiveOutput: { result in response = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -1112,18 +1130,18 @@ class OpenGroupAPISpec: QuickSpec {
} }
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
var response: OpenGroupAPI.CapabilitiesAndRoomResponse? var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)?
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI try OpenGroupAPI.preparedCapabilitiesAndRoom(
.capabilitiesAndRoom( db,
db, for: "testRoom",
for: "testRoom", on: "testserver",
on: "testserver", using: dependencies
using: dependencies )
)
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in response = result }) .handleEvents(receiveOutput: { result in response = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -1201,17 +1219,18 @@ class OpenGroupAPISpec: QuickSpec {
} }
dependencies = dependencies.with(onionApi: TestApi.self) dependencies = dependencies.with(onionApi: TestApi.self)
var response: OpenGroupAPI.CapabilitiesAndRoomResponse? var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)?
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI.capabilitiesAndRoom( try OpenGroupAPI.preparedCapabilitiesAndRoom(
db, db,
for: "testRoom", for: "testRoom",
on: "testserver", on: "testserver",
using: dependencies using: dependencies
) )
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in response = result }) .handleEvents(receiveOutput: { result in response = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -2809,7 +2828,7 @@ class OpenGroupAPISpec: QuickSpec {
} }
context("when banning and deleting all messages for a user") { context("when banning and deleting all messages for a user") {
var response: (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: ResponseInfoType])? var response: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)?
beforeEach { beforeEach {
class TestApi: TestOnionRequestAPI { class TestApi: TestOnionRequestAPI {
@ -2845,16 +2864,16 @@ class OpenGroupAPISpec: QuickSpec {
it("generates the request and handles the response correctly") { it("generates the request and handles the response correctly") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI try OpenGroupAPI.preparedUserBanAndDeleteAllMessages(
.userBanAndDeleteAllMessages( db,
db, sessionId: "testUserId",
sessionId: "testUserId", in: "testRoom",
in: "testRoom", on: "testserver",
on: "testserver", using: dependencies
using: dependencies )
)
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in response = result }) .handleEvents(receiveOutput: { result in response = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)
@ -2874,16 +2893,16 @@ class OpenGroupAPISpec: QuickSpec {
it("bans the user from the specified room rather than globally") { it("bans the user from the specified room rather than globally") {
mockStorage mockStorage
.readPublisherFlatMap { db in .readPublisher { db in
OpenGroupAPI try OpenGroupAPI.preparedUserBanAndDeleteAllMessages(
.userBanAndDeleteAllMessages( db,
db, sessionId: "testUserId",
sessionId: "testUserId", in: "testRoom",
in: "testRoom", on: "testserver",
on: "testserver", using: dependencies
using: dependencies )
)
} }
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.handleEvents(receiveOutput: { result in response = result }) .handleEvents(receiveOutput: { result in response = result })
.mapError { error.setting(to: $0) } .mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables) .sinkAndStore(in: &disposables)

@ -14,7 +14,7 @@ internal extension SnodeAPI {
// MARK: - BatchRequest.Info // MARK: - BatchRequest.Info
struct Info { struct Info {
public let responseType: Codable.Type public let responseType: Decodable.Type
fileprivate let child: Child fileprivate let child: Child
public init<T: Encodable, R: Codable>(request: SnodeRequest<T>, responseType: R.Type) { public init<T: Encodable, R: Codable>(request: SnodeRequest<T>, responseType: R.Type) {

@ -38,11 +38,11 @@ public extension AES.GCM {
/// - Note: Sync. Don't call from the main thread. /// - Note: Sync. Don't call from the main thread.
static func generateSymmetricKey(x25519PublicKey: Data, x25519PrivateKey: Data) throws -> Data { static func generateSymmetricKey(x25519PublicKey: Data, x25519PrivateKey: Data) throws -> Data {
#if DEBUG
if Thread.isMainThread { if Thread.isMainThread {
#if DEBUG
preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.")
#endif
} }
#endif
guard let sharedSecret: Data = try? Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, privateKey: x25519PrivateKey) else { guard let sharedSecret: Data = try? Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, privateKey: x25519PrivateKey) else {
throw Error.sharedSecretGenerationFailed throw Error.sharedSecretGenerationFailed
} }
@ -58,11 +58,11 @@ public extension AES.GCM {
/// - Note: Sync. Don't call from the main thread. /// - Note: Sync. Don't call from the main thread.
static func decrypt(_ nonceAndCiphertext: Data, with symmetricKey: Data) throws -> Data { static func decrypt(_ nonceAndCiphertext: Data, with symmetricKey: Data) throws -> Data {
#if DEBUG
if Thread.isMainThread { if Thread.isMainThread {
#if DEBUG
preconditionFailure("It's illegal to call decrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") preconditionFailure("It's illegal to call decrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.")
#endif
} }
#endif
return try AES.GCM.open( return try AES.GCM.open(
try AES.GCM.SealedBox(combined: nonceAndCiphertext), try AES.GCM.SealedBox(combined: nonceAndCiphertext),
@ -72,11 +72,11 @@ public extension AES.GCM {
/// - Note: Sync. Don't call from the main thread. /// - Note: Sync. Don't call from the main thread.
static func encrypt(_ plaintext: Data, with symmetricKey: Data) throws -> Data { static func encrypt(_ plaintext: Data, with symmetricKey: Data) throws -> Data {
#if DEBUG
if Thread.isMainThread { if Thread.isMainThread {
#if DEBUG
preconditionFailure("It's illegal to call encrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") preconditionFailure("It's illegal to call encrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.")
#endif
} }
#endif
let nonceData: Data = try Randomness.generateRandomBytes(numberBytes: ivSize) let nonceData: Data = try Randomness.generateRandomBytes(numberBytes: ivSize)
let sealedData: AES.GCM.SealedBox = try AES.GCM.seal( let sealedData: AES.GCM.SealedBox = try AES.GCM.seal(
@ -94,11 +94,11 @@ public extension AES.GCM {
/// - Note: Sync. Don't call from the main thread. /// - Note: Sync. Don't call from the main thread.
static func encrypt(_ plaintext: Data, for hexEncodedX25519PublicKey: String) throws -> EncryptionResult { static func encrypt(_ plaintext: Data, for hexEncodedX25519PublicKey: String) throws -> EncryptionResult {
#if DEBUG
if Thread.isMainThread { if Thread.isMainThread {
#if DEBUG
preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.")
#endif
} }
#endif
let x25519PublicKey = Data(hex: hexEncodedX25519PublicKey) let x25519PublicKey = Data(hex: hexEncodedX25519PublicKey)
let ephemeralKeyPair = Curve25519.generateKeyPair() let ephemeralKeyPair = Curve25519.generateKeyPair()
let symmetricKey = try generateSymmetricKey(x25519PublicKey: x25519PublicKey, x25519PrivateKey: ephemeralKeyPair.privateKey) let symmetricKey = try generateSymmetricKey(x25519PublicKey: x25519PublicKey, x25519PrivateKey: ephemeralKeyPair.privateKey)

@ -4,18 +4,63 @@ import Foundation
import Combine import Combine
public extension HTTP { public extension HTTP {
typealias BatchResponseTypes = [Codable.Type]
// MARK: - BatchResponse // MARK: - BatchResponse
struct BatchResponse { struct BatchResponse {
public let info: ResponseInfoType public let info: ResponseInfoType
public let responses: [Codable] public let responses: [Decodable]
public static func decodingResponses(
from data: Data?,
as types: [Decodable.Type],
requireAllResults: Bool,
using dependencies: Dependencies = Dependencies()
) throws -> [Decodable] {
// Need to split the data into an array of data so each item can be Decoded correctly
guard let data: Data = data else { throw HTTPError.parsingFailed }
guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else {
throw HTTPError.parsingFailed
}
let dataArray: [Data]
switch jsonObject {
case let anyArray as [Any]:
dataArray = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) }
guard !requireAllResults || dataArray.count == types.count else {
throw HTTPError.parsingFailed
}
case let anyDict as [String: Any]:
guard
let resultsArray: [Data] = (anyDict["results"] as? [Any])?
.compactMap({ try? JSONSerialization.data(withJSONObject: $0) }),
(
!requireAllResults ||
resultsArray.count == types.count
)
else { throw HTTPError.parsingFailed }
dataArray = resultsArray
default: throw HTTPError.parsingFailed
}
return try zip(dataArray, types)
.map { data, type in try type.decoded(from: data, using: dependencies) }
}
} }
// MARK: - BatchSubResponse<T> // MARK: - BatchSubResponse<T>
struct BatchSubResponse<T: Codable>: BatchSubResponseType { struct BatchSubResponse<T: Decodable>: BatchSubResponseType {
public enum CodingKeys: String, CodingKey {
case code
case headers
case body
}
/// The numeric http response code (e.g. 200 for success) /// The numeric http response code (e.g. 200 for success)
public let code: Int public let code: Int
@ -42,7 +87,7 @@ public extension HTTP {
} }
} }
public protocol BatchSubResponseType: Codable { public protocol BatchSubResponseType: Decodable {
var code: Int { get } var code: Int { get }
var headers: [String: String] { get } var headers: [String: String] { get }
var failedToParseBody: Bool { get } var failedToParseBody: Bool { get }
@ -52,6 +97,8 @@ extension BatchSubResponseType {
public var responseInfo: ResponseInfoType { HTTP.ResponseInfo(code: code, headers: headers) } public var responseInfo: ResponseInfoType { HTTP.ResponseInfo(code: code, headers: headers) }
} }
extension HTTP.BatchSubResponse: Encodable where T: Encodable {}
public extension HTTP.BatchSubResponse { public extension HTTP.BatchSubResponse {
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self) let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
@ -80,48 +127,20 @@ public extension Decodable {
public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error {
func decoded( func decoded(
as types: HTTP.BatchResponseTypes, as types: [Decodable.Type],
requireAllResults: Bool = true, requireAllResults: Bool = true,
using dependencies: Dependencies = Dependencies() using dependencies: Dependencies = Dependencies()
) -> AnyPublisher<HTTP.BatchResponse, Error> { ) -> AnyPublisher<HTTP.BatchResponse, Error> {
self self
.tryMap { responseInfo, maybeData -> HTTP.BatchResponse in .tryMap { responseInfo, maybeData -> HTTP.BatchResponse in
// Need to split the data into an array of data so each item can be Decoded correctly HTTP.BatchResponse(
guard let data: Data = maybeData else { throw HTTPError.parsingFailed }
guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else {
throw HTTPError.parsingFailed
}
let dataArray: [Data]
switch jsonObject {
case let anyArray as [Any]:
dataArray = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) }
guard !requireAllResults || dataArray.count == types.count else {
throw HTTPError.parsingFailed
}
case let anyDict as [String: Any]:
guard
let resultsArray: [Data] = (anyDict["results"] as? [Any])?
.compactMap({ try? JSONSerialization.data(withJSONObject: $0) }),
(
!requireAllResults ||
resultsArray.count == types.count
)
else { throw HTTPError.parsingFailed }
dataArray = resultsArray
default: throw HTTPError.parsingFailed
}
// TODO: Remove the 'Swift.'
return HTTP.BatchResponse(
info: responseInfo, info: responseInfo,
responses: try Swift.zip(dataArray, types) responses: try HTTP.BatchResponse.decodingResponses(
.map { data, type in try type.decoded(from: data, using: dependencies) } from: maybeData,
as: types,
requireAllResults: requireAllResults,
using: dependencies
)
) )
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()

@ -47,8 +47,8 @@ public struct Version: Comparable {
} }
public static func < (lhs: Version, rhs: Version) -> Bool { public static func < (lhs: Version, rhs: Version) -> Bool {
guard lhs.major >= rhs.major else { return true } guard lhs.major == rhs.major else { return (lhs.major < rhs.major) }
guard lhs.minor >= rhs.minor else { return true } guard lhs.minor == rhs.minor else { return (lhs.minor < rhs.minor) }
return (lhs.patch < rhs.patch) return (lhs.patch < rhs.patch)
} }

@ -54,11 +54,15 @@ class VersionSpec: QuickSpec {
} }
it("returns correctly for a complex major difference") { it("returns correctly for a complex major difference") {
let version1: Version = Version.from("2.90.90") let version1a: Version = Version.from("2.90.90")
let version2: Version = Version.from("10.0.0") let version2a: Version = Version.from("10.0.0")
let version1b: Version = Version.from("0.7.2")
let version2b: Version = Version.from("5.0.2")
expect(version1 < version2).to(beTrue()) expect(version1a < version2a).to(beTrue())
expect(version2 > version1).to(beTrue()) expect(version2a > version1a).to(beTrue())
expect(version1b < version2b).to(beTrue())
expect(version2b > version1b).to(beTrue())
} }
it("returns correctly for a simple minor difference") { it("returns correctly for a simple minor difference") {
@ -70,11 +74,15 @@ class VersionSpec: QuickSpec {
} }
it("returns correctly for a complex minor difference") { it("returns correctly for a complex minor difference") {
let version1: Version = Version.from("90.2.90") let version1a: Version = Version.from("90.2.90")
let version2: Version = Version.from("90.10.0") let version2a: Version = Version.from("90.10.0")
let version1b: Version = Version.from("2.0.7")
let version2b: Version = Version.from("2.5.0")
expect(version1 < version2).to(beTrue()) expect(version1a < version2a).to(beTrue())
expect(version2 > version1).to(beTrue()) expect(version2a > version1a).to(beTrue())
expect(version1b < version2b).to(beTrue())
expect(version2b > version1b).to(beTrue())
} }
it("returns correctly for a simple patch difference") { it("returns correctly for a simple patch difference") {
@ -86,11 +94,15 @@ class VersionSpec: QuickSpec {
} }
it("returns correctly for a complex patch difference") { it("returns correctly for a complex patch difference") {
let version1: Version = Version.from("90.90.2") let version1a: Version = Version.from("90.90.2")
let version2: Version = Version.from("90.90.10") let version2a: Version = Version.from("90.90.10")
let version1b: Version = Version.from("2.5.0")
let version2b: Version = Version.from("2.5.7")
expect(version1 < version2).to(beTrue()) expect(version1a < version2a).to(beTrue())
expect(version2 > version1).to(beTrue()) expect(version2a > version1a).to(beTrue())
expect(version1b < version2b).to(beTrue())
expect(version2b > version1b).to(beTrue())
} }
} }
} }

Loading…
Cancel
Save