From f45571eb786b01108d5279c89ccf3847fb48fa9b Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Thu, 31 Jul 2025 16:08:43 -0400 Subject: [PATCH 1/5] chore: Delete CODEOWNERS (#632) See: https://github.com/openedx/axim-engineering/issues/1511 --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 12682e978..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @raccoongang/educationx-app-ios-reviewers From 42d71b827c72361181ea5c7006b63eb318598f64 Mon Sep 17 00:00:00 2001 From: irfanuddinahmad <34648393+irfanuddinahmad@users.noreply.github.com> Date: Thu, 28 May 2026 13:46:12 +0500 Subject: [PATCH 2/5] chore: pin GitHub Actions workflows to full commit SHAs (#660) --- .github/workflows/swiftlint.yml | 6 +++--- .github/workflows/unit_tests.yml | 8 ++++---- .github/workflows/validate-translations.yml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml index 15da04520..572355bd6 100644 --- a/.github/workflows/swiftlint.yml +++ b/.github/workflows/swiftlint.yml @@ -14,11 +14,11 @@ jobs: cancel-in-progress: true steps: - - uses: nschloe/action-cached-lfs-checkout@v1.2.1 + - uses: nschloe/action-cached-lfs-checkout@d6efedcb8fc03d006e1e77743718e26234ed2c97 # v1.2.1 with: ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/cache@v3 + - uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3.5.0 with: path: vendor/bundle key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} @@ -41,7 +41,7 @@ jobs: swift PrepareSarifToUpload.swift - name: Upload report - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@458d36d7d4f47d0dd16ca424c1d3cda0060f1360 # v3.35.5 if: success() || failure() with: sarif_file: swiftlint.report.sarif diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 8d72de40f..e39562ea6 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -17,11 +17,11 @@ jobs: cancel-in-progress: true steps: - - uses: nschloe/action-cached-lfs-checkout@v1.2.1 + - uses: nschloe/action-cached-lfs-checkout@d6efedcb8fc03d006e1e77743718e26234ed2c97 # v1.2.1 with: ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: vendor/bundle key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} @@ -36,7 +36,7 @@ jobs: run: bundle exec fastlane unit_tests - name: Archive artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: test-output @@ -45,6 +45,6 @@ jobs: if-no-files-found: ignore - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 with: flags: unittests diff --git a/.github/workflows/validate-translations.yml b/.github/workflows/validate-translations.yml index 8bb49d670..c644875b0 100644 --- a/.github/workflows/validate-translations.yml +++ b/.github/workflows/validate-translations.yml @@ -33,12 +33,12 @@ jobs: test -f Authorization/Authorization/uk.lproj/Localizable.strings; steps: - - uses: nschloe/action-cached-lfs-checkout@v1.2.1 + - uses: nschloe/action-cached-lfs-checkout@d6efedcb8fc03d006e1e77743718e26234ed2c97 # v1.2.1 with: ref: ${{ github.event.pull_request.head.sha }} - name: Use Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: 3.11 From cd539a6e64c0fdd686515d36fb4b2f3b9f9a9b68 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:44:25 +0000 Subject: [PATCH 3/5] feat: route requests through the selected instance's host (RequestInterceptor) Reuses the tenants branch's already-built host-rewrite approach (renamed tenant -> instance): API is still constructed once against the app's default ConfigProtocol.baseURL, and RequestInterceptor.adapt(...) re-targets any request built against that host at the currently selected instance's baseURL, preserving path/query/fragment. Requests that already point elsewhere (SSO webviews, third-party SDKs) are left untouched. refreshToken(...) prefers the selected instance's baseURL/ oAuthClientId, falling back to the app config for single-instance deployments or before an instance is picked. instanceStore comes in via the InstanceProvider protocol, already registered in DI by PR-2 -- no new DI wiring beyond passing it through to RequestInterceptor's initializer. --- Core/Core.xcodeproj/project.pbxproj | 4 ++ Core/Core/Network/RequestInterceptor.swift | 47 ++++++++++++- Core/CoreTests/RequestInterceptorTests.swift | 71 ++++++++++++++++++++ OpenEdX/DI/NetworkAssembly.swift | 6 +- 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 Core/CoreTests/RequestInterceptorTests.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index ad9b5713c..353c12d70 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -147,6 +147,7 @@ BAD9CA2F2B289B3500DE790A /* ajaxHandler.js in Resources */ = {isa = PBXBuildFile; fileRef = BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */; }; BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */; }; BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */; }; + A79BEFA61FDAFB006FBD32F6 /* RequestInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D8C6EECDB077F495200840 /* RequestInterceptorTests.swift */; }; EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */; }; BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */; }; BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */; }; @@ -370,6 +371,7 @@ BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ajaxHandler.js; sourceTree = ""; }; BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AjaxProvider.swift; sourceTree = ""; }; BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = ""; }; + 02D8C6EECDB077F495200840 /* RequestInterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInterceptorTests.swift; sourceTree = ""; }; A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStoreTests.swift; sourceTree = ""; }; BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookConfig.swift; sourceTree = ""; }; BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftConfig.swift; sourceTree = ""; }; @@ -929,6 +931,7 @@ CE953A3C2CD0DA940023D668 /* Generated */, CE54C2CE2CC80B4A00E529F9 /* DownloadManager */, E09179FB2B0F204D002AB695 /* Configuration */, + 02D8C6EECDB077F495200840 /* RequestInterceptorTests.swift */, ); path = CoreTests; sourceTree = ""; @@ -1169,6 +1172,7 @@ buildActionMask = 2147483647; files = ( BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */, + A79BEFA61FDAFB006FBD32F6 /* RequestInterceptorTests.swift in Sources */, EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */, CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index d6bb9b8cd..21286e861 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -4,6 +4,12 @@ // // Created by Vladimir Chekyrta on 14.09.2022. // +// Instance-aware: requests built by `API` against the app's default +// `ConfigProtocol.baseURL` are re-targeted at the currently selected instance's +// host in `adapt(...)`, and `refreshToken(...)` prefers the selected instance's +// base URL / OAuth client id. Requests that already point elsewhere (SSO +// webviews, third-party SDKs) are left untouched. +// import Foundation import Alamofire @@ -17,10 +23,12 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { private let config: ConfigProtocol private let storage: CoreStorage + private let instanceStore: InstanceProvider - public init(config: ConfigProtocol, storage: CoreStorage) { + public init(config: ConfigProtocol, storage: CoreStorage, instanceStore: InstanceProvider) { self.config = config self.storage = storage + self.instanceStore = instanceStore } private let lock = NSLock() @@ -63,9 +71,37 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { urlRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent") + urlRequest = rewriteHostIfNeeded(urlRequest) + completion(.success(urlRequest)) } + /// If an instance is selected and the request currently targets the app's base + /// `ConfigProtocol.baseURL` (i.e. it was built by `API` from its fixed baseURL), + /// re-target it at the instance's host, preserving path/query/fragment. Requests + /// that already point somewhere else are left untouched. + private func rewriteHostIfNeeded(_ request: URLRequest) -> URLRequest { + guard let instance = instanceStore.currentInstance, + let requestURL = request.url, + var components = URLComponents(url: requestURL, resolvingAgainstBaseURL: false), + let targetComponents = URLComponents(url: instance.baseURL, resolvingAgainstBaseURL: false), + requestURL.host == config.baseURL.host + else { + // No instance selected, the instance host can't be parsed, or this request + // wasn't built against the app's default base URL to begin with. + return request + } + + components.scheme = targetComponents.scheme + components.host = targetComponents.host + components.port = targetComponents.port + + guard let newURL = components.url else { return request } + var newRequest = request + newRequest.url = newURL + return newRequest + } + public func retry( _ request: Request, for session: Session, @@ -119,11 +155,16 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { mutableState.isRefreshing = true - let url = config.baseURL.appendingPathComponent("/oauth2/access_token") + // Prefer the currently selected instance's base URL / OAuth client id; fall + // back to the app config for single-instance deployments (or before an + // instance has been picked). + let currentInstance = instanceStore.currentInstance + let url = (currentInstance?.baseURL ?? config.baseURL).appendingPathComponent("/oauth2/access_token") + let clientId = currentInstance?.oAuthClientId ?? config.oAuthClientId let parameters: [String: Encodable & Sendable] = [ "grant_type": AuthConstants.GrantTypeRefreshToken, - "client_id": config.oAuthClientId, + "client_id": clientId, "refresh_token": refreshToken, "token_type": config.tokenType.rawValue, "asymmetric_jwt": true diff --git a/Core/CoreTests/RequestInterceptorTests.swift b/Core/CoreTests/RequestInterceptorTests.swift new file mode 100644 index 000000000..880817543 --- /dev/null +++ b/Core/CoreTests/RequestInterceptorTests.swift @@ -0,0 +1,71 @@ +// +// RequestInterceptorTests.swift +// CoreTests +// +// Created by Rawan Matar on 15/09/2026. +// + +import XCTest +import Alamofire +@testable import Core + +final class RequestInterceptorTests: XCTestCase { + + private func makeConfig(baseURL: String = "https://app.example.com") -> ConfigProtocolMock { + let config = ConfigProtocolMock() + config.baseURL = URL(string: baseURL)! + config.oAuthClientId = "app-oauth-client-id" + return config + } + + private func adapt( + _ interceptor: Core.RequestInterceptor, + url: String + ) throws -> URLRequest { + let request = URLRequest(url: URL(string: url)!) + var result: Result? + interceptor.adapt(request, for: Session()) { result = $0 } + return try XCTUnwrap(result?.get()) + } + + func test_adapt_selectedInstance_rewritesRequestBuiltAgainstAppBaseURL() throws { + let config = makeConfig() + let instance = Instance.mock(baseURL: URL(string: "https://acme.example.com")!) + let instanceStore = InstanceProviderMock(currentInstance: instance) + let interceptor = Core.RequestInterceptor( + config: config, storage: CoreStorageMock(), instanceStore: instanceStore + ) + + let adapted = try adapt(interceptor, url: "https://app.example.com/api/v1/courses?page=2") + + XCTAssertEqual(adapted.url?.host, "acme.example.com") + XCTAssertEqual(adapted.url?.path, "/api/v1/courses") + XCTAssertEqual(adapted.url?.query, "page=2") + } + + func test_adapt_noInstanceSelected_leavesRequestUntouched() throws { + let config = makeConfig() + let instanceStore = InstanceProviderMock(currentInstance: nil) + let interceptor = Core.RequestInterceptor( + config: config, storage: CoreStorageMock(), instanceStore: instanceStore + ) + + let adapted = try adapt(interceptor, url: "https://app.example.com/api/v1/courses") + + XCTAssertEqual(adapted.url?.host, "app.example.com") + } + + func test_adapt_requestNotBuiltAgainstAppBaseURL_leftUntouched() throws { + let config = makeConfig() + let instance = Instance.mock(baseURL: URL(string: "https://acme.example.com")!) + let instanceStore = InstanceProviderMock(currentInstance: instance) + let interceptor = Core.RequestInterceptor( + config: config, storage: CoreStorageMock(), instanceStore: instanceStore + ) + + // e.g. an SSO webview or third-party SDK call -- never rewritten. + let adapted = try adapt(interceptor, url: "https://sso.thirdparty.com/authorize") + + XCTAssertEqual(adapted.url?.host, "sso.thirdparty.com") + } +} diff --git a/OpenEdX/DI/NetworkAssembly.swift b/OpenEdX/DI/NetworkAssembly.swift index f8280670f..f0d89da4f 100644 --- a/OpenEdX/DI/NetworkAssembly.swift +++ b/OpenEdX/DI/NetworkAssembly.swift @@ -34,7 +34,11 @@ class NetworkAssembly: Assembly { }.inObjectScope(.container) container.register(RequestInterceptor.self) { r in - RequestInterceptor(config: r.resolve(ConfigProtocol.self)!, storage: r.resolve(CoreStorage.self)!) + RequestInterceptor( + config: r.resolve(ConfigProtocol.self)!, + storage: r.resolve(CoreStorage.self)!, + instanceStore: r.resolve(InstanceProvider.self)! + ) }.inObjectScope(.container) container.register(Alamofire.Session.self) { r in From 6c26abe59d6da5952ef69080847f764b9a5af780 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:25:37 +0000 Subject: [PATCH 4/5] feat: fail fast with a migration hint when config.yaml wasn't converted process_config.py previously just fell into the generic "config files not found" exit when config.json didn't exist yet, with no indication that config.yaml (retired in 85aa83f3) was the reason. fail_if_unmigrated_yaml_config now detects a leftover config.yaml with no sibling config.json and fails with a message naming both files and the converter to run. Add config_script/yaml_to_json_config.py: a small standalone converter (reuses this project's existing PyYAML/json deps, no new dependency) so operators don't hand-write the JSON. See Ivan's review on #677. --- config_script/process_config.py | 17 +++++++++++++++ config_script/yaml_to_json_config.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100755 config_script/yaml_to_json_config.py diff --git a/config_script/process_config.py b/config_script/process_config.py index dbbf96b2d..a5bab532f 100644 --- a/config_script/process_config.py +++ b/config_script/process_config.py @@ -308,6 +308,21 @@ def parse_yaml(file_path): CONFIG_DIRECTORY_NAME = 'config_directory' CONFIG_MAPPINGS = 'config_mapping' MAPPINGS_FILENAME = 'file_mappings.yaml' +LEGACY_YAML_CONFIG_FILENAME = 'config.yaml' +JSON_CONFIG_FILENAME = 'config.json' + + +# Fail with a clear reason if config.yaml wasn't converted. +def fail_if_unmigrated_yaml_config(config_directory_path): + yaml_path = os.path.join(config_directory_path, LEGACY_YAML_CONFIG_FILENAME) + json_path = os.path.join(config_directory_path, JSON_CONFIG_FILENAME) + if os.path.exists(yaml_path) and not os.path.exists(json_path): + print( + f"Found '{yaml_path}' but no '{json_path}'. config.yaml was retired for " + f"config.json -- run: python3 config_script/yaml_to_json_config.py {yaml_path}" + ) + sys.exit(1) + def get_current_config(configuration, scheme_mappings): for key, values in scheme_mappings.items(): @@ -351,6 +366,8 @@ def main(configuration, scheme_mappings): if config_directory and config_name: path = os.path.join(config_directory, config_name) + fail_if_unmigrated_yaml_config(path) + mappings_path = os.path.join(path, MAPPINGS_FILENAME) data = parse_yaml(mappings_path) diff --git a/config_script/yaml_to_json_config.py b/config_script/yaml_to_json_config.py new file mode 100755 index 000000000..3325a46eb --- /dev/null +++ b/config_script/yaml_to_json_config.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Convert a legacy config.yaml to config.json. + +Usage: python3 config_script/yaml_to_json_config.py path/to/config.yaml +""" +import json +import sys + +import yaml + + +def main(): + if len(sys.argv) != 2: + print("Usage: yaml_to_json_config.py ") + sys.exit(1) + + yaml_path = sys.argv[1] + json_path = yaml_path.rsplit('.', 1)[0] + '.json' + + with open(yaml_path) as f: + data = yaml.safe_load(f) + + with open(json_path, 'w') as f: + json.dump(data, f, indent=2, sort_keys=True) + f.write('\n') + + print(f"Wrote {json_path}. Delete {yaml_path} once you've checked it in.") + + +if __name__ == "__main__": + main() From a724c06f6ca2404bab075aca5589de6c0cd786e3 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:26:03 +0000 Subject: [PATCH 5/5] feat: convert config_settings.yaml/file_mappings.yaml to JSON Last two YAML files in default_config/ -- everything else there has been JSON since 85aa83f3. Converts config_settings.yaml -> config_settings.json and each environment's file_mappings.yaml -> file_mappings.json (via yaml_to_json_config.py), updates process_config.py and whitelabel.py (which duplicates the same config_settings/file_mappings parsing) to read JSON instead. Addresses Ivan's review on #677: migration path, docs, and one format instead of JSON+YAML side by side in the same folder. --- Documentation/CONFIGURATION_MANAGEMENT.md | 201 +++++++++++----------- Documentation/Theming_implementation.md | 4 +- README.md | 2 +- config_script/process_config.py | 17 +- config_script/whitelabel.py | 22 +-- default_config/config_settings.json | 8 + default_config/config_settings.yaml | 5 - default_config/dev/file_mappings.json | 7 + default_config/dev/file_mappings.yaml | 3 - default_config/prod/file_mappings.json | 7 + default_config/prod/file_mappings.yaml | 3 - default_config/stage/file_mappings.json | 7 + default_config/stage/file_mappings.yaml | 3 - 13 files changed, 150 insertions(+), 139 deletions(-) create mode 100644 default_config/config_settings.json delete mode 100644 default_config/config_settings.yaml create mode 100644 default_config/dev/file_mappings.json delete mode 100644 default_config/dev/file_mappings.yaml create mode 100644 default_config/prod/file_mappings.json delete mode 100644 default_config/prod/file_mappings.yaml create mode 100644 default_config/stage/file_mappings.json delete mode 100644 default_config/stage/file_mappings.yaml diff --git a/Documentation/CONFIGURATION_MANAGEMENT.md b/Documentation/CONFIGURATION_MANAGEMENT.md index b8695e546..91e895e13 100644 --- a/Documentation/CONFIGURATION_MANAGEMENT.md +++ b/Documentation/CONFIGURATION_MANAGEMENT.md @@ -1,108 +1,105 @@ # Configuration Management -This documentation provides a comprehensive solution for integrating and managing configuration files in OpenEdx iOS project. +This document describes how the app is configured, both locally (build-time) and remotely +(runtime instance catalog). As of the instance-model work (`infra/03-remote-config-fetch` +onward), **JSON is the single configuration format used end-to-end** — there is no YAML +anywhere in the config pipeline. + +## Why one format + +Earlier revisions of this project used `config.yaml` for local/build-time config and were +briefly considering a second, separate JSON format for the remote instance directory (fetched +by URL, or bundled locally). That would have meant two independent parsers — `PyYAML` in the +build scripts and a JSON decoder in Swift — each with its own schema and casing convention, +kept in sync only by developer discipline. Nothing in the toolchain enforces that; a field +renamed or reshaped in one format's parser and not the other drifts silently until it breaks +at runtime. + +Using JSON everywhere collapses that to one schema, one casing convention +(`UPPER_SNAKE_CASE`), and one thing to keep consistent when the schema changes. + +## Local / build-time config: `config.json` + +- Lives per environment: `default_config/dev/config.json`, `.../stage/config.json`, + `.../prod/config.json`. (`config.yaml` has been deleted from all three — there is no YAML + fallback.) +- Read at build time by `config_script/process_config.py` and `whitelabel.py`, which were + rewritten to parse JSON instead of YAML. These scripts flatten the relevant keys into the + build-time `Info.plist` that `Config.swift` reads at runtime — the Swift layer never parses + `config.json` directly for these app-level keys. +- `default_config/config_settings.json` (which config directory/mapping the build uses) and + each environment's `file_mappings.json` (which config file maps to which target) are JSON + too — build-tooling plumbing, not app config, but kept in the same format as everything + else so `default_config/` isn't half JSON, half YAML. +- Also holds `INSTANCES_CATALOG_URL` — the URL the app fetches the remote instance catalog + from at launch — and a bundled fallback catalog for offline/first-run use. +- **Temporary compatibility bridge**: `Config.swift` still hard-requires `API_HOST_URL`, + `SSO_URL`, `SSO_FINISHED_URL`, and `OAUTH_CLIENT_ID` at the top level, because it's the only + `ConfigProtocol` implementation wired into DI until `InstanceAwareConfig` lands (PR-9). + These four keys are mirrored from the example/default instance and are explicitly commented + as removable once PR-9 ships. Don't add new app-level keys here without checking whether + they actually belong on `Instance` instead. + +## Upgrading from a pre-JSON checkout + +If your `config_directory` still has an old `config.yaml` and no `config.json`, +`process_config.py` fails the build with a message naming both files rather than a generic +"config files not found." Convert with: -## Features - -- **Build Phase Script Integration:** Adds a script to the Build Phase of Xcode. It calls the Xcode build phase run script, which takes care of the virtual environment and installing dependencies and executes a Python script `process_config.py` with `$CONFIGURATION` and `scheme_mappings` argument. -- **Python Script for Configuration:** Utilizes `process_config.py` for: - - Adding essential keys to `Info.plist` (e.g., Facebook, Microsoft keys). - - Creating `GoogleServices.plist` with Firebase keys. - - Generating `config.plist` from `ios.yaml` and `shared.yaml`. - -Inside `Config.swift`, parsing and populating relevant keys and classes are done, e.g. `AgreementConfig.swift` and `FirebaseConfig.swift`. - -## Getting Started - -### Configuration Setup - -Edit a `config_settings.yaml` in the `default_config` folder. It should contain data as follows: - -```yaml -config_directory: '{path_to_config_folder}' -config_mapping: - prod: 'prod' - stage: 'stage' - dev: 'dev' -# These mappings are configurable, e.g. dev: 'prod_test' ``` - -- `config_directory` provides the path of the config directory. -- `config_mappings` provides mappings that can be utilized to map the Xcode build scheme to a defined folder within the config directory, and it will be referenced. - -### Configuration Files - -Two main configuration files are used: `ios.yaml` and `shared.yaml`, placed under the folder defined in `config_mappings`. Additionally, a `mappings.yaml` file is required in the same directory, specifying the YAML files to be processed. Its structure is as follows: - -```yaml -ios: - files: - - {file_one.yaml} - - {file_two.yaml} -``` - -- `ios.yaml` will contain config data specific to iOS, e.g., Firebase keys, Facebook keys, etc. -- `shared.yaml` will contain config data that is shared, e.g., `API_HOST_URL`, `OAUTH_CLIENT_ID`, `TOKEN_TYPE`, etc. - -## Future Support - -- To add config related to some other service, create a class, e.g. `ServiceNameConfig.swift`, to be able to populate related fields. -- Create an `extension` to `Config.swift` to be able to add the newly created service as a variable to the main Config. -- If needed, make a protocol to be referenced inside the scope of `ConfigProtocol` so that the config is available using `ConfigProtocol` service. - -Example: - -```swift -private let key = "KEY" -extension Config { - public var serviceNameConfig: ServiceNameConfig { - return ServiceNameConfig(dictionary: self[key] as? [String: AnyObject] ?? [:]) - } -} -``` - -## Note - -If Firebase Configuration is provided the updated `FirebaseCrashlytics` build phase script extracts `googleAppID` from the newly generated `GoogleService-Info.plist` and runs the Crashlytics script with the provifing id. - -## Examples of Config Files - -`ios.yaml`: - -```yaml -OAUTH_CLIENT_ID: '' - -FIREBASE: - ENABLED: true - API_KEY: "testApiKey" - BUNDLE_ID: "testBundleID" - CLIENT_ID: "testClientID" - DATABASE_URL: "https://test.database.url" - GCM_SENDER_ID: "testGCMSenderID" - GOOGLE_APP_ID: "testGoogleAppID" - PROJECT_ID: "testProjectID" - REVERSED_CLIENT_ID: "testReversedClientID" - STORAGE_BUCKET: "testStorageBucket" - ANALYTICS_SOURCE: "firebase" - -MICROSOFT: - ENABLED: true - CLIENT_ID: "microsoftAppID" -``` - -`shared.yaml`: - -```yaml -API_HOST_URL: "https://www.example.com" -FEEDBACK_EMAIL_ADDRESS: "example@mail.com" -TOKEN_TYPE: "JWT" - -AGREEMENT_URLS: - PRIVACY_POLICY_URL: "https://www.example.com/privacy" - TOS_URL: "https://www.example.com/tos" - -# Features -WHATS_NEW_ENABLED: false +python3 config_script/yaml_to_json_config.py path/to/config.yaml ``` -The `default_config` directory is added to the project to provide an idea of how to write config YAML files. +This writes the equivalent `config.json` next to it (structure carries over as-is — the +schema didn't reshape moving formats, just casing, which was already consistent). Delete the +old `.yaml` file once you've checked the output in. The same script converts +`config_settings.yaml`/`file_mappings.yaml` if you're upgrading from before those were +converted too. + +## Remote instance catalog + +- `InstanceApiService` does a plain `URLSession` GET against `INSTANCES_CATALOG_URL` — not + routed through the authenticated Alamofire/API stack, since this is a fixed, anonymous, + external endpoint. It returns raw JSON bytes; all parsing happens in `InstancesConfig`. +- `InstanceConfigLoader.load()` merge policy (offline-safe): + - Baseline = last cached successful fetch, else the bundled catalog, else empty. + - Every launch awaits a live fetch. A non-empty response wholesale-replaces the baseline and + becomes the new cache. + - A failed fetch, or a fetch that succeeds with zero instances, leaves the baseline + untouched — a reachable-but-empty catalog must never wipe a good cache. +- Both the bundled catalog and the remote catalog are parsed through the same `Instance` / + `InstancesConfig` `Codable` model — one schema for both sources, not two. + +## Schema conventions + +- All keys this project owns are uniformly `UPPER_SNAKE_CASE` (`NAME`, `COLOR`, `THEME.LIGHT` + / `DARK`, `LOGO_URL`, `HEADER_BACKGROUND_URL`, etc.) across local and remote — a shared + instance schema reads the same regardless of which source parsed it. +- A remote payload that arrives snake_case or mixed-case is normalized on the way in, + key-by-key, at every nesting depth. Anything with no explicit mapping passes through + unchanged. + - **Known naming debt**: this normalization function is still called + `remoteToYAMLKeyMap`, a holdover from before YAML was retired — it no longer maps to + YAML anything. Rename it (e.g. `remoteKeyNormalizationMap`) as part of the PR-9 cleanup + below, so the name doesn't mislead anyone reading the schema fresh. +- Palette-internal field names (`accent_color`, etc.) are deliberately left alone — that's the + Theme module's own contract, not this schema's to rename. + +## What's explicitly out of scope today + +- Wiring `InstanceConfigLoader` into the app launch sequence / DI — PR-9. +- Instance picker / selection UI — PR-10. +- Removing the temporary `Config.swift` bridge keys once `InstanceAwareConfig` exists, and + renaming `remoteToYAMLKeyMap` — PR-9. +- Removing the dead `ConfigProtocol.instancesCatalogURL` property, once nothing references it. + +## Summary + +| Concern | Format | Owner | +|---|---|---| +| Local/build-time app config | JSON (`config.json`) | `process_config.py` / `whitelabel.py` → build-time plist | +| Build-tooling plumbing (`config_settings.json`, `file_mappings.json`) | JSON | `process_config.py` / `whitelabel.py` | +| Bundled fallback instance catalog | JSON | `InstancesConfig` (`Codable`) | +| Remote instance catalog | JSON (fetched from `INSTANCES_CATALOG_URL`) | `InstanceApiService` + `InstanceConfigLoader` | + +No YAML remains anywhere in this pipeline. diff --git a/Documentation/Theming_implementation.md b/Documentation/Theming_implementation.md index 735ebef8e..a734b5271 100644 --- a/Documentation/Theming_implementation.md +++ b/Documentation/Theming_implementation.md @@ -45,11 +45,11 @@ project_config: config1: # Build Configuration name in project app_bundle_id: "bundle.id.app.new1" # Bundle ID to be set product_name: "Mobile App Name1" # App Name to be set - env_config: 'prod' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.yaml defines) + env_config: 'prod' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.json defines) config2: # Build Configuration name in project app_bundle_id: "bundle.id.app.new2" # Bundle ID to be set product_name: "Mobile App Name2" # App Name to be set - env_config: 'dev' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.yaml defines) + env_config: 'dev' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.json defines) ``` ### Assets The config `whitelabel.yaml` can contain a few Asset items (every added Xcode project can have its own Assets). diff --git a/README.md b/README.md index b42796aa0..5cbf2c44d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Modern vision of the mobile application for the Open edX platform from Raccoon G 4. Ensure that the ``OpenEdXDev``, ``OpenEdXStage`` or ``OpenEdXProd`` scheme is selected. -5. Configure `config_settings.yaml` inside `default_config` and `config.json` inside sub direcroties to point to your OpenEdx configuration [Configuration Documentation](./Documentation/CONFIGURATION_MANAGEMENT.md) +5. Configure `config_settings.json` inside `default_config` and `config.json` inside sub direcroties to point to your OpenEdx configuration [Configuration Documentation](./Documentation/CONFIGURATION_MANAGEMENT.md) 6. Click the **Run** button. diff --git a/config_script/process_config.py b/config_script/process_config.py index a5bab532f..58086ed59 100644 --- a/config_script/process_config.py +++ b/config_script/process_config.py @@ -1,7 +1,6 @@ import plistlib import os import shutil -import yaml from pathlib import Path import sys import json @@ -295,19 +294,19 @@ def update_info_plist(self, plist_data, plist_path): print(f"Error reading or writing plist file: {e}") sys.exit(1) -def parse_yaml(file_path): +def parse_json(file_path): try: with open(file_path, 'r') as file: - return yaml.safe_load(file) + return json.load(file) except Exception as e: print(f"Unable to open or read the file '{file_path}': {e}") return None -CONFIG_SETTINGS_YAML_FILENAME = 'config_settings.yaml' -DEFAULT_CONFIG_PATH = './default_config/' + CONFIG_SETTINGS_YAML_FILENAME +CONFIG_SETTINGS_FILENAME = 'config_settings.json' +DEFAULT_CONFIG_PATH = './default_config/' + CONFIG_SETTINGS_FILENAME CONFIG_DIRECTORY_NAME = 'config_directory' CONFIG_MAPPINGS = 'config_mapping' -MAPPINGS_FILENAME = 'file_mappings.yaml' +MAPPINGS_FILENAME = 'file_mappings.json' LEGACY_YAML_CONFIG_FILENAME = 'config.yaml' JSON_CONFIG_FILENAME = 'config.json' @@ -355,11 +354,11 @@ def main(configuration, scheme_mappings): print("Config not found in mappings. Exiting.") sys.exit(1) - config_settings = parse_yaml(CONFIG_SETTINGS_YAML_FILENAME) + config_settings = parse_json(CONFIG_SETTINGS_FILENAME) if not config_settings: print("Parsing default config.") - config_settings = parse_yaml(DEFAULT_CONFIG_PATH) + config_settings = parse_json(DEFAULT_CONFIG_PATH) config_directory = config_settings.get(CONFIG_DIRECTORY_NAME) config_name = config_settings.get(CONFIG_MAPPINGS, {}).get(current_config) @@ -369,7 +368,7 @@ def main(configuration, scheme_mappings): fail_if_unmigrated_yaml_config(path) mappings_path = os.path.join(path, MAPPINGS_FILENAME) - data = parse_yaml(mappings_path) + data = parse_json(mappings_path) if data: ios_json_files = data.get('ios', {}).get('json_files', []) diff --git a/config_script/whitelabel.py b/config_script/whitelabel.py index 808c23c83..55f652bfe 100644 --- a/config_script/whitelabel.py +++ b/config_script/whitelabel.py @@ -50,11 +50,11 @@ class WhitelabelApp: config1: # build configuration name in project app_bundle_id: "bundle.id.app.new1" # bundle ID which should be set product_name: "Mobile App Name1" # app name which should be set - env_config: 'prod' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.yaml defines) + env_config: 'prod' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.json defines) config2: # build configuration name in project app_bundle_id: "bundle.id.app.new2" # bundle ID which should be set product_name: "Mobile App Name2" # app name which should be set - env_config: 'dev' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.yaml defines) + env_config: 'dev' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.json defines) font: font_import_file_path: 'path/to/importing/Font_file.ttf' # path to ttf font file what should be imported to project project_font_file_path: 'path/to/font/file/in/project/font.ttf' # path to existing ttf font file in project @@ -520,16 +520,16 @@ def copy_project_files(self): logging.debug("Project's Files for copying not found in config") # params from MOBILE CONFIG - CONFIG_SETTINGS_YAML_FILENAME = 'config_settings.yaml' - DEFAULT_CONFIG_PATH = './default_config/' + CONFIG_SETTINGS_YAML_FILENAME + CONFIG_SETTINGS_FILENAME = 'config_settings.json' + DEFAULT_CONFIG_PATH = './default_config/' + CONFIG_SETTINGS_FILENAME CONFIG_DIRECTORY_NAME = 'config_directory' CONFIG_MAPPINGS = 'config_mapping' - MAPPINGS_FILENAME = 'file_mappings.yaml' + MAPPINGS_FILENAME = 'file_mappings.json' - def parse_yaml(self, file_path): + def parse_json(self, file_path): try: with open(file_path, 'r') as file: - return yaml.safe_load(file) + return json.load(file) except Exception as e: logging.error(f"Unable to open or read the file '{file_path}': {e}") return None @@ -539,7 +539,7 @@ def get_mobile_config(self, config_directory, config_folder, errors_texts): path = os.path.join(config_directory, config_folder) mappings_path = os.path.join(path, self.MAPPINGS_FILENAME) # read mappings file - data = self.parse_yaml(mappings_path) + data = self.parse_json(mappings_path) if data: # get config for ios described in mappings file ios_json_files = data.get('ios', {}).get('json_files', []) @@ -556,9 +556,9 @@ def get_mobile_config(self, config_directory, config_folder, errors_texts): def set_flags_from_mobile_config(self): # get path to mobile config - config_settings = self.parse_yaml(self.CONFIG_SETTINGS_YAML_FILENAME) + config_settings = self.parse_json(self.CONFIG_SETTINGS_FILENAME) if not config_settings: - config_settings = self.parse_yaml(self.DEFAULT_CONFIG_PATH) + config_settings = self.parse_json(self.DEFAULT_CONFIG_PATH) config_directory = config_settings.get(self.CONFIG_DIRECTORY_NAME) # check if we found config directory if config_directory: @@ -579,7 +579,7 @@ def set_flags_from_mobile_config(self): # project_file_string = self.replace_fullstory_flag(project_file_string, config_directory, name, config_folder, errors_texts) pass else: - logging.error("Config folder for '"+config['env_config']+"' is not defined in config_settings.yaml->config_mapping") + logging.error("Config folder for '"+config['env_config']+"' is not defined in config_settings.json->config_mapping") else: logging.error("'env_config' is not defined for "+name) # write to project file diff --git a/default_config/config_settings.json b/default_config/config_settings.json new file mode 100644 index 000000000..5b1c20577 --- /dev/null +++ b/default_config/config_settings.json @@ -0,0 +1,8 @@ +{ + "config_directory": "./default_config", + "config_mapping": { + "dev": "dev", + "prod": "prod", + "stage": "stage" + } +} diff --git a/default_config/config_settings.yaml b/default_config/config_settings.yaml deleted file mode 100644 index 249e93fc3..000000000 --- a/default_config/config_settings.yaml +++ /dev/null @@ -1,5 +0,0 @@ -config_directory: './default_config' -config_mapping: - prod: 'prod' - stage: 'stage' - dev: 'dev' diff --git a/default_config/dev/file_mappings.json b/default_config/dev/file_mappings.json new file mode 100644 index 000000000..b9599e61a --- /dev/null +++ b/default_config/dev/file_mappings.json @@ -0,0 +1,7 @@ +{ + "ios": { + "json_files": [ + "config.json" + ] + } +} diff --git a/default_config/dev/file_mappings.yaml b/default_config/dev/file_mappings.yaml deleted file mode 100644 index f0c64b9e8..000000000 --- a/default_config/dev/file_mappings.yaml +++ /dev/null @@ -1,3 +0,0 @@ -ios: - json_files: - - config.json diff --git a/default_config/prod/file_mappings.json b/default_config/prod/file_mappings.json new file mode 100644 index 000000000..b9599e61a --- /dev/null +++ b/default_config/prod/file_mappings.json @@ -0,0 +1,7 @@ +{ + "ios": { + "json_files": [ + "config.json" + ] + } +} diff --git a/default_config/prod/file_mappings.yaml b/default_config/prod/file_mappings.yaml deleted file mode 100644 index f0c64b9e8..000000000 --- a/default_config/prod/file_mappings.yaml +++ /dev/null @@ -1,3 +0,0 @@ -ios: - json_files: - - config.json diff --git a/default_config/stage/file_mappings.json b/default_config/stage/file_mappings.json new file mode 100644 index 000000000..b9599e61a --- /dev/null +++ b/default_config/stage/file_mappings.json @@ -0,0 +1,7 @@ +{ + "ios": { + "json_files": [ + "config.json" + ] + } +} diff --git a/default_config/stage/file_mappings.yaml b/default_config/stage/file_mappings.yaml deleted file mode 100644 index f0c64b9e8..000000000 --- a/default_config/stage/file_mappings.yaml +++ /dev/null @@ -1,3 +0,0 @@ -ios: - json_files: - - config.json