Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 0 additions & 1 deletion .github/CODEOWNERS

This file was deleted.

6 changes: 3 additions & 3 deletions .github/workflows/swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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') }}
Expand Down Expand Up @@ -47,7 +47,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
8 changes: 4 additions & 4 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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') }}
Expand All @@ -42,7 +42,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
Expand All @@ -51,6 +51,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
4 changes: 2 additions & 2 deletions .github/workflows/validate-translations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions Core/Core.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
9C33F22BA8733548621D5A97 /* InstancesConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE56371521165CD47B36418 /* InstancesConfigTests.swift */; };
BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */; };
Expand Down Expand Up @@ -371,6 +372,7 @@
BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ajaxHandler.js; sourceTree = "<group>"; };
BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AjaxProvider.swift; sourceTree = "<group>"; };
BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = "<group>"; };
02D8C6EECDB077F495200840 /* RequestInterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInterceptorTests.swift; sourceTree = "<group>"; };
A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStoreTests.swift; sourceTree = "<group>"; };
BDE56371521165CD47B36418 /* InstancesConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesConfigTests.swift; sourceTree = "<group>"; };
BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookConfig.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -931,6 +933,7 @@
CE953A3C2CD0DA940023D668 /* Generated */,
CE54C2CE2CC80B4A00E529F9 /* DownloadManager */,
E09179FB2B0F204D002AB695 /* Configuration */,
02D8C6EECDB077F495200840 /* RequestInterceptorTests.swift */,
);
path = CoreTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -1172,6 +1175,7 @@
buildActionMask = 2147483647;
files = (
BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */,
A79BEFA61FDAFB006FBD32F6 /* RequestInterceptorTests.swift in Sources */,
EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */,
9C33F22BA8733548621D5A97 /* InstancesConfigTests.swift in Sources */,
CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */,
Expand Down
47 changes: 44 additions & 3 deletions Core/Core/Network/RequestInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions Core/CoreTests/RequestInterceptorTests.swift
Original file line number Diff line number Diff line change
@@ -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<URLRequest, Error>?
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")
}
}
Loading
Loading