diff --git a/.github/workflows/include-deploy-release.yml b/.github/workflows/include-deploy-release.yml index 7141f919f1..4dc1d2d163 100644 --- a/.github/workflows/include-deploy-release.yml +++ b/.github/workflows/include-deploy-release.yml @@ -13,7 +13,7 @@ on: jobs: deploy: - runs-on: macos-latest + runs-on: macos-12 name: Deploy release steps: diff --git a/.github/workflows/include-integration-tests.yml b/.github/workflows/include-integration-tests.yml index e8b35c5133..e39b2613e0 100644 --- a/.github/workflows/include-integration-tests.yml +++ b/.github/workflows/include-integration-tests.yml @@ -16,7 +16,7 @@ jobs: # TODO: The Monkey seems to crash the app all the time, but with failures that are not coming from the app. Figure out why. # android-sample-app: - # runs-on: macos-latest + # runs-on: macos-12 # steps: # - name: Checkout code # uses: actions/checkout@v3 @@ -87,7 +87,7 @@ jobs: ./gradlew assembleDebug jvmJar realm-java-compatibiliy: - runs-on: macos-latest + runs-on: macos-12 steps: - name: Checkout code uses: actions/checkout@v3 @@ -217,7 +217,7 @@ jobs: - type: gradle75 path: integration-tests/gradle/gradle75-test arguments: integrationTest - runs-on: macos-latest + runs-on: macos-12 steps: - uses: actions/checkout@v3 @@ -295,7 +295,7 @@ jobs: - type: gradle8 path: integration-tests/gradle/gradle8-test arguments: integrationTest - runs-on: macos-latest + runs-on: macos-12 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9d463ceb66..3f94add458 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -229,7 +229,7 @@ jobs: retention-days: 1 build-jvm-macos-native-lib: - runs-on: macos-latest + runs-on: macos-12 needs: [check-cache, build-jni-swig-stub] if: | always() && @@ -395,7 +395,7 @@ jobs: # This task is also responsible for creating the Gradle and Compiler Plugin as well as # all Kotlin Multiplatform Metadata build-jvm-packages: - runs-on: macos-latest + runs-on: macos-12 needs: [check-cache, build-jvm-linux-native-lib, build-jvm-windows-native-lib, build-jvm-macos-native-lib] if: | always() && @@ -638,7 +638,7 @@ jobs: # TODO: ccache is not being used by this build for some reason build-macos-x64-packages: - runs-on: macos-latest + runs-on: macos-12 needs: check-cache if: always() && !cancelled() && needs.check-cache.outputs.packages-macos-x64-cache-hit != 'true' @@ -706,7 +706,7 @@ jobs: retention-days: 1 build-macos-arm64-packages: - runs-on: macos-latest + runs-on: macos-12 needs: check-cache # needs: static-analysis if: always() && !cancelled() && needs.check-cache.outputs.packages-macos-arm64-cache-hit != 'true' @@ -774,7 +774,7 @@ jobs: retention-days: 1 build-ios-x64-packages: - runs-on: macos-latest + runs-on: macos-12 needs: check-cache # needs: static-analysis if: always() && !cancelled() && needs.check-cache.outputs.packages-ios-x64-cache-hit != 'true' @@ -843,7 +843,7 @@ jobs: retention-days: 1 build-ios-arm64-packages: - runs-on: macos-latest + runs-on: macos-12 needs: check-cache # needs: static-analysis if: always() && !cancelled() && needs.check-cache.outputs.packages-ios-arm64-cache-hit != 'true' @@ -931,7 +931,7 @@ jobs: - type: sync test-title: Unit Test Results - Android Sync (Emulator) - runs-on: macos-latest + runs-on: macos-12 needs: [check-cache, build-android-packages, build-jvm-packages, build-kotlin-metadata-package] if: | always() && @@ -1175,15 +1175,15 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest] # , macos-arm] + os: [macos-12] # , macos-arm] type: [base, sync] include: - - os: macos-latest + - os: macos-12 type: base os-id: macos package-prefix: macos-x64 test-title: Unit Test Results - MacOS x64 Base - - os: macos-latest + - os: macos-12 type: sync os-id: macos package-prefix: macos-x64 @@ -1297,15 +1297,15 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest] # , macos-arm] + os: [macos-12] # , macos-arm] type: [base, sync] include: - - os: macos-latest + - os: macos-12 type: base package-prefix: x64 test-title: Unit Test Results - iOS x64 Base test-task: iosTest - - os: macos-latest + - os: macos-12 type: sync package-prefix: x64 test-title: Unit Test Results - iOS x64 Sync @@ -1419,10 +1419,10 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] # TODO Should we also test om MacOS arm64? + os: [macos-12, ubuntu-latest, windows-latest] # TODO Should we also test om MacOS arm64? type: [base, sync] include: - - os: macos-latest + - os: macos-12 os-id: mac type: base test-title: Unit Test Results - Base JVM MacOS x64 @@ -1434,7 +1434,7 @@ jobs: os-id: win type: base test-title: Unit Test Results - Base JVM Windows - - os: macos-latest + - os: macos-12 os-id: mac type: sync test-title: Unit Test Results - Sync JVM MacOS x64 diff --git a/CHANGELOG.md b/CHANGELOG.md index c89297b997..f1f2c77fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ This release will bump the Realm file format from version 23 to 24. Opening a fi * None. ### Enhancements -* None. +* Add support for changing the App Services base URL. It allows to roam between Atlas and Edge Server. Changing the url would trigger a client reset. (Issue [#1659](https://github.com/realm/realm-kotlin/issues/1659)/[RKOTLIN-1013](https://jira.mongodb.org/browse/RKOTLIN-1023)) ### Fixed * None. diff --git a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index 1a42b6faac..96945a1a68 100644 --- a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -551,6 +551,16 @@ expect object RealmInterop { callback: AppCallback>, ) + fun realm_app_get_base_url( + app: RealmAppPointer, + ): String + + fun realm_app_update_base_url( + app: RealmAppPointer, + baseUrl: String?, + callback: AppCallback, + ) + // User fun realm_user_get_all_identities(user: RealmUserPointer): List fun realm_user_get_identity(user: RealmUserPointer): String diff --git a/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index 7797adcf14..a819965c2c 100644 --- a/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -1170,6 +1170,18 @@ actual object RealmInterop { return result } + actual fun realm_app_get_base_url( + app: RealmAppPointer, + ): String = realmc.realm_app_get_base_url(app.cptr()) + + actual fun realm_app_update_base_url( + app: RealmAppPointer, + baseUrl: String?, + callback: AppCallback, + ) { + realmc.realm_app_update_base_url(app.cptr(), baseUrl, callback) + } + actual fun realm_user_get_all_identities(user: RealmUserPointer): List { val count = AuthProvider.values().size.toLong() // Optimistically allocate the max size of the array val keys = realmc.new_identityArray(count.toInt()) diff --git a/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index 10b979dafa..059268f986 100644 --- a/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/nativeDarwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -2309,6 +2309,33 @@ actual object RealmInterop { .also { realm_wrapper.realm_free(cPath) } } + actual fun realm_app_get_base_url( + app: RealmAppPointer, + ): String = realm_wrapper.realm_app_get_base_url(app.cptr())?.toKString()!! + + actual fun realm_app_update_base_url( + app: RealmAppPointer, + baseUrl: String?, + callback: AppCallback, + ) { + checkedBooleanResult( + realm_wrapper.realm_app_update_base_url( + app.cptr(), + baseUrl, + callback = staticCFunction { userData, error -> + handleAppCallback( + userData, + error + ) { /* No-op, returns Unit */ } + }, + StableRef.create(callback).asCPointer(), + staticCFunction { userdata -> + disposeUserData>(userdata) + } + ) + ) + } + actual fun realm_user_get_all_identities(user: RealmUserPointer): List { memScoped { val count = AuthProvider.values().size diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/App.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/App.kt index 21a44d3831..d129f3b526 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/App.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/App.kt @@ -17,6 +17,7 @@ package io.realm.kotlin.mongodb import io.realm.kotlin.internal.util.Validation +import io.realm.kotlin.mongodb.annotations.ExperimentalEdgeServerApi import io.realm.kotlin.mongodb.auth.EmailPasswordAuth import io.realm.kotlin.mongodb.exceptions.AppException import io.realm.kotlin.mongodb.exceptions.AuthException @@ -84,6 +85,23 @@ public interface App { */ public val sync: Sync + /** + * Current base URL to communicate with App Services. + */ + @ExperimentalEdgeServerApi + public val baseUrl: String + + /** + * Sets the App Services base url. + * + * *NOTE* Changing the URL would trigger a client reset. + * + * @param baseUrl The new App Services base url. If `null` it will be using the default value + * ([AppConfiguration.DEFAULT_BASE_URL]). + */ + @ExperimentalEdgeServerApi + public suspend fun updateBaseUrl(baseUrl: String?) + /** * Returns all known users that are either [User.State.LOGGED_IN] or [User.State.LOGGED_OUT]. * Only users that at some point logged into this device will be returned. diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/annotations/ExperimentalEdgeServerApi.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/annotations/ExperimentalEdgeServerApi.kt new file mode 100644 index 0000000000..62d59c2d74 --- /dev/null +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/annotations/ExperimentalEdgeServerApi.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Realm Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package io.realm.kotlin.mongodb.annotations + +/** + * This annotation mark Realm APIs specific to the Atlas Edge server and are considered + * **experimental**, i.e. there are no guarantees given that these APIs cannot change without + * warning between minor and major versions. They will not change between patch versions. + * + * For all other purposes these APIs are considered stable, i.e. they undergo the same testing + * as other parts of the API and should behave as documented with no bugs. They are primarily + * marked as experimental because we are unsure if these APIs provide value and solve the use + * cases that people have. If not, they will be changed or removed altogether. + */ +@MustBeDocumented +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FUNCTION, + AnnotationTarget.TYPEALIAS +) +@RequiresOptIn(level = RequiresOptIn.Level.ERROR) +public annotation class ExperimentalEdgeServerApi diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AppImpl.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AppImpl.kt index ffd161b575..870e08de95 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AppImpl.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/AppImpl.kt @@ -31,6 +31,7 @@ import io.realm.kotlin.mongodb.AppConfiguration import io.realm.kotlin.mongodb.AuthenticationChange import io.realm.kotlin.mongodb.Credentials import io.realm.kotlin.mongodb.User +import io.realm.kotlin.mongodb.annotations.ExperimentalEdgeServerApi import io.realm.kotlin.mongodb.auth.EmailPasswordAuth import io.realm.kotlin.mongodb.sync.Sync import io.realm.kotlin.types.RealmInstant @@ -63,6 +64,24 @@ public class AppImpl( @Suppress("MagicNumber") private val reconnectThreshold = 5.seconds + @ExperimentalEdgeServerApi + override val baseUrl: String + get() = RealmInterop.realm_app_get_base_url(nativePointer) + + @ExperimentalEdgeServerApi + override suspend fun updateBaseUrl(baseUrl: String?) { + Channel>(1).use { channel -> + RealmInterop.realm_app_update_base_url( + app = nativePointer, + baseUrl = baseUrl?.trimEnd('/'), // trailing slashes are not handled properly in core + callback = channelResultCallback(channel) { + // No-op + } + ) + channel.receive().getOrThrow() + } + } + @Suppress("invisible_member", "invisible_reference", "MagicNumber") private val connectionListener = NetworkStateObserver.ConnectionListener { connectionAvailable -> // In an ideal world, we would be able to reliably detect the network coming and diff --git a/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/AppTests.kt b/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/AppTests.kt index 10a037e906..c92ffc82dc 100644 --- a/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/AppTests.kt +++ b/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/AppTests.kt @@ -21,6 +21,8 @@ import io.realm.kotlin.RealmConfiguration import io.realm.kotlin.internal.platform.appFilesDirectory import io.realm.kotlin.internal.platform.fileExists import io.realm.kotlin.internal.platform.runBlocking +import io.realm.kotlin.log.LogLevel +import io.realm.kotlin.log.RealmLog import io.realm.kotlin.mongodb.App import io.realm.kotlin.mongodb.AppConfiguration import io.realm.kotlin.mongodb.AuthenticationChange @@ -30,8 +32,11 @@ import io.realm.kotlin.mongodb.LoggedIn import io.realm.kotlin.mongodb.LoggedOut import io.realm.kotlin.mongodb.Removed import io.realm.kotlin.mongodb.User +import io.realm.kotlin.mongodb.annotations.ExperimentalEdgeServerApi import io.realm.kotlin.mongodb.exceptions.InvalidCredentialsException +import io.realm.kotlin.mongodb.exceptions.ServiceException import io.realm.kotlin.mongodb.sync.SyncConfiguration +import io.realm.kotlin.test.mongodb.SyncServerConfig import io.realm.kotlin.test.mongodb.TEST_APP_FLEX import io.realm.kotlin.test.mongodb.TestApp import io.realm.kotlin.test.mongodb.asTestApp @@ -478,4 +483,93 @@ class AppTests { } } } + + /** + * The app id must exist on the new base url, it is validated and an exception would be thrown. + * + * This test case circumvents this issue by initializing an app to a url that does not contain the + * app, as it is not validated on initialization. And then updating the base url the the test server. + */ + @Test + @OptIn(ExperimentalEdgeServerApi::class) + fun changeBaseUrl() { + TestApp( + testId = "changeBaseUrl", + builder = { builder -> + // We create a test app that points to the default base url + // this app is not going to be validated yet. + builder.baseUrl(AppConfiguration.DEFAULT_BASE_URL) + } + ).use { testApp -> + assertEquals(AppConfiguration.DEFAULT_BASE_URL, testApp.baseUrl) + + runBlocking { + // Update the base url, this method will validate the app + // if the app id is not available it would fail. + testApp.updateBaseUrl(app.configuration.baseUrl) + } + assertEquals(app.configuration.baseUrl, testApp.baseUrl) + } + } + + @Test + // We don't have a way to test this on CI, so for now just verify manually that the + // request towards the server after setting the URL to null is using the default URL. + @Ignore + @OptIn(ExperimentalEdgeServerApi::class) + fun changeBaseUrl_null() { + TestApp( + testId = "changeBaseUrl", + ).use { testApp -> + assertEquals(SyncServerConfig.url, testApp.baseUrl) + + RealmLog.level = LogLevel.ALL + runBlocking { + testApp.updateBaseUrl(null) + } + } + } + + @Test + @Ignore // See https://github.com/realm/realm-kotlin/issues/1734 + @OptIn(ExperimentalEdgeServerApi::class) + fun changeBaseUrl_trailing_slashes_trimmed() { + assertFailsWithMessage("cannot find app using Client App ID") { + runBlocking { + app.updateBaseUrl(AppConfiguration.DEFAULT_BASE_URL + "///") + } + } + } + + @Test + @Ignore // see https://github.com/realm/realm-kotlin/issues/1734 + @OptIn(ExperimentalEdgeServerApi::class) + fun changeBaseUrl_empty() { + assertFailsWithMessage("cannot find app using Client App ID") { + runBlocking { + app.updateBaseUrl("") + } + } + } + + @Test + @OptIn(ExperimentalEdgeServerApi::class) + fun changeBaseUrl_invalidUrl() { + assertFailsWithMessage("URL missing scheme separator") { + runBlocking { + app.updateBaseUrl("hello world") + } + } + } + + @Test + @Ignore // see https://github.com/realm/realm-kotlin/issues/1734 + @OptIn(ExperimentalEdgeServerApi::class) + fun changeBaseUrl_nonAppServicesUrl() { + assertFailsWithMessage("http error code considered fatal") { + runBlocking { + app.updateBaseUrl("https://www.google.com/") + } + } + } }