FFiBRe pronounced "fibre" is a Proof-of-Concept of bridging between Swift and Rust for async methods.
I showcase how we can bridge certain operation from Rust to FFI side (Swift side) and read the outcome of these operations in Rust - using callback pattern.
The implementation uses tokio::oneshot::channel for the callback.
This repo contains three examples:
- Networking
- File IO Read
- File IO Write
All examples have two versions:
- Callback based
- Async wrapped (translated to callback)
brew install kotlin
Important
To run tests in Kotlin you also need to download
- JNA (currently tested under version
5.13.0
) - Coroutines-JVM
- OkHttp For network requests
- Okio Transitive dependency for OkHttp
curl https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar
curl https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/1.8.0-RC2/kotlinx-coroutines-core-jvm-1.8.0-RC2.jar
curl https://repo1.maven.org/maven2/com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.jar
curl https://repo1.maven.org/maven2/com/squareup/okio/okio/3.7.0/okio-3.7.0.jar
Install direnv
in order to automatically load CLASSPATH
and JAVA_OPTS
in .envrc
, so that you can run Kotlin bindgen tests from cli using the command in the bottom of this document - i.e. without having to export `CLASSPATH``.
Run test:
cargo test
Which should output something like:
🚀🗂️ SWIFT 'test_file_io' start
✅🗂️ writeToNewOrExtendExistingFile CB outcome: didWrite(alreadyExisted: false)
✅🗂️ writeToNewOrExtendExistingFile ASYNC outcome: didWrite(alreadyExisted: true)
✅🗂️ writeToNewOrExtendExistingFile CB outcome: didWrite(alreadyExisted: true)
🏁🗂️ SWIFT 'test_file_io' done
🚀🛜 SWIFT 'test_networking' start
🛜 ✅ SWIFT CB balance: 890.88637929049
🛜 ✅ SWIFT ASYNC balance: 890.88637929049
🏁🛜 SWIFT 'test_networking' done
For each FFI interface you need to declare:
- Request - some operation we want the FFI side to execute using the
executor
(see below). - Response (
Ok
value) - some value produced by theexecutor
as a response to therequest
. - Failure (
Error
value) - a type representing all kinds of failures that can happen FFI side during theexecutor
s running of the request. - Outcome (
Result<Response, Failure>
) - a result type aggregating both responses and failures, which theexecutor
pass back to theoutcomeListener
usingnotifyOutcome
. - OutcomeListener - which has a single function the FFI side's executor (see below) should invoke, named
notifyOutcome
- Executor - a (request, outcomeListener) receiver FFI side which is responsible for executing the
request
and sends back the outcome using the providedoutcomeListener
. - Some async fn Rust side which builds the request, creates an
outcomeListener
and dispatches therequest
to theexecutor
and awaits thenotifyOutcome
call on theoutcomeListener
, e.g.async fn login_user
#[derive(Record)]
pub struct FFINetworkingRequest {
pub url: String,
pub method: String,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
#[derive(Record)]
pub struct FFINetworkingResponse {
pub status_code: u16,
/// Can be empty.
pub body: Vec<u8>,
}
#[derive(uniffi::Error, thiserror::Error)]
pub enum FFINetworkingError {
#[error("Fail to create Swift 'URL''")]
FailedToCreateURL,
...
}
#[derive(Enum)]
pub enum FFINetworkingOutcome {
Success { value: FFINetworkingResponse },
Failure { error: FFINetworkingError },
}
#[uniffi::export(with_foreign)]
pub trait FFINetworkingExecutor: FFIOperationExecutor<FFINetworkingOutcomeListener> {
fn execute_networking_request(
&self,
request: FFINetworkingRequest,
listener_rust_side: Arc<FFINetworkingOutcomeListener>,
) -> Result<(), FFISideError>;
}
#[derive(Object)]
pub struct FFINetworkingOutcomeListener {
result_listener: FFIOperationOutcomeListener<FFINetworkingOutcome>,
}
impl IsOutcomeListener for FFINetworkingOutcomeListener {
type Request = FFINetworkingRequest;
type Response = FFINetworkingResponse;
type Failure = FFINetworkingError;
type Outcome = FFINetworkingOutcome;
}
#[export]
impl FFINetworkingOutcomeListener {
fn notify_outcome(&self, result: FFINetworkingOutcome) {
self.result_listener.notify_outcome(result.into())
}
}
Which allows us to build an async method e.g. a REST API endpoint, where JSON deserialization happens inside of Rust, and parsing of models into a result.
#[derive(Object)]
pub struct GatewayClient {
pub(crate) networking_dispatcher: FFIOperationDispatcher<FFINetworkingOutcomeListener>,
}
impl GatewayClient {
func make_request<T: Serialize, U: Deserialize>(
request: T,
url: String,
method: String
) -> Result<U, Error> {
let body = serde_json::to_vec(request)?;
let request = FFINetworkingRequest {
url,
body,
method: ..
headers: ..
};
// Let Swift side make network request and await response
let response = self.networking_dispatcher.dispatch(request).await?;
serde_json::from_slice<U>(response)?;
}
pub async fn get_xrd_balance_of_account(
&self,
address: String,
) -> Result<String, FFIBridgeError> {
self.make_request(
GetEntityDetailsRequest::new(address),
"https://mainnet.radixdlt.com/state/entity/details",
"POST",
)
.await
}
}
pub struct FFIOperationDispatcher<L: IsOutcomeListener> {
pub executor: Arc<dyn FFIOperationExecutor<L>>,
}
impl<L: IsOutcomeListener> FFIOperationDispatcher<L> {
pub(crate) async fn dispatch(
&self,
operation: L::Request,
) -> Result<L::Response, FFIBridgeError> {
// Underlying tokio channel used to get result from Swift back to Rust.
let (sender, receiver) = channel::<L::Outcome>();
// Our callback we pass to Swift
let outcome_listener = FFIOperationOutcomeListener::new(sender);
// Make request
self.executor
.execute_request(
// Pass operation to Swift to make
operation,
// Pass callback, Swift will call `outcome_listener.notify_outcome`
outcome_listener.into(),
)
.map_err(|e| FFIBridgeError::from(e))?;
// Await response from Swift
let response = receiver.await.map_err(|_| FFIBridgeError::FromRust {
error: RustSideError::FailedToReceiveResponseFromSwift,
})?;
response.into().map_err(|e| e.into().into())
}
}
Translate FFINetworkingRequest -> URLRequest
import Foundation
import ffibre
// Convert `[Rust]FFINetworkingRequest` to `[Swift]URLRequest`
extension FFINetworkingRequest {
func urlRequest(url: URL) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = self.method
request.httpBody = self.body
request.allHTTPHeaderFields = self.headers
return request
}
}
// Turn `URLSession` into a "network antenna" for Rust
extension URLSession: FfiNetworkingExecutor {
public func executeNetworkingRequest(
request rustRequest: FfiNetworkingRequest,
listenerRustSide: FfiNetworkingOutcomeListener
) throws {
guard let url = URL(string: rustRequest.url) else {
throw FfiNetworkingError.failedToCreateUrlFrom(string: rustRequest.url)
}
let task = dataTask(with: rustRequest.urlRequest(url: url)) { data, urlResponse, error in
let result = FfiNetworkingOutcome.with(
data: data,
urlResponse: urlResponse,
error: error
)
listenerRustSide.notifyOutcome(result: result)
}
task.resume()
}
}
Now ready to be used!
let gatewayClient = GatewayClient(networkAntenna: URLSession.shared)
// Call async method in Rust land from Swift!
let balance = try await gatewayClient.getXrdBalanceOfAccount(
address: "account_rdx..."
)
print("SWIFT ✅ getXrdBalanceOfAccount success, got balance: \(balance) ✅")
But it gets better! We can perform an async call in a Swift Task
and let a holder of it implement the FfiOperationExecutor
trait!
public final class Async<Request, Intermediary, Response> {
typealias Operation = (Request) async throws -> Intermediary
typealias MapToResponse = (Intermediary) async throws -> Response
private var task: Task<Void, Never>?
let operation: Operation
let mapToData: MapToData
}
extension Async: FfiNetworkingExecutor
where
Request == FfiNetworkingRequest, Intermediary == (Data, URLResponse),
Response == FfiNetworkingResponse
{
public func executeNetworkingRequest(
request rustRequest: FfiNetworkingRequest,
listenerRustSide: FfiNetworkingOutcomeListener
) throws {
self.task = Task {
do {
let result = try await self.operation(rustRequest)
let data = try await self.mapToData(result)
listenerRustSide.notifyOutcome(result: .success(value: data))
} catch {
listenerRustSide.notifyOutcome(result: .failure(error: ...))
}
}
}
}
Now ready to be used!
let gatewayClient = GatewayClient(
networkAntenna: Async {
try await urlSession.data(for: $0.asFFINetworkingRequest.urlRequest()).0
}
)
let balance = try await gatewayClient.getXrdBalanceOfAccount(address: "account_rdx...")
print("SWIFT ✅ getXrdBalanceOfAccount success, got balance: \(balance) ✅")
// 🎉
There are two different kinds of demos of Swift's AsyncStream of values (Transaction
) - both use the GatewayClient to fetch some data from Radix Gateway.
See test_async_stream_from_rust.swift
using Rust side example_async_stream_from_rust
TL;DR This is a bad idea - at least in its current form - because it is very complex and requires DOUBLE sided cancellation listeners. Rust must listen to cancellation from Swift and Swift must listen to cancellation from Rust.
This is built with tokio::runtime::Builder::new_multi_thread()
and block_on
inside a Rust async fn
- need not be async
in Rust but marked as such forcing us to do Task { rust_async_fn() }
in Swift, thus letting it loop and run in a detached
background task.
Far better approach than the "From Rust" example mentioned above.
See test_async_stream_from_swift.swift
which Rust side just callsget_latest_transactions_or_panic
in GatewayClient
Here we need not propagate any listeners at all between Swift and Rust, so it is much simpler.
What if Rust side still need to schedule some repetitive work? We probably we would let Rust rely on FFI side (Swift Side) providing that...