diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4502bbfe1..7ddd72495e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,10 @@
## vNext (TBD)
### Enhancements
-* None
+* Added an experimental API to update the base url for an application at runtime - `App.UpdateBaseUriAsync()`. This intended to be used for roaming between edge server and cloud. (Issue [#3521](https://github.com/realm/realm-dotnet/issues/3521))
### Fixed
-* The returned value from `MongoClient.Collection.FindOneAsync` is now a nullable document to more explicitly convey that `null` may be returned in case no object matched the filter. ([PR #3586](https://github.com/realm/realm-dotnet/pull/3586))
+* The returned value from `MongoClient.Collection.FindOneAsync` is now a nullable document to more explicitly convey that `null` may be returned in case no object matched the filter. (PR [#3586](https://github.com/realm/realm-dotnet/pull/3586))
### Compatibility
* Realm Studio: 15.0.0 or later.
diff --git a/Realm/Realm/Handles/AppHandle.cs b/Realm/Realm/Handles/AppHandle.cs
index 59fc97f668..2a4746cc3e 100644
--- a/Realm/Realm/Handles/AppHandle.cs
+++ b/Realm/Realm/Handles/AppHandle.cs
@@ -121,6 +121,12 @@ public static extern IntPtr get_user_for_testing(
[DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_app_get_default_url", CallingConvention = CallingConvention.Cdecl)]
public static extern StringValue get_default_url(out NativeException ex);
+
+ [DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_app_update_base_url", CallingConvention = CallingConvention.Cdecl)]
+ public static extern void update_base_uri(AppHandle appHandle,
+ [MarshalAs(UnmanagedType.LPWStr)] string url_buf, IntPtr url_len,
+ IntPtr tcs_ptr,
+ out NativeException ex);
}
static AppHandle()
@@ -343,6 +349,23 @@ public Uri GetBaseUri()
return new Uri(uriString);
}
+ public async Task UpdateBaseUriAsync(Uri? newUri)
+ {
+ var tcs = new TaskCompletionSource();
+ var tcsHandle = GCHandle.Alloc(tcs);
+ try
+ {
+ var url = newUri?.ToString().TrimEnd('/') ?? string.Empty;
+ NativeMethods.update_base_uri(this, url, (IntPtr)url.Length, GCHandle.ToIntPtr(tcsHandle), out var ex);
+ ex.ThrowIfNecessary();
+ await tcs.Task;
+ }
+ finally
+ {
+ tcsHandle.Free();
+ }
+ }
+
public string GetId()
{
var value = NativeMethods.get_id(this, out var ex);
diff --git a/Realm/Realm/Helpers/ExperimentalAttribute.cs b/Realm/Realm/Helpers/ExperimentalAttribute.cs
new file mode 100644
index 0000000000..380af2e3f2
--- /dev/null
+++ b/Realm/Realm/Helpers/ExperimentalAttribute.cs
@@ -0,0 +1,64 @@
+#if !NET8_0_OR_GREATER
+
+using System.ComponentModel;
+
+namespace System.Diagnostics.CodeAnalysis;
+
+///
+/// Indicates that an API is experimental and it may change in the future.
+///
+///
+/// This attribute allows call sites to be flagged with a diagnostic that indicates that an experimental
+/// feature is used. Authors can use this attribute to ship preview features in their assemblies.
+///
+/// This is a polyfill of the ExperimentalAttribute added in .NET 8.
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+[AttributeUsage(
+ AttributeTargets.Assembly
+ | AttributeTargets.Module
+ | AttributeTargets.Class
+ | AttributeTargets.Struct
+ | AttributeTargets.Enum
+ | AttributeTargets.Constructor
+ | AttributeTargets.Method
+ | AttributeTargets.Property
+ | AttributeTargets.Field
+ | AttributeTargets.Event
+ | AttributeTargets.Interface
+ | AttributeTargets.Delegate,
+ Inherited = false)]
+public sealed class ExperimentalAttribute : Attribute
+{
+ ///
+ /// Initializes a new instance of the class, specifying the ID that the compiler will use
+ /// when reporting a use of the API the attribute applies to.
+ ///
+ /// The ID that the compiler will use when reporting a use of the API the attribute applies to.
+ public ExperimentalAttribute(string diagnosticId)
+ {
+ DiagnosticId = diagnosticId;
+ }
+
+ ///
+ /// Gets the ID that the compiler will use when reporting a use of the API the attribute applies to.
+ ///
+ /// The unique diagnostic ID.
+ ///
+ /// The diagnostic ID is shown in build output for warnings and errors.
+ /// This property represents the unique ID that can be used to suppress the warnings or errors, if needed.
+ ///
+ public string DiagnosticId { get; }
+
+ ///
+ /// Gets or sets the URL for corresponding documentation.
+ /// The API accepts a format string instead of an actual URL, creating a generic URL that includes the diagnostic ID.
+ ///
+ /// The format string that represents a URL to corresponding documentation.
+ ///
+ /// An example format string is https://contoso.com/obsoletion-warnings/{0}.
+ ///
+ public string? UrlFormat { get; set; }
+}
+
+#endif
diff --git a/Realm/Realm/Sync/App.cs b/Realm/Realm/Sync/App.cs
index febfbb525b..88036677bc 100644
--- a/Realm/Realm/Sync/App.cs
+++ b/Realm/Realm/Sync/App.cs
@@ -17,6 +17,7 @@
////////////////////////////////////////////////////////////////////////////
using System;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
@@ -270,6 +271,27 @@ public Task DeleteUserFromServerAsync(User user)
return Handle.DeleteUserAsync(user.Handle);
}
+ ///
+ /// Temporarily overrides the value from
+ /// with a new value used for communicating with the server.
+ ///
+ ///
+ /// The new uri that will be used for communicating with the server. If set to null, the base uri will
+ /// be reset to its default value.
+ ///
+ /// An awaitable that represents the asynchronous operation.
+ ///
+ /// The App will revert to using the value in [AppConfiguration] when it is restarted.
+ ///
+ /// This API must be called after sync sessions have been manually stopped and at a point
+ /// where the server at is reachable. Once the base uri has been
+ /// updated, sync sessions should be resumed and the user needs to reauthenticate.
+ ///
+ /// This API is experimental and subject to change without a major version increase.
+ ///
+ [Experimental("Rlm001", UrlFormat = "www.mongodb.com/docs/atlas/app-services/edge-server/connect/#roaming-between-edge-servers")]
+ public Task UpdateBaseUriAsync(Uri? newUri) => Handle.UpdateBaseUriAsync(newUri);
+
///
public override bool Equals(object? obj)
{
diff --git a/Tests/Realm.Tests/Sync/AppTests.cs b/Tests/Realm.Tests/Sync/AppTests.cs
index f8a9c4c7bc..7f109511fc 100644
--- a/Tests/Realm.Tests/Sync/AppTests.cs
+++ b/Tests/Realm.Tests/Sync/AppTests.cs
@@ -26,6 +26,7 @@
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
+using Baas;
using NUnit.Framework;
using Realms.Logging;
using Realms.PlatformHelpers;
@@ -398,5 +399,44 @@ public void RealmConfigurationBaseUrl_ReturnsExpectedValue()
var config = new AppConfiguration("abc");
Assert.That(config.BaseUri, Is.EqualTo(new Uri("https://services.cloud.mongodb.com")));
}
+
+ [Test]
+ public void App_UpdateBaseUri_UpdatesBaseUri()
+ {
+ SyncTestHelpers.RunBaasTestAsync(async () =>
+ {
+ var appConfig = SyncTestHelpers.GetAppConfig(AppConfigType.FlexibleSync);
+ appConfig.BaseUri = new Uri("https://services.mongodb.com");
+ var app = CreateApp(appConfig);
+
+ Assert.That(app.BaseUri, Is.EqualTo(new Uri("https://services.mongodb.com")));
+
+#pragma warning disable Rlm001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ await app.UpdateBaseUriAsync(SyncTestHelpers.BaasUri!);
+#pragma warning restore Rlm001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+ Assert.That(app.BaseUri, Is.EqualTo(SyncTestHelpers.BaasUri));
+ });
+ }
+
+ [Test]
+ public void App_UpdateBaseUri_WhenUnreachable_Throws()
+ {
+ SyncTestHelpers.RunBaasTestAsync(async () =>
+ {
+ var appConfig = SyncTestHelpers.GetAppConfig(AppConfigType.FlexibleSync);
+ appConfig.BaseUri = new Uri("https://services.mongodb.com");
+ var app = CreateApp(appConfig);
+
+ Assert.That(app.BaseUri, Is.EqualTo(new Uri("https://services.mongodb.com")));
+
+#pragma warning disable Rlm001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ var ex = await TestHelpers.AssertThrows(() => app.UpdateBaseUriAsync(new Uri("https://google.com")));
+#pragma warning restore Rlm001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+ Assert.That(ex.Message, Does.Contain("404"));
+ Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
+ });
+ }
}
}
diff --git a/wrappers/src/app_cs.cpp b/wrappers/src/app_cs.cpp
index 90fe1e144c..ccf3f694d1 100644
--- a/wrappers/src/app_cs.cpp
+++ b/wrappers/src/app_cs.cpp
@@ -347,6 +347,25 @@ extern "C" {
});
}
+ REALM_EXPORT void shared_app_update_base_url(SharedApp& app, uint16_t* url_buf, size_t url_len, void* tcs_ptr, NativeException::Marshallable& ex)
+ {
+ return handle_errors(ex, [&]() {
+ std::string url(Utf16StringAccessor(url_buf, url_len));
+
+ app->update_base_url(url, [tcs_ptr](util::Optional err) {
+ if (err) {
+ auto& err_copy = *err;
+ MarshaledAppError app_error(err_copy);
+
+ s_void_callback(tcs_ptr, app_error);
+ }
+ else {
+ s_void_callback(tcs_ptr, MarshaledAppError());
+ }
+ });
+ });
+ }
+
#pragma region EmailPassword
REALM_EXPORT void shared_app_email_register_user(SharedApp& app, uint16_t* username_buf, size_t username_len, uint16_t* password_buf, size_t password_len, void* tcs_ptr, NativeException::Marshallable& ex)