Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@W-15363709 Changes to RestAPI for modern Async/Await #3806

Merged
merged 9 commits into from
Jan 24, 2025
Original file line number Diff line number Diff line change
@@ -36,6 +36,7 @@ public enum RestClientError: Error {
case apiFailed(response: Any?, underlyingError: Error, urlResponse: URLResponse?)
case decodingFailed(underlyingError: Error)
case jsonSerialization(underlyingError: Error)
case invalidRequest(String)
}

public struct RestResponse {
@@ -115,6 +116,7 @@ extension RestClient {
/// Execute a prebuilt request.
/// - Parameter request: `RestRequest` object.
/// - Parameter completionBlock: `Result` block that handles the server's response.
@available(*, deprecated, message: "Deprecated in Salesforce Mobile SDK 13.0 and will be removed in Salesforce Mobile SDK 14.0. Use the async/await version of `send(request:)` instead.")
public func send(request: RestRequest, _ completionBlock: @escaping (Result<RestResponse, RestClientError>) -> Void) {
request.parseResponse = false
__send(request, failureBlock: { (rawResponse, error, urlResponse) in
@@ -131,10 +133,40 @@ extension RestClient {
})
}

/// Execute a prebuilt request.
/// - Parameter request: The `RestRequest` object containing the request details.
/// - Returns: A `RestResponse` object containing the response data and metadata.
/// - Throws: A `RestClientError` if the request fails.
public func send(request: RestRequest) async throws -> RestResponse {
request.parseResponse = false

return try await withCheckedThrowingContinuation { continuation in
__send(request,
failureBlock: { rawResponse, error, urlResponse in
let apiError = RestClientError.apiFailed(
response: rawResponse,
underlyingError: error ?? RestClientError.apiResponseIsEmpty,
urlResponse: urlResponse
)
continuation.resume(throwing: apiError)
},
successBlock: { rawResponse, urlResponse in
if let data = rawResponse as? Data,
let urlResponse = urlResponse {
let result = RestResponse(data: data, urlResponse: urlResponse)
continuation.resume(returning: result)
} else {
continuation.resume(throwing: RestClientError.apiResponseIsEmpty)
}
})
}
}

/// Execute a prebuilt composite request.
/// - Parameter compositeRequest: `CompositeRequest` object containing the array of subrequests to execute.
/// - Parameter completionBlock: `Result` block that handles the server's response.
/// - See [Composite](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_composite_composite.htm).
@available(*, deprecated, message: "Deprecated in Salesforce Mobile SDK 13.0 and will be removed in Salesforce Mobile SDK 14.0. Use the async/await version of `send(compositeRequest:)` instead.")
public func send(compositeRequest: CompositeRequest, _ completionBlock: @escaping (Result<CompositeResponse, RestClientError>) -> Void) {
compositeRequest.parseResponse = false
__send(compositeRequest, failureBlock: { (response, error, urlResponse) in
@@ -145,20 +177,64 @@ extension RestClient {
})
}

public func send(compositeRequest: CompositeRequest) async throws -> CompositeResponse {
compositeRequest.parseResponse = false

return try await withCheckedThrowingContinuation { continuation in
__send(compositeRequest, failureBlock: { (response, error, urlResponse) in
let apiError = RestClientError.apiFailed(response: response, underlyingError: error ?? RestClientError.apiResponseIsEmpty, urlResponse: urlResponse)
continuation.resume(throwing: apiError)
}, successBlock: { (response, _) in
continuation.resume(returning: response)
})
}

}

/// Execute a prebuilt batch of requests.
/// - Parameter batchRequest: `BatchRequest` object containing the array of subrequests to execute.
/// - Parameter completionBlock: `Result` block that handles the server's response.
/// - See [Batch](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_composite_batch.htm).
public func send(batchRequest: BatchRequest, _ completionBlock: @escaping (Result<BatchResponse, RestClientError>) -> Void ) {
@available(*, deprecated, message: "Deprecated in Salesforce Mobile SDK 13.0 and will be removed in Salesforce Mobile SDK 14.0. Use the async/await version of `send(batchRequest:)` instead.")
public func send(batchRequest: BatchRequest, _ completionBlock: @escaping (Result<BatchResponse, RestClientError>) -> Void) {
batchRequest.parseResponse = false
__send(batchRequest, failureBlock: { (response, error, urlResponse) in
let apiError = RestClientError.apiFailed(response: response, underlyingError: error ?? RestClientError.apiResponseIsEmpty, urlResponse: urlResponse)
completionBlock(Result.failure(apiError))
}, successBlock: { (response, _) in
completionBlock(Result.success(response))
__send(batchRequest,
failureBlock: { (response, error, urlResponse) in
let apiError = RestClientError.apiFailed(
response: response,
underlyingError: error ?? RestClientError.apiResponseIsEmpty,
urlResponse: urlResponse
)
completionBlock(.failure(apiError))
},
successBlock: { (response, _) in
completionBlock(.success(response))
})
}


/// Execute a prebuilt batch of requests.
/// - Parameter batchRequest: `BatchRequest` object containing the array of subrequests to execute.
/// - Returns: A `BatchResponse` object containing the responses for the batch requests.
/// - Throws: A `RestClientError` if the request fails.
/// - See [Batch](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_composite_batch.htm).
public func send(batchRequest: BatchRequest) async throws -> BatchResponse {
batchRequest.parseResponse = false

return try await withCheckedThrowingContinuation { continuation in
__send(batchRequest,
failureBlock: { response, error, urlResponse in
let apiError = RestClientError.apiFailed(
response: response,
underlyingError: error ?? RestClientError.apiResponseIsEmpty,
urlResponse: urlResponse
)
continuation.resume(throwing: apiError)
}, successBlock: { response, _ in
continuation.resume(returning: response)
})
}
}

// MARK: Record Convience API - Pure Swift 4+

/// This method provides a reusuable, generic pipeline for retrieving records
@@ -177,26 +253,52 @@ extension RestClient {
///
/// This method relies on the passed parameter ofModelType to infer the generic Record's
/// concrete type.
@available(*, deprecated, message: "Deprecated in Salesforce Mobile SDK 13.0 and will be removed in Salesforce Mobile SDK 14.0. Use the async/await version of `fetchRecords(ofModelType:forRequest:withDecoder:)` instead.")
public func fetchRecords<Record: Decodable>(ofModelType modelType: Record.Type,
forRequest request: RestRequest,
withDecoder decoder: JSONDecoder = .init(),
_ completionBlock: @escaping (Result<QueryResponse<Record>, RestClientError>) -> Void) {
guard request.isQueryRequest else { return }
RestClient.shared.send(request: request) { result in
switch result {
case .success(let response):
forRequest request: RestRequest,
withDecoder decoder: JSONDecoder = .init(),
_ completionBlock: @escaping (Result<QueryResponse<Record>, RestClientError>) -> Void) {
guard request.isQueryRequest else { return }
RestClient.shared.send(request: request) { result in
switch result {
case .success(let response):
do {
let wrapper = try response.asDecodable(type: QueryResponse<Record>.self, decoder: decoder)
completionBlock(.success(wrapper))
let wrapper = try response.asDecodable(type: QueryResponse<Record>.self, decoder: decoder)
completionBlock(.success(wrapper))
} catch {
completionBlock(.success(QueryResponse<Record>(totalSize: 0, done: true, records: [])))
}
case .failure(let err):
completionBlock(.failure(err))
}
completionBlock(.success(QueryResponse<Record>(totalSize: 0, done: true, records: [])))
}
case .failure(let err):
completionBlock(.failure(err))
}
}
}


/// Fetches records of a specific model type using the provided request.
/// - Parameters:
/// - modelType: The type of the model to decode the records into.
/// - request: The `RestRequest` object representing the query.
/// - decoder: The `JSONDecoder` used to decode the response. Defaults to `.init()`.
/// - Returns: A `QueryResponse` object containing the query results.
/// - Throws: A `RestClientError` if the request fails or the response cannot be decoded.
public func fetchRecords<Record: Decodable>(ofModelType modelType: Record.Type,
forRequest request: RestRequest,
withDecoder decoder: JSONDecoder = .init()
) async throws -> QueryResponse<Record> {
guard request.isQueryRequest else {
throw RestClientError.invalidRequest("Request is not a query request.")
}

do {
let response = try await RestClient.shared.send(request: request)
return try response.asDecodable(type: QueryResponse<Record>.self, decoder: decoder)
} catch _ as RestClientError {
return QueryResponse<Record>(totalSize: 0, done: true, records: [])
} catch {
throw error
}
}

/// This method provides a reusuable, generic pipeline for retrieving records
/// from Salesforce. It relys on Swift Generics, and type inference to determine what
/// models to create.
@@ -215,58 +317,83 @@ extension RestClient {
///
/// This method relies on the passed parameter ofModelType to infer the generic Record's
/// concrete type.
@available(*, deprecated, message: "Deprecated in Salesforce Mobile SDK 13.0 and will be removed in Salesforce Mobile SDK 14.0. Use the async/await version of `fetchRecords(ofModelType:forQuery:withApiVersion:withDecoder:)` instead.")
public func fetchRecords<Record: Decodable>(ofModelType modelType: Record.Type,
forQuery query: String,
withApiVersion version: String = SFRestDefaultAPIVersion,
withDecoder decoder: JSONDecoder = .init(),
_ completionBlock: @escaping (Result<QueryResponse<Record>, RestClientError>) -> Void) {
forQuery query: String,
withApiVersion version: String = SFRestDefaultAPIVersion,
withDecoder decoder: JSONDecoder = .init(),
_ completionBlock: @escaping (Result<QueryResponse<Record>, RestClientError>) -> Void) {
let request = RestClient.shared.request(forQuery: query, apiVersion: version)
guard request.isQueryRequest else { return }
return self.fetchRecords(ofModelType: modelType, forRequest: request, withDecoder: decoder, completionBlock)
}


/// Fetches records of a specific model type using the provided query.
/// - Parameters:
/// - modelType: The type of the model to decode the records into.
/// - query: The SOQL query string to execute.
/// - version: The API version to use. Defaults to `SFRestDefaultAPIVersion`.
/// - decoder: The `JSONDecoder` used to decode the response. Defaults to `.init()`.
/// - Returns: A `QueryResponse` object containing the query results.
/// - Throws: A `RestClientError` if the request fails or the response cannot be decoded.
public func fetchRecords<Record: Decodable>(
ofModelType modelType: Record.Type,
forQuery query: String,
withApiVersion version: String = SFRestDefaultAPIVersion,
withDecoder decoder: JSONDecoder = .init()
) async throws -> QueryResponse<Record> {
let request = RestClient.shared.request(forQuery: query, apiVersion: version)
return try await fetchRecords(ofModelType: modelType, forRequest: request, withDecoder: decoder)
}

}

extension RestClient {

public func publisher(for request: RestRequest) -> Future<RestResponse, RestClientError> {
return Future<RestResponse, RestClientError> { promise in
self.send(request: request) { (result) in
switch result {
case .success(let response):
Task {
do {
let response = try await self.send(request: request)
promise(.success(response))
case .failure(let error):
} catch let error as RestClientError {
promise(.failure(error))
} catch {
promise(.failure(RestClientError.apiFailed(response: nil, underlyingError: error, urlResponse: nil)))
}
}
}
}

public func publisher(for request: CompositeRequest) -> Future<CompositeResponse, RestClientError> {
return Future<CompositeResponse, RestClientError> { promise in
self.send(compositeRequest: request) { (result) in
switch result {
case .success(let response):
Task {
do {
let response = try await self.send(compositeRequest: request)
promise(.success(response))
case .failure(let error):
} catch let error as RestClientError {
promise(.failure(error))
} catch {
promise(.failure(.apiFailed(response: nil, underlyingError: error, urlResponse: nil)))
}
}
}
}

public func publisher(for request: BatchRequest) -> Future<BatchResponse, RestClientError> {
return Future<BatchResponse, RestClientError> { promise in
self.send(batchRequest: request) { (result) in
switch result {
case .success(let response):
promise(.success(response))
case .failure(let error):
promise(.failure(error))
return Future<BatchResponse, RestClientError> { promise in
Task {
do {
let response = try await self.send(batchRequest: request)
promise(.success(response))
} catch let error as RestClientError {
promise(.failure(error))
} catch {
promise(.failure(RestClientError.apiFailed(response: nil, underlyingError: error, urlResponse: nil)))
}
}
}
}
}

// MARK: Record Convience API - Swift & Combine

Original file line number Diff line number Diff line change
@@ -63,6 +63,19 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
let code: String
}

private func handleResponseForRequest<T>(
execute: () async throws -> T
) async -> Result<T, RestClientError> {
do {
let result = try await execute()
return .success(result)
} catch let error as RestClientError {
return .failure(error)
} catch {
return .failure(.apiFailed(response: nil, underlyingError: error, urlResponse: nil))
}
}

@objc public init(clientId: String, redirectUri: String, loginUrl: String, scene: UIScene?
) {
self.clientId = clientId
@@ -119,10 +132,8 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
authRequest.endpoint = ""

// First REST Call - Authorization
let authorizationResponse = await withCheckedContinuation { continuation in
RestClient.sharedGlobal.send(request: authRequest) { result in
continuation.resume(returning: result)
}
let authorizationResponse = await handleResponseForRequest {
try await RestClient.sharedGlobal.send(request: authRequest)
}

// Second REST Call - Access token request with code verifier
@@ -271,14 +282,10 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
startRegistrationRequestBodyString,
contentType: kHttpPostApplicationJsonContentType
)

// Submit the start registration request and fetch the response.
let startRegistrationResponse = await withCheckedContinuation { continuation in
RestClient.sharedGlobal.send(
request: startRegistrationRequest
) { result in
continuation.resume(returning: result)
}
let startRegistrationResponse = await handleResponseForRequest {
try await RestClient.sharedGlobal.send(request: startRegistrationRequest)
}

// React to the start registration response.
@@ -420,12 +427,8 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
contentType: kHttpPostApplicationJsonContentType
)

let startPasswordResetResponse = await withCheckedContinuation { continuation in
RestClient.sharedGlobal.send(
request: startPasswordResetRequest
) { result in
continuation.resume(returning: result)
}
let startPasswordResetResponse = await handleResponseForRequest {
try await RestClient.sharedGlobal.send(request: startPasswordResetRequest)
}

// React to the start password reset response.
@@ -485,12 +488,8 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
contentType: kHttpPostApplicationJsonContentType
)

let completePasswordResetResponse = await withCheckedContinuation { continuation in
RestClient.sharedGlobal.send(
request: completePasswordResetRequest
) { result in
continuation.resume(returning: result)
}
let completePasswordResetResponse = await handleResponseForRequest {
try await RestClient.shared.send(request: completePasswordResetRequest)
}

// React to the complete password reset response.
@@ -563,12 +562,8 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
)

// Submit the OTP request and fetch the OTP response.
let otpResponse = await withCheckedContinuation { continuation in
RestClient.sharedGlobal.send(
request: otpRequest
) { result in
continuation.resume(returning: result)
}
let otpResponse = await handleResponseForRequest {
try await RestClient.sharedGlobal.send(request: otpRequest)
}

// React to the OTP response.
@@ -653,12 +648,8 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
contentType: kHttpPostContentType)

// Submit the authorization request and fetch the authorization response.
let authorizationResponse = await withCheckedContinuation { continuation in
RestClient.sharedGlobal.send(
request: authorizationRequest
) { result in
continuation.resume(returning: result)
}
let authorizationResponse = await handleResponseForRequest {
try await RestClient.sharedGlobal.send(request: authorizationRequest)
}

// React to the authorization response.
@@ -916,12 +907,8 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
contentType: kHttpPostContentType)

// Submit the authorization request and fetch the authorization response.
let authorizationResponse = await withCheckedContinuation { continuation in
RestClient.sharedGlobal.send(
request: authorizationRequest
) { result in
continuation.resume(returning: result)
}
let authorizationResponse = await handleResponseForRequest {
try await RestClient.sharedGlobal.send(request: authorizationRequest)
}

// React to the authorization response.
@@ -965,12 +952,8 @@ public class NativeLoginManagerInternal: NSObject, NativeLoginManager {
contentType: kHttpPostContentType)

// Submit the access token request.
let tokenResponse = await withCheckedContinuation { continuation in
RestClient.sharedGlobal.send(
request: tokenRequest
) { tokenResponse in
continuation.resume(returning: tokenResponse)
}
let tokenResponse = await handleResponseForRequest {
try await RestClient.sharedGlobal.send(request: tokenRequest)
}

// React to the token response.
284 changes: 242 additions & 42 deletions libs/SalesforceSDKCore/SalesforceSDKCoreTests/RestClientTest.swift
Original file line number Diff line number Diff line change
@@ -65,21 +65,25 @@ class RestClientTests: XCTestCase {
XCTAssertNil(erroredResult,"Query call should not have failed")
}

func testQuery() {
let expectation = XCTestExpectation(description: "queryTest")
func testAsyncFetchRecordsNonCombine() async {
let request = RestClient.shared.request(forQuery: "select name from CONTACT", apiVersion: nil)

var erroredResult: RestClientError?
RestClient.shared.send(request: request) { result in
switch (result) {
case .failure(let error):
erroredResult = error
default: break
}
expectation.fulfill()
do {
let result = try await RestClient.shared.fetchRecords(ofModelType: TestContact.self, forRequest: request)
XCTAssertNotNil(result, "Query call should not have failed")
} catch {
XCTFail("Fetch Record should not throw an error")
}
}

func testQuery() async {
let request = RestClient.shared.request(forQuery: "select name from CONTACT", apiVersion: nil)
do {
let result = try await RestClient.shared.send(request: request)
XCTAssertNotNil(result, "Query call should not have failed")
} catch {
XCTFail("Fetch Record should not throw an error")
}
self.wait(for: [expectation], timeout: 10.0)
XCTAssertNil(erroredResult,"Query call should not have failed")
}

func testQueryWithDefaultBatchSize() {
@@ -96,6 +100,212 @@ class RestClientTests: XCTestCase {
XCTAssertNil(request2001.customHeaders?["@SForce-Query-Options"]);
}

func testAsyncCompositeRequest() async throws {
let accountName = self.generateRecordName()
let contactName = self.generateRecordName()
let apiVersion = RestClient.shared.apiVersion
let requestBuilder = CompositeRequestBuilder()
.add(RestClient.shared.requestForCreate(withObjectType: "Account", fields: ["Name": accountName], apiVersion: apiVersion), referenceId: "refAccount")
.add(RestClient.shared.requestForCreate(withObjectType: "Contact", fields: ["LastName": contactName,"AccountId": "@{refAccount.id}"], apiVersion: apiVersion), referenceId: "refContact")
.add(RestClient.shared.request(forQuery: "select Id, AccountId from Contact where LastName = '\(contactName)'", apiVersion: apiVersion), referenceId: "refQuery")
.setAllOrNone(true)

let compositeRequest = requestBuilder.buildCompositeRequest(apiVersion)
XCTAssertNotNil(compositeRequest, "Composite Request should not be nil")
XCTAssertNotNil(compositeRequest.allSubRequests.count == 3, "Composite Requests should have 3 requests")

do {
let compositeResponse = try await RestClient.shared.send(compositeRequest: compositeRequest)
XCTAssertNotNil(compositeResponse.subResponses, "Composite Sub Responses should not be nil")
XCTAssertTrue(3 == compositeResponse.subResponses.count, "Wrong number of results")
XCTAssertEqual(compositeResponse.subResponses[0].httpStatusCode, 201, "Wrong status for first request")
XCTAssertEqual(compositeResponse.subResponses[1].httpStatusCode, 201, "Wrong status for second request")
XCTAssertEqual(compositeResponse.subResponses[2].httpStatusCode, 200, "Wrong status for third request")
XCTAssertNotNil(compositeResponse.subResponses[0].body, "Subresponse must have a response body")
XCTAssertNotNil(compositeResponse.subResponses[1].body, "Subresponse must have a response body")
XCTAssertNotNil(compositeResponse.subResponses[2].body, "Subresponse must have a response body")

let resp1 = compositeResponse.subResponses[0].body as! [String:Any]
let resp2 = compositeResponse.subResponses[1].body as! [String:Any]
let resp3 = compositeResponse.subResponses[2].body as! [String:Any]
XCTAssertNotNil(resp1["id"] as? String, "Subresponse must have a Id in reponse body")
XCTAssertNotNil(resp2["id"] as? String, "Subresponse must have an Id in reponse body")

let accountID = try XCTUnwrap(resp1["id"] as? String)
let contactID = try XCTUnwrap(resp2["id"] as? String)
let queryRecords = try XCTUnwrap(resp3["records"] as? [[String:Any]])

XCTAssertNotNil(queryRecords, "Subresponse must have records in body")

let accountIDInQuery = queryRecords[0]["AccountId"] as! String
let contactIDInQuery = queryRecords[0]["Id"] as! String
// Query should have returned ids of newly created account and contact
XCTAssertTrue(1 == queryRecords.count, "Wrong number of results for query request")
XCTAssertTrue(accountID == accountIDInQuery, "Account id not returned by query")
XCTAssertTrue(contactID == contactIDInQuery, "Contact id not returned by query");
} catch {
XCTFail("Send Composite Request should not throw an error")
}
}

func testAsyncCompositeRequestFailure() async throws {
let accountName = self.generateRecordName()
let contactName = self.generateRecordName()
let apiVersion = RestClient.shared.apiVersion
let requestBuilder = CompositeRequestBuilder()
.add(RestClient.shared.requestForCreate(withObjectType: "Account", fields: ["Name": accountName], apiVersion: apiVersion), referenceId: "refAccount")
.add(RestClient.shared.requestForCreate(withObjectType: "Contact", fields: ["LastName": contactName,"AccountId": "@{refAccount.id}"], apiVersion: apiVersion), referenceId: "refContact")
.add(RestClient.shared.request(forQuery: "select Id, AccountId", apiVersion: apiVersion), referenceId: "refQuery") // bad request!
.setAllOrNone(true)

let compositeRequest = requestBuilder.buildCompositeRequest(apiVersion)
XCTAssertNotNil(compositeRequest, "Composite Request should not be nil")
XCTAssertNotNil(compositeRequest.allSubRequests.count == 3, "Composite Requests should have 3 requests")

do {
let compositeResponse = try await RestClient.shared.send(compositeRequest: compositeRequest)
XCTAssertNotNil(compositeResponse.subResponses, "Composite Sub Responses should not be nil")
XCTAssertTrue(3 == compositeResponse.subResponses.count, "Wrong number of results")
XCTAssertEqual(compositeResponse.subResponses[0].httpStatusCode, 400, "Wrong status for first request")
XCTAssertEqual(compositeResponse.subResponses[1].httpStatusCode, 400, "Wrong status for second request")
XCTAssertEqual(compositeResponse.subResponses[2].httpStatusCode, 400, "Wrong status for third request")
} catch {
XCTFail("Send Composite Request should not throw an error")
}
}

func testAsyncCompositeRequestPartialSuccess() async throws {
let accountName = self.generateRecordName()
let contactName = self.generateRecordName()
let apiVersion = RestClient.shared.apiVersion
let requestBuilder = CompositeRequestBuilder()
.add(RestClient.shared.requestForCreate(withObjectType: "Account", fields: ["Name": accountName], apiVersion: apiVersion), referenceId: "refAccount")
.add(RestClient.shared.requestForCreate(withObjectType: "Contact", fields: ["LastName": contactName,"AccountId": "@{refAccount.id}"], apiVersion: apiVersion), referenceId: "refContact")
.add(RestClient.shared.request(forQuery: "select Id, AccountId", apiVersion: apiVersion), referenceId: "refQuery") // bad request!
.setAllOrNone(false)

let compositeRequest = requestBuilder.buildCompositeRequest(apiVersion)
XCTAssertNotNil(compositeRequest, "Composite Request should not be nil")
XCTAssertNotNil(compositeRequest.allSubRequests.count == 3, "Composite Requests should have 3 requests")

do {
let compositeResponse = try await RestClient.shared.send(compositeRequest: compositeRequest)
XCTAssertNotNil(compositeResponse.subResponses, "Composite Sub Responses should not be nil")
XCTAssertTrue(3 == compositeResponse.subResponses.count, "Wrong number of results")
XCTAssertEqual(compositeResponse.subResponses[0].httpStatusCode, 201, "Wrong status for first request")
XCTAssertEqual(compositeResponse.subResponses[1].httpStatusCode, 201, "Wrong status for second request")
XCTAssertEqual(compositeResponse.subResponses[2].httpStatusCode, 400, "Wrong status for third request")
} catch {
XCTFail("Send Composite Request should not throw an error")
}
}

func testAsyncBatchRequest() async throws {
// Create account
let accountName = self.generateRecordName()
let contactName = self.generateRecordName()
let apiVersion = RestClient.shared.apiVersion

let requestBuilder = BatchRequestBuilder()
.add(RestClient.shared.requestForCreate(withObjectType: "Account", fields: ["Name": accountName], apiVersion: apiVersion))
.add(RestClient.shared.requestForCreate(withObjectType: "Contact", fields: ["LastName": contactName], apiVersion: apiVersion))
.add(RestClient.shared.request(forQuery: "select Id from Account where Name = '\(accountName)'", apiVersion: apiVersion))
.add(RestClient.shared.request(forQuery: "select Id from Contact where Name = '\(contactName)'", apiVersion: apiVersion))
.setHaltOnError(true)

let batchRequest = requestBuilder.buildBatchRequest(apiVersion)
XCTAssertNotNil(batchRequest, "Batch Request should not be nil")
XCTAssertTrue(batchRequest.batchRequests.count==4,"Batch Requests should have 4 requests")


do {
let batchResponse = try await RestClient.shared.send(batchRequest: batchRequest)
XCTAssertFalse(batchResponse.hasErrors, "BatchResponse results should not have any errors")
XCTAssertNotNil(batchResponse.results, "BatchResponse results should not be nil")
XCTAssertTrue(4 == batchResponse.results.count, "Wrong number of results")

XCTAssertNotNil(batchResponse.results[0] as? [String: Any], "BatchResponse result should be a dictionary")
XCTAssertNotNil(batchResponse.results[1] as? [String: Any], "BatchResponse results should be a dictionary")
XCTAssertNotNil(batchResponse.results[2] as? [String: Any], "BatchResponse results should be a dictionary")
XCTAssertNotNil(batchResponse.results[3] as? [String: Any], "BatchResponse results should be a dictionary")


let resp1 = batchResponse.results[0] as! [String: Any]
let resp2 = batchResponse.results[1] as! [String: Any]
let resp3 = batchResponse.results[2] as! [String: Any]
let resp4 = batchResponse.results[3] as! [String: Any]

XCTAssertTrue(resp1["statusCode"] as? Int == 201, "Wrong status for first request")
XCTAssertTrue(resp2["statusCode"] as? Int == 201, "Wrong status for first request")
XCTAssertTrue(resp3["statusCode"] as? Int == 200, "Wrong status for first request")
XCTAssertTrue(resp4["statusCode"] as? Int == 200, "Wrong status for first request")

let result1 = resp1["result"] as! [String: Any]
let result2 = resp2["result"] as! [String: Any]
let result3 = resp3["result"] as! [String: Any]
let result4 = resp4["result"] as! [String: Any]

let accountId = result1["id"] as? String
let contactId = result2["id"] as? String
let resFomfirstQuery = result3["records"] as? [[String: Any]]
let resFomSecondQuery = result4["records"] as? [[String: Any]]
XCTAssertNotNil(accountId)
XCTAssertNotNil(contactId)
XCTAssertNotNil(resFomfirstQuery)
XCTAssertNotNil(resFomSecondQuery)

let idFromFirstQuery = try XCTUnwrap(resFomfirstQuery?[0]["Id"] as? String)
let idFromSecondQuery = try XCTUnwrap(resFomSecondQuery?[0]["Id"] as? String)
XCTAssertTrue(accountId! == idFromFirstQuery, "Account id not returned by query")
XCTAssertTrue(contactId! == idFromSecondQuery, "Account id not returned by query")
} catch {
XCTFail("Send Batch Request should not throw an error")
}
}

func testAsyncBatchRequestStopOnFailure() async throws {
do {
// Create account
let accountName = self.generateRecordName()
let contactName = self.generateRecordName()
let apiVersion = RestClient.shared.apiVersion

let requestBuilder = BatchRequestBuilder()
.add(RestClient.shared.requestForCreate(withObjectType: "Account", fields: ["Name": accountName], apiVersion: apiVersion))
.add(RestClient.shared.requestForCreate(withObjectType: "Contact", fields: ["LastName": contactName], apiVersion: apiVersion))
.add(RestClient.shared.request(forQuery: "select Id from Account where Name ", apiVersion: apiVersion)) // bad query
.add(RestClient.shared.request(forQuery: "select Id from Contact where Name = '\(contactName)'", apiVersion: apiVersion))
.setHaltOnError(true)

let batchRequest = requestBuilder.buildBatchRequest(apiVersion)
XCTAssertNotNil(batchRequest, "Batch Request should not be nil")
XCTAssertTrue(batchRequest.batchRequests.count == 4, "Batch Requests should have 4 requests")

let batchResponse = try await RestClient.shared.send(batchRequest: batchRequest)
XCTAssertTrue(batchResponse.hasErrors, "BatchResponse results should not have any errors")
XCTAssertNotNil(batchResponse.results, "BatchResponse results should not be nil")
XCTAssertTrue(4 == batchResponse.results.count, "Wrong number of results")

XCTAssertNotNil(batchResponse.results[0] as? [String: Any], "BatchResponse result should be a dictionary")
XCTAssertNotNil(batchResponse.results[1] as? [String: Any], "BatchResponse results should be a dictionary")
XCTAssertNotNil(batchResponse.results[2] as? [String: Any], "BatchResponse results should be a dictionary")
XCTAssertNotNil(batchResponse.results[3] as? [String: Any], "BatchResponse results should be a dictionary")


let resp1 = batchResponse.results[0] as! [String: Any]
let resp2 = batchResponse.results[1] as! [String: Any]
let resp3 = batchResponse.results[2] as! [String: Any]
let resp4 = batchResponse.results[3] as! [String: Any]

XCTAssertTrue(resp1["statusCode"] as? Int == 201, "Wrong status for first request")
XCTAssertTrue(resp2["statusCode"] as? Int == 201, "Wrong status for first request")
XCTAssertTrue(resp3["statusCode"] as? Int == 400, "Wrong status for first request")
XCTAssertTrue(resp4["statusCode"] as? Int == 412, "Request processing should have stopped on error")
} catch {
XCTFail("Send Batch Request should not throw an error")
}
}

func testCompositeRequest() throws {
let expectation = XCTestExpectation(description: "compositeTest")
let accountName = self.generateRecordName()
@@ -415,46 +625,36 @@ class RestClientTests: XCTestCase {
XCTAssertTrue(resp4["statusCode"] as? Int == 200, "Request processing should have stopped on error")
}

func testDecodableResponse() {
let expectation = XCTestExpectation(description: "decodableResponseTest")
func testDecodableResponse() async throws {
let apiVersion = RestClient.shared.apiVersion

let query = "select Id from Account limit 5"
let request = RestClient.shared.request(forQuery: query, apiVersion: apiVersion)

var response: RestResponse?
var restClientError: Error?

RestClient.shared.send(request: request) { result in
defer { expectation.fulfill() }
switch (result) {
case .success(let resp):
response = resp
case .failure(let error):
restClientError = error
}
}
self.wait(for: [expectation], timeout: 20)
XCTAssertNil(restClientError, "Error should not have occurred")
XCTAssertNotNil(response, "RestResponse should not be nil")

struct Response: Decodable {
struct Record: Decodable {
struct Attributes: Decodable {
let type: String
let url: String
do {
let response = try await RestClient.shared.send(request: request)
XCTAssertNotNil(response, "RestResponse should not be nil")

struct Response: Decodable {
struct Record: Decodable {
struct Attributes: Decodable {
let type: String
let url: String
}

let attributes: Attributes
let Id: String
}

let attributes: Attributes
let Id: String
let totalSize: Int
let done: Bool
let records: [Record]
}

let totalSize: Int
let done: Bool
let records: [Record]
XCTAssertNoThrow(try response.asDecodable(type: Response.self), "RestResponse should be decodable")
} catch {
XCTFail("Send Batch Request should not throw an error")
}

XCTAssertNoThrow(try response?.asDecodable(type: Response.self), "RestResponse should be decodable")
}

private func generateRecordName() -> String {
Original file line number Diff line number Diff line change
@@ -568,13 +568,10 @@ class RootViewController: UIViewController {
}

let request = RestRequest(method: method, path: path, queryParams: queryParams)
RestClient.shared.send(request: request) { result in
switch result {
case .success(let response):
self.handleSuccess(request: request, response: response)
case .failure(let error):
self.handleError(request: request, error: error)
}

self.view.endEditing(true)
Copy link
Member

@bbirman bbirman Jan 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this change for? is the same call at line 562 still needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, that was a copy error. I take that out. Good catch on that one. 🙏

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bbirman OK, Fixed my goof. Thanks for catching that .

Task {
await handleResponseForRequest(request: request)
}
}

@@ -884,15 +881,21 @@ extension RootViewController: ActionTableViewDelegate {
return
}

if let sendRequest = request {
RestClient.shared.send(request: sendRequest) { result in
switch result {
case .success(let response):
self.handleSuccess(request: sendRequest, response: response)
case .failure(let error):
self.handleError(request: sendRequest, error: error)
}
if let requestToSend = request {
Task {
await handleResponseForRequest(request: requestToSend)
}
}
}

private func handleResponseForRequest(request: RestRequest) async {
do {
let response = try await RestClient.shared.send(request: request)
self.handleSuccess(request: request, response: response)
} catch let error as RestClientError {
self.handleError(request: request, error: error)
} catch {
self.handleError(request: request, error: RestClientError.invalidRequest(error.localizedDescription))
}
}
}