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

Use ClientBuilder pattern in SDK FFI #739

Merged
merged 16 commits into from
Jun 29, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 6 additions & 15 deletions bindings/apple/MatrixRustSDKTests/MatrixRustSDKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,14 @@ import XCTest

class MatrixRustSDKTests: XCTestCase {

static var client: Client!

override class func setUp() {
client = try! guestClient(basePath: basePath, homeserver: "https://matrix.org")
}

func testClientProperties() {
XCTAssertTrue(Self.client.isGuest())

XCTAssertNotNil(try? Self.client.restoreToken())
XCTAssertNotNil(try? Self.client.deviceId())
XCTAssertNotNil(try? Self.client.displayName())
}

func testReadOnlyFileSystemError() {
do {
let _ = try loginNewClient(basePath: "", username: "test", password: "test")
let client = try ClientBuilder()
.basePath(path: "")
.username(username: "@test:domain")
.build()

try client.login(username: "@test:domain", password: "test")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The use of the new ClientBuilder API highlights that username now has to be passed twice, which is not ideal. The client builder needs username to determine file store path, and potentially homeserver, if not set explicitely. The client needs username to construct user to log in with.

Potentially the client could retrieve the username directly from the builder, but not sure if api like client.login(password) is very good.

} catch ClientError.Generic(let message) {
XCTAssertNotNil(message.range(of: "Read-only file system"))
} catch {
Expand Down
31 changes: 22 additions & 9 deletions bindings/matrix-sdk-ffi/src/api.udl
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
namespace sdk {
[Throws=ClientError]
Client login_new_client(string base_path, string username, string password);

[Throws=ClientError]
Client guest_client(string base_path, string homeserver);

[Throws=ClientError]
Client login_with_token(string base_path, string restore_token);

MediaSource media_source_from_url(string url);
MessageEventContent message_event_content_from_markdown(string md);
string gen_transaction_id();
Expand All @@ -22,9 +13,31 @@ callback interface ClientDelegate {
void did_receive_sync_update();
};

interface ClientBuilder {
constructor();

[Self=ByArc]
ClientBuilder base_path(string path);

[Self=ByArc]
ClientBuilder username(string username);

[Self=ByArc]
ClientBuilder homeserver_url(string url);

[Throws=ClientError, Self=ByArc]
Client build();
};

interface Client {
void set_delegate(ClientDelegate? delegate);

[Throws=ClientError]
void login(string username, string password);

[Throws=ClientError]
void restore_login(string restore_token);

void start_sync();

[Throws=ClientError]
Expand Down
19 changes: 18 additions & 1 deletion bindings/matrix-sdk-ffi/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use matrix_sdk::{
sync::sync_events::v3::Filter,
},
events::room::MediaSource,
TransactionId,
TransactionId, UserId,
},
Client as MatrixClient, LoopCtrl,
};
Expand Down Expand Up @@ -44,6 +44,23 @@ impl Client {
}
}

pub fn login(&self, username: String, password: String) -> anyhow::Result<()> {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These are the methods that used to exist as top-level functions, now methods on the Client. Internally they pretty much just call the inner SDK methods of the same name

Copy link
Member

Choose a reason for hiding this comment

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

🙌

RUNTIME.block_on(async move {
self.client.login_username(&username, &password).send().await?;
Ok(())
})
}

pub fn restore_login(&self, restore_token: String) -> anyhow::Result<()> {
let RestoreToken { session, homeurl: _, is_guest: _ } =
serde_json::from_str(&restore_token)?;

RUNTIME.block_on(async move {
self.client.restore_login(session).await?;
Ok(())
})
}

pub fn set_delegate(&self, delegate: Option<Box<dyn ClientDelegate>>) {
*self.delegate.write() = delegate;
}
Expand Down
87 changes: 87 additions & 0 deletions bindings/matrix-sdk-ffi/src/client_builder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use std::{fs, path::PathBuf, sync::Arc};

use anyhow::Context;
use matrix_sdk::{
ruma::UserId, store::make_store_config, Client as MatrixClient,
ClientBuilder as MatrixClientBuilder,
};
use sanitize_filename_reader_friendly::sanitize;

use super::{client::Client, ClientState, RUNTIME};

#[derive(Clone)]
pub struct ClientBuilder {
base_path: Option<String>,
username: Option<String>,
homeserver_url: Option<String>,
inner: MatrixClientBuilder,
}

impl ClientBuilder {
pub fn new() -> Self {
Self {
base_path: None,
username: None,
homeserver_url: None,
inner: MatrixClient::builder().user_agent("rust-sdk-ios"),
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Docs should probably say that base path must be set. Which let's me wonder if we shouldn't have that as a parameters on new so it is enforced and never Option that could fail at runtime (when calling build) . Wdyt?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think Andy actually wanted to have it as a new parameter, and I wanted to have it as a builder method instead. My reasoning for that was that it's the usual pattern for builders, and a new ClientBuilder("/some/path") invocation wouldn't be quite clear. (and it would be a very easy to catch programmer error when it isn't set)

However, this point only applies to Kotlin since UniFFI's Swift codegen requires callers to re-state argument names (as in new ClientBuilder(base_path: "/some/path")). So I don't insist on keeping it this way.

Copy link
Contributor Author

@Anderas Anderas Jun 29, 2022

Choose a reason for hiding this comment

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

Yea I generally favor API that makes invalid states impossible to represent, which would require passing them to new. This sounds like an example of clash between SDK and client-side consistency. Happy to follow up on this in a new PR.

pub fn base_path(self: Arc<Self>, path: String) -> Arc<Self> {
let mut builder = unwrap_or_clone_arc(self);
builder.base_path = Some(path);
Arc::new(builder)
}

pub fn username(self: Arc<Self>, username: String) -> Arc<Self> {
let mut builder = unwrap_or_clone_arc(self);
builder.username = Some(username);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why hold an inner builder but still have all parameters twice? Why not just

Suggested change
builder.username = Some(username);
builder.inner = builder.inner.username(username);

For the homeserver, too...

Copy link
Collaborator

Choose a reason for hiding this comment

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

We can't currently read those fields from the inner builder, but they're needed in build. I think there's room for improvement here, but it's not entirely clear how this should be improved. In general I think the FFI layer shouldn't add its own logic like deriving a store path from a base path plus username / homeserver, but doing that immediately would mean extra work for using the FFI compared to before this change, which is also not great.

Arc::new(builder)
}

pub fn homeserver_url(self: Arc<Self>, url: String) -> Arc<Self> {
let mut builder = unwrap_or_clone_arc(self);
builder.homeserver_url = Some(url);
Arc::new(builder)
}

pub fn build(self: Arc<Self>) -> anyhow::Result<Arc<Client>> {
let builder = unwrap_or_clone_arc(self);

let base_path = builder.base_path.context("Base path was not set")?;
let username = builder
Copy link
Contributor

Choose a reason for hiding this comment

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

This would fail for guest clients... but as this also drops support fir them, I guess that's fine...

.username
.context("Username to determine homeserver and home path was not set")?;

// Determine store path
let data_path = PathBuf::from(base_path).join(sanitize(&username));
fs::create_dir_all(&data_path)?;
let store_config = make_store_config(&data_path, None)?;

let mut inner_builder = builder.inner.store_config(store_config);

// Determine server either from explicitly set homeserver or from userId
if let Some(homeserver_url) = builder.homeserver_url {
inner_builder = inner_builder.homeserver_url(homeserver_url);
} else {
let user = UserId::parse(username)?;
inner_builder = inner_builder.server_name(user.server_name());
}

RUNTIME.block_on(async move {
let client = inner_builder.build().await?;
let c = Client::new(client, ClientState::default());
Ok(Arc::new(c))
})
}
}

impl Default for ClientBuilder {
fn default() -> Self {
Self::new()
}
}
Comment on lines +79 to +83
Copy link
Collaborator

Choose a reason for hiding this comment

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

What is this Default impl used for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was a clippy failure here pointing to this rule

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah, that's pretty useless in this context but whatever.


fn unwrap_or_clone_arc<T: Clone>(arc: Arc<T>) -> T {
Arc::try_unwrap(arc).unwrap_or_else(|x| (*x).clone())
}
64 changes: 3 additions & 61 deletions bindings/matrix-sdk-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@

pub mod backward_stream;
pub mod client;
pub mod client_builder;
pub mod messages;
pub mod room;
mod uniffi_api;

use std::{fs, path, sync::Arc};

use client::Client;
use matrix_sdk::{store::make_store_config, Client as MatrixClient, ClientBuilder, Session};
use client_builder::ClientBuilder;
use matrix_sdk::Session;
use once_cell::sync::Lazy;
use sanitize_filename_reader_friendly::sanitize;
use serde::{Deserialize, Serialize};
use tokio::runtime::Runtime;
pub use uniffi_api::*;
Expand All @@ -25,63 +24,6 @@ pub use matrix_sdk::ruma::{api::client::account::register, UserId};

pub use self::{backward_stream::*, client::*, messages::*, room::*};

pub fn guest_client(base_path: String, homeurl: String) -> anyhow::Result<Arc<Client>> {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The guest client login is not used by element-x, only in a unit test of sample project that could be recreated in other ways, so I did not port this method yet. If I missed some other reason why this is important, I can bring it back

let builder = new_client_builder(base_path, homeurl.clone())?.homeserver_url(&homeurl);
let mut guest_registration = register::v3::Request::new();
guest_registration.kind = register::RegistrationKind::Guest;
RUNTIME.block_on(async move {
let client = builder.build().await?;
let register = client.register(guest_registration).await?;
let session = Session {
access_token: register.access_token.expect("no access token given"),
user_id: register.user_id,
device_id: register.device_id.clone().expect("device ID is given by server"),
};
client.restore_login(session).await?;
let c = Client::new(client, ClientState { is_guest: true, ..ClientState::default() });
Ok(Arc::new(c))
})
}

pub fn login_with_token(base_path: String, restore_token: String) -> anyhow::Result<Arc<Client>> {
let RestoreToken { session, homeurl, is_guest } = serde_json::from_str(&restore_token)?;
let builder = new_client_builder(base_path, session.user_id.to_string())?
.homeserver_url(&homeurl)
.user_id(&session.user_id);
// First we need to log in.
RUNTIME.block_on(async move {
let client = builder.build().await?;
client.restore_login(session).await?;
let c = Client::new(client, ClientState { is_guest, ..ClientState::default() });
Ok(Arc::new(c))
})
}

pub fn login_new_client(
base_path: String,
username: String,
password: String,
) -> anyhow::Result<Arc<Client>> {
let builder = new_client_builder(base_path, username.clone())?;
let user = UserId::parse(username)?;
// First we need to log in.
RUNTIME.block_on(async move {
let client = builder.user_id(&user).build().await?;
client.login_username(user.as_str(), &password).send().await?;
let c = Client::new(client, ClientState { is_guest: false, ..ClientState::default() });
Ok(Arc::new(c))
})
}

fn new_client_builder(base_path: String, home: String) -> anyhow::Result<ClientBuilder> {
let data_path = path::PathBuf::from(base_path).join(sanitize(&home));

fs::create_dir_all(&data_path)?;
let store_config = make_store_config(&data_path, None)?;

Ok(MatrixClient::builder().user_agent("rust-sdk-ios").store_config(store_config))
}

#[derive(Default, Debug)]
pub struct ClientState {
is_guest: bool,
Expand Down
2 changes: 1 addition & 1 deletion crates/matrix-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use matrix_sdk::{
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let alice = user_id!("@alice:example.org");
let client = Client::builder().user_id(alice).build().await?;
let client = Client::builder().server_name(alice.server_name()).build().await?;

// First we need to log in.
client.login_username(alice, "password").send().await?;
Expand Down
19 changes: 4 additions & 15 deletions crates/matrix-sdk/src/client/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use async_once_cell::OnceCell;
use matrix_sdk_base::{locks::RwLock, store::StoreConfig, BaseClient, StateStore};
use ruma::{
api::{client::discovery::discover_homeserver, error::FromHttpResponseError, MatrixVersion},
OwnedServerName, ServerName, UserId,
OwnedServerName, ServerName,
};
use thiserror::Error;
#[cfg(not(target_arch = "wasm32"))]
Expand Down Expand Up @@ -84,25 +84,14 @@ impl ClientBuilder {

/// Set the homeserver URL to use.
///
/// This method is mutually exclusive with [`user_id()`][Self::user_id], if
/// you set both whatever was set last will be used.
/// This method is mutually exclusive with
/// [`server_name()`][Self::server_name], if you set both whatever was
/// set last will be used.
pub fn homeserver_url(mut self, url: impl AsRef<str>) -> Self {
self.homeserver_cfg = Some(HomeserverConfig::Url(url.as_ref().to_owned()));
self
}

/// Set the user ID to discover the homeserver from.
///
/// `builder.user_id(id)` is a shortcut for
/// `builder.server_name(id.server_name())`.
///
/// This method is mutually exclusive with
/// [`homeserver_url()`][Self::homeserver_url], if you set both whatever was
/// set last will be used.
pub fn user_id(self, user_id: &UserId) -> Self {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Given that user_id naming was confusing (sets server, not user), and server_name_from_user_id was too long, it seems we could simply just rely on server_name(user_id.server_name())

Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we please not do this in this PR? I'd rather deprecate it for now, not remove it entirely.

self.server_name(user_id.server_name())
}

/// Set the server name to discover the homeserver from.
///
/// This method is mutually exclusive with
Expand Down
4 changes: 2 additions & 2 deletions crates/matrix-sdk/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2281,7 +2281,7 @@ pub(crate) mod tests {
.with_status(200)
.with_body(test_json::VERSIONS.to_string())
.create();
let client = Client::builder().user_id(&alice).build().await.unwrap();
let client = Client::builder().server_name(alice.server_name()).build().await.unwrap();

assert_eq!(client.homeserver().await, Url::parse(server_url.as_ref()).unwrap());
}
Expand All @@ -2295,7 +2295,7 @@ pub(crate) mod tests {
let _m = mock("GET", "/.well-known/matrix/client").with_status(404).create();

assert!(
Client::builder().user_id(&alice).build().await.is_err(),
Client::builder().server_name(alice.server_name()).build().await.is_err(),
"Creating a client from a user ID should fail when the .well-known request fails."
);
}
Expand Down