From efacd2ef740c6fe2a23ad6734fee36aa1b8aa566 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:53:26 +0300 Subject: [PATCH] feat: add instance domain model and static config Introduces Instance and InstancesConfig: what one Open edX platform is and how it is parsed from the INSTANCES block of the bundled config. Nothing reads it yet. Required fields are NAME, API_HOST_URL and OAUTH_CLIENT_ID; a malformed entry is dropped. KEY falls back to a slug of NAME, INSTANCE_NAME is localized with an English fallback, and PLATFORM_NAME defaults to the display name. The schema matches iOS (#672). --- .../org/openedx/core/instance/Instance.kt | 185 ++++++++++++++++++ .../openedx/core/instance/InstancesConfig.kt | 51 +++++ .../core/instance/InstancesConfigTest.kt | 183 +++++++++++++++++ .../instances/bundled_instances.json | 45 +++++ default_config/dev/config.yaml | 49 +++++ 5 files changed, 513 insertions(+) create mode 100644 core/src/main/java/org/openedx/core/instance/Instance.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstancesConfig.kt create mode 100644 core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt create mode 100644 core/src/test/resources/instances/bundled_instances.json diff --git a/core/src/main/java/org/openedx/core/instance/Instance.kt b/core/src/main/java/org/openedx/core/instance/Instance.kt new file mode 100644 index 000000000..670b13216 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/Instance.kt @@ -0,0 +1,185 @@ +package org.openedx.core.instance + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import java.net.URI +import java.text.Normalizer +import java.util.Locale + +/** + * One Open edX platform the app can sign in to: its own host, OAuth client, branding and + * per-platform overrides of the app-level configuration. + * + * Instances are identified by their stable [key], never by their (possibly localized) + * display name or their position in the catalog. + */ +data class Instance( + /** Machine-readable identifier, e.g. "niepd". Falls back to a slugified [name]. */ + val key: String, + val name: String, + /** Localized display name; falls back to the English entry, then to [name]. */ + val instanceName: String, + val color: String, + val oAuthClientId: String, + val apiHostUrl: String, + val ssoUrl: String?, + val ssoFinishedUrl: String?, + val environmentDisplayName: String?, + val isSwitchInstanceLoginEnabled: Boolean, + /** Remote URL, or the name of a bundled drawable. `null` keeps the default logo. */ + val logoUrl: String?, + /** Remote URL, or the name of a bundled drawable. `null` keeps the default header. */ + val headerBackgroundUrl: String?, + /** `null` derives the palette from [color] instead. */ + val themeColors: InstanceThemeColors?, + /** Defaults to [instanceName] when unset, so it is never blank. */ + val platformName: String, + val isRoundedCorners: Boolean, + val buttonCornersRadius: Double, + /** + * The entry exactly as configured, in UPPER_SNAKE_CASE. Instance-level overrides of + * app-level keys (`AGREEMENT_URLS`, `DISCOVERY`, `UI_COMPONENTS`, ...) are read from here. + */ + val config: JsonObject, +) { + + companion object { + const val KEY = "KEY" + const val NAME = "NAME" + const val INSTANCE_NAME = "INSTANCE_NAME" + const val COLOR = "COLOR" + const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" + const val API_HOST_URL = "API_HOST_URL" + const val SSO_URL = "SSO_URL" + const val SSO_FINISHED_URL = "SSO_FINISHED_URL" + const val SSO_URL_SUCCESSFUL_LOGIN = "SSO_URL_SUCCESSFUL_LOGIN" + const val ENVIRONMENT_DISPLAY_NAME = "ENVIRONMENT_DISPLAY_NAME" + const val IS_SWITCH_INSTANCE_LOGIN_ENABLED = "IS_SWITCH_INSTANCE_LOGIN_ENABLED" + const val PLATFORM_NAME = "PLATFORM_NAME" + const val THEME = "THEME" + const val THEME_LIGHT = "LIGHT" + const val THEME_DARK = "DARK" + const val THEME_LOGO_URL = "LOGO_URL" + const val THEME_HEADER_BACKGROUND_URL = "HEADER_BACKGROUND_URL" + const val THEME_ROUNDED_CORNERS_STYLE = "ROUNDED_CORNERS_STYLE" + const val THEME_BUTTON_CORNERS_RADIUS = "BUTTON_CORNERS_RADIUS" + + private const val DEFAULT_COLOR = "#007AFF" + private const val DEFAULT_BUTTON_CORNERS_RADIUS = 8.0 + private const val DEFAULT_LANGUAGE = "en" + + /** + * Parses one `INSTANCES` entry. Returns `null` when a required field (`NAME`, + * `API_HOST_URL`, `OAUTH_CLIENT_ID`) is missing or `API_HOST_URL` isn't an absolute + * URL, so a malformed entry is dropped instead of producing a half-populated instance. + */ + @Suppress("ReturnCount") + fun fromJson( + json: JsonObject, + languageCode: String = Locale.getDefault().language, + ): Instance? { + val name = json.string(NAME)?.takeIf { it.isNotBlank() } ?: return null + val apiHostUrl = json.string(API_HOST_URL)?.takeIf { it.isAbsoluteUrl() } ?: return null + val oAuthClientId = json.string(OAUTH_CLIENT_ID)?.takeIf { it.isNotBlank() } ?: return null + + val key = json.string(KEY)?.takeIf { it.isNotBlank() } ?: slugify(name) + val localizedNames = json.stringMap(INSTANCE_NAME) + val instanceName = localizedNames[languageCode] + ?: localizedNames[DEFAULT_LANGUAGE] + ?: name + + // Logo and header live inside THEME, as siblings of LIGHT/DARK. The palette maps' + // own field names (accent_color, ...) stay lowercase: they are the theme engine's + // contract, shared with iOS, not part of this schema. + val theme = json.obj(THEME) + val themeColors = theme?.let { + InstanceThemeColors( + light = it.stringMap(THEME_LIGHT), + dark = it.stringMap(THEME_DARK), + ) + } + + return Instance( + key = key, + name = name, + instanceName = instanceName, + color = json.nonBlankString(COLOR) ?: DEFAULT_COLOR, + oAuthClientId = oAuthClientId, + apiHostUrl = apiHostUrl, + ssoUrl = json.nonBlankString(SSO_URL), + ssoFinishedUrl = json.nonBlankString(SSO_FINISHED_URL) + ?: json.nonBlankString(SSO_URL_SUCCESSFUL_LOGIN), + environmentDisplayName = json.nonBlankString(ENVIRONMENT_DISPLAY_NAME), + isSwitchInstanceLoginEnabled = json.boolean(IS_SWITCH_INSTANCE_LOGIN_ENABLED) ?: false, + logoUrl = theme?.nonBlankString(THEME_LOGO_URL), + headerBackgroundUrl = theme?.nonBlankString(THEME_HEADER_BACKGROUND_URL), + themeColors = themeColors, + platformName = json.nonBlankString(PLATFORM_NAME) ?: instanceName, + // Absent or non-boolean means rounded, same as the app-level theme default. + isRoundedCorners = theme?.boolean(THEME_ROUNDED_CORNERS_STYLE) != false, + buttonCornersRadius = theme?.double(THEME_BUTTON_CORNERS_RADIUS) + ?: DEFAULT_BUTTON_CORNERS_RADIUS, + config = json.deepCopy(), + ) + } + + internal fun slugify(name: String): String { + return Normalizer.normalize(name.lowercase(Locale.ROOT), Normalizer.Form.NFD) + .replace("\\p{Mn}+".toRegex(), "") + .replace("[^a-z0-9]+".toRegex(), "-") + .trim('-') + } + + private fun String.isAbsoluteUrl(): Boolean { + return try { + val uri = URI(this) + !uri.scheme.isNullOrEmpty() && !uri.host.isNullOrEmpty() + } catch (_: Exception) { + false + } + } + } +} + +/** + * Per-mode color overrides from an instance's `THEME` block. Keys are the shared palette + * field names (`accent_color`, `text_primary`, ...); values are hex strings. + */ +data class InstanceThemeColors( + val light: Map, + val dark: Map, +) + +internal fun JsonObject.primitive(key: String): JsonElement? { + val element = get(key) ?: return null + return if (element.isJsonPrimitive) element else null +} + +internal fun JsonObject.string(key: String): String? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isString) element.asString else null +} + +internal fun JsonObject.nonBlankString(key: String): String? = string(key)?.takeIf { it.isNotBlank() } + +internal fun JsonObject.boolean(key: String): Boolean? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isBoolean) element.asBoolean else null +} + +internal fun JsonObject.double(key: String): Double? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isNumber) element.asDouble else null +} + +internal fun JsonObject.obj(key: String): JsonObject? { + val element = get(key) ?: return null + return if (element.isJsonObject) element.asJsonObject else null +} + +internal fun JsonObject.stringMap(key: String): Map { + val obj = obj(key) ?: return emptyMap() + return obj.entrySet() + .filter { (_, value) -> value.isJsonPrimitive && value.asJsonPrimitive.isString } + .associate { (entryKey, value) -> entryKey to value.asString } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt b/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt new file mode 100644 index 000000000..f96879b10 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt @@ -0,0 +1,51 @@ +package org.openedx.core.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import java.util.Locale + +/** + * The parsed instance catalog: every platform the app can sign in to, in catalog order. + */ +data class InstancesConfig( + val instances: List = emptyList(), +) { + + fun instance(withKey: String): Instance? = instances.firstOrNull { it.key == withKey } + + companion object { + const val INSTANCES = "INSTANCES" + + /** + * Builds the catalog from an `INSTANCES` array. Entries that aren't objects or fail + * [Instance.fromJson] are dropped. + * + * @param fallback values used for the fields an entry omits; the entry always wins. + */ + fun fromJson( + array: JsonArray, + fallback: JsonObject = JsonObject(), + languageCode: String = Locale.getDefault().language, + ): InstancesConfig { + val instances = array.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val merged = fallback.deepCopy() + element.asJsonObject.entrySet().forEach { (key, value) -> merged.add(key, value) } + Instance.fromJson(merged, languageCode) + } + return InstancesConfig(instances) + } + + /** The `INSTANCES` array of the bundled `config.json`, or an empty catalog. */ + fun fromAppConfig( + appConfig: JsonObject, + languageCode: String = Locale.getDefault().language, + ): InstancesConfig { + val array = appConfig.get(INSTANCES) + ?.takeIf { it.isJsonArray } + ?.asJsonArray + ?: return InstancesConfig() + return fromJson(array, languageCode = languageCode) + } + } +} diff --git a/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt b/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt new file mode 100644 index 000000000..75fa89e22 --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt @@ -0,0 +1,183 @@ +package org.openedx.core.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InstancesConfigTest { + + private fun fixture(): JsonObject { + val stream = requireNotNull(javaClass.classLoader?.getResourceAsStream("instances/bundled_instances.json")) + return stream.reader().use { JsonParser.parseReader(it).asJsonObject } + } + + private fun requiredFields(): JsonObject = JsonObject().apply { + addProperty("KEY", "test-instance") + addProperty("NAME", "Test Instance") + addProperty("OAUTH_CLIENT_ID", "client-id") + addProperty("API_HOST_URL", "https://test-instance.example.com") + } + + @Test + fun `bundled catalog parses every field of a complete entry`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + val instance = requireNotNull(catalog.instance("example-instance")) + + assertEquals("Example Instance", instance.name) + assertEquals("Example Instance", instance.instanceName) + assertEquals("example-oauth-client-id", instance.oAuthClientId) + assertEquals("https://example-instance.example.com", instance.apiHostUrl) + assertEquals( + "https://example-instance.example.com/auth/login/tpa-saml/?auth_entry=login", + instance.ssoUrl + ) + assertEquals( + "https://example-instance.example.com/auth/login/tpa-saml/auth/complete/tpa-saml", + instance.ssoFinishedUrl + ) + assertEquals("Example Instance", instance.environmentDisplayName) + assertTrue(instance.isSwitchInstanceLoginEnabled) + assertEquals("exampleInstanceAppLogo", instance.logoUrl) + assertNull(instance.headerBackgroundUrl) + assertEquals(mapOf("accent_color" to "#3C68FF"), instance.themeColors?.light) + assertEquals(mapOf("accent_color" to "#1C355E"), instance.themeColors?.dark) + assertTrue(instance.config.has("UI_COMPONENTS")) + } + + @Test + fun `malformed entries are dropped instead of half populated`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + + assertEquals(listOf("example-instance", "second-universite"), catalog.instances.map { it.key }) + assertNull(catalog.instance("missing-client")) + } + + @Test + fun `each required field is enforced`() { + listOf("NAME", "API_HOST_URL", "OAUTH_CLIENT_ID").forEach { field -> + val json = requiredFields().apply { remove(field) } + assertNull("missing $field must be rejected", Instance.fromJson(json)) + } + val blankName = requiredFields().apply { addProperty("NAME", " ") } + assertNull(Instance.fromJson(blankName)) + val relativeHost = requiredFields().apply { addProperty("API_HOST_URL", "test-instance.example.com") } + assertNull(Instance.fromJson(relativeHost)) + val numericClient = requiredFields().apply { addProperty("OAUTH_CLIENT_ID", 42) } + assertNull(Instance.fromJson(numericClient)) + } + + @Test + fun `key falls back to a slug of the name`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + + assertNotNull(catalog.instance("second-universite")) + assertEquals("acme-u-of-arts", Instance.slugify(" ACME U. of Arts! ")) + } + + @Test + fun `display name follows the language then English then NAME`() { + assertEquals("اسم المُزود", InstancesConfig.fromAppConfig(fixture(), "ar").instances[0].instanceName) + assertEquals("Example Instance", InstancesConfig.fromAppConfig(fixture(), "uk").instances[0].instanceName) + assertEquals("Second Université", InstancesConfig.fromAppConfig(fixture(), "ar").instances[1].instanceName) + } + + @Test + fun `optional fields use defaults when omitted`() { + val instance = requireNotNull(Instance.fromJson(requiredFields())) + + assertEquals("#007AFF", instance.color) + assertEquals("Test Instance", instance.platformName) + assertNull(instance.ssoUrl) + assertNull(instance.ssoFinishedUrl) + assertNull(instance.themeColors) + assertNull(instance.logoUrl) + assertFalse(instance.isSwitchInstanceLoginEnabled) + assertTrue(instance.isRoundedCorners) + assertEquals(8.0, instance.buttonCornersRadius, 0.0) + } + + @Test + fun `blank optional strings count as unset`() { + val json = requiredFields().apply { + addProperty("SSO_URL", "") + addProperty("SSO_URL_SUCCESSFUL_LOGIN", " ") + addProperty("PLATFORM_NAME", "") + add("THEME", JsonParser.parseString("""{"LOGO_URL": "", "HEADER_BACKGROUND_URL": ""}""")) + } + val instance = requireNotNull(Instance.fromJson(json)) + + assertNull(instance.ssoUrl) + assertNull(instance.ssoFinishedUrl) + assertNull(instance.logoUrl) + assertNull(instance.headerBackgroundUrl) + assertEquals("Test Instance", instance.platformName) + } + + @Test + fun `instance level values win when provided`() { + val json = requiredFields().apply { + addProperty("PLATFORM_NAME", "Custom Platform Name") + addProperty("SSO_FINISHED_URL", "https://test-instance.example.com/finished") + addProperty("SSO_URL_SUCCESSFUL_LOGIN", "https://test-instance.example.com/ignored") + add("THEME", JsonParser.parseString("""{"ROUNDED_CORNERS_STYLE": false, "BUTTON_CORNERS_RADIUS": 4}""")) + } + val instance = requireNotNull(Instance.fromJson(json)) + + assertEquals("Custom Platform Name", instance.platformName) + assertEquals("https://test-instance.example.com/finished", instance.ssoFinishedUrl) + assertFalse(instance.isRoundedCorners) + assertEquals(4.0, instance.buttonCornersRadius, 0.0) + assertEquals(InstanceThemeColors(emptyMap(), emptyMap()), instance.themeColors) + } + + @Test + fun `fallback fills omitted fields and never overrides the entry`() { + val fallback = JsonObject().apply { + addProperty("OAUTH_CLIENT_ID", "fallback-client") + addProperty("FEEDBACK_EMAIL_ADDRESS", "fallback@example.com") + } + val entry = requiredFields().apply { remove("OAUTH_CLIENT_ID") } + val withClient = requiredFields().apply { addProperty("KEY", "with-client") } + val array = JsonArray().apply { + add(entry) + add(withClient) + } + + val catalog = InstancesConfig.fromJson(array, fallback) + + assertEquals("fallback-client", catalog.instance("test-instance")?.oAuthClientId) + assertEquals("client-id", catalog.instance("with-client")?.oAuthClientId) + val feedbackEmail = catalog.instance("with-client")?.config?.get("FEEDBACK_EMAIL_ADDRESS")?.asString + assertEquals("fallback@example.com", feedbackEmail) + assertFalse(fallback.has("KEY")) + } + + @Test + fun `identity is the key, not the position in the catalog`() { + val fixture = fixture() + val reversed = JsonArray().apply { + fixture.getAsJsonArray("INSTANCES").reversed().forEach { add(it) } + } + + val original = InstancesConfig.fromAppConfig(fixture, "en") + val reordered = InstancesConfig.fromJson(reversed, languageCode = "en") + + assertEquals(original.instance("example-instance"), reordered.instance("example-instance")) + assertEquals(original.instance("second-universite"), reordered.instance("second-universite")) + } + + @Test + fun `missing or non array INSTANCES gives an empty catalog`() { + assertTrue(InstancesConfig.fromAppConfig(JsonObject()).instances.isEmpty()) + val notArray = JsonObject().apply { addProperty("INSTANCES", "nope") } + assertTrue(InstancesConfig.fromAppConfig(notArray).instances.isEmpty()) + val junk = JsonObject().apply { add("INSTANCES", JsonParser.parseString("""[1, "two", null]""")) } + assertTrue(InstancesConfig.fromAppConfig(junk).instances.isEmpty()) + } +} diff --git a/core/src/test/resources/instances/bundled_instances.json b/core/src/test/resources/instances/bundled_instances.json new file mode 100644 index 000000000..da32373fb --- /dev/null +++ b/core/src/test/resources/instances/bundled_instances.json @@ -0,0 +1,45 @@ +{ + "API_HOST_URL": "https://example-instance.example.com", + "OAUTH_CLIENT_ID": "example-oauth-client-id", + "INSTANCES": [ + { + "KEY": "example-instance", + "NAME": "Example Instance", + "INSTANCE_NAME": { + "ar": "اسم المُزود", + "en": "Example Instance" + }, + "ENVIRONMENT_DISPLAY_NAME": "Example Instance", + "OAUTH_CLIENT_ID": "example-oauth-client-id", + "API_HOST_URL": "https://example-instance.example.com", + "SSO_URL": "https://example-instance.example.com/auth/login/tpa-saml/?auth_entry=login", + "SSO_URL_SUCCESSFUL_LOGIN": "https://example-instance.example.com/auth/login/tpa-saml/auth/complete/tpa-saml", + "IS_SWITCH_INSTANCE_LOGIN_ENABLED": true, + "THEME": { + "LIGHT": { + "accent_color": "#3C68FF" + }, + "DARK": { + "accent_color": "#1C355E" + }, + "LOGO_URL": "exampleInstanceAppLogo" + }, + "UI_COMPONENTS": { + "COURSE_UNIT_PROGRESS_ENABLED": false, + "LOGIN_REGISTRATION_ENABLED": true, + "SAML_SSO_LOGIN_ENABLED": false, + "SAML_SSO_DEFAULT_LOGIN_BUTTON": false + } + }, + { + "NAME": "Second Université", + "OAUTH_CLIENT_ID": "second-client-id", + "API_HOST_URL": "https://second.example.com:8443" + }, + { + "KEY": "missing-client", + "NAME": "Missing Client", + "API_HOST_URL": "https://missing-client.example.com" + } + ] +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index f2868eb78..97db15191 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -94,3 +94,52 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false + +# Learning sites ("instances") this build can sign in to. One entry: the app behaves like a +# single-site build, with no site picker. Several entries: the learner picks a site and can stay +# signed in to more than one. NAME, API_HOST_URL and OAUTH_CLIENT_ID are required; every other key +# is optional and falls back to the app-level value above. +INSTANCES: + - KEY: 'localhost' + NAME: 'Localhost' + INSTANCE_NAME: + en: 'Localhost' + ENVIRONMENT_DISPLAY_NAME: 'Localhost' + API_HOST_URL: 'http://localhost:8000' + OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' + SSO_URL: '' + SSO_URL_SUCCESSFUL_LOGIN: '' + IS_SWITCH_INSTANCE_LOGIN_ENABLED: false + FEEDBACK_EMAIL_ADDRESS: 'support@example.com' + FAQ_URL: '' + PLATFORM_NAME: 'OpenEdX' + TOKEN_TYPE: 'JWT' + THEME: + LIGHT: + accent_color: '#3C68FF' + DARK: + accent_color: '#3C68FF' + LOGO_URL: '' + HEADER_BACKGROUND_URL: '' + UI_COMPONENTS: + COURSE_DROPDOWN_NAVIGATION_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false + AGREEMENT_URLS: + PRIVACY_POLICY_URL: '' + COOKIE_POLICY_URL: '' + DATA_SELL_CONSENT_URL: '' + TOS_URL: '' + EULA_URL: '' + SUPPORTED_LANGUAGES: [ ] + DISCOVERY: + TYPE: 'native' + PROGRAM: + TYPE: 'native' + DASHBOARD: + TYPE: 'gallery' + EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + APP_LEVEL_DATES: + ENABLED: false