Skip to content
Open
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
29 changes: 29 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!--
Provide a description of your changes below and a general summary in the title.
Please also provide some test recommendations if necessary to ensure we don't have regressions.
Please look at the following checklist to ensure that your PR can be accepted quickly:
-->

## Description

<!--- Describe your changes in detail -->

## Regression Test Recommendations

<!--- Functionality that could be affected by the change and any other concerns -->

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore

## Estimated time to fix the ticket(s) or epic(s) referenced by the PR in days

<!--- Add estimate to complete the work -->
35 changes: 35 additions & 0 deletions FlagsmithClient/Classes/Flagsmith.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,41 @@ typealias CompletionHandler<T> = @Sendable (Result<T, any Error>) -> Void
public final class Flagsmith: @unchecked Sendable {
/// Shared singleton client object
public static let shared: Flagsmith = .init()

/// SDK version constant - should match the podspec version
/// This is used as a fallback when bundle version detection fails
// x-release-please-start-version
private static let sdkVersionConstant = "3.8.4"
// // x-release-please-end

/// User-Agent header value for HTTP requests
/// Format: flagsmith-swift-ios-sdk/<version>
/// Falls back to hardcoded constant if version is not discoverable at runtime
public static var userAgent: String {
let version = getSDKVersion()
return "flagsmith-swift-ios-sdk/\(version)"
}

/// Get the SDK version from the bundle at runtime
/// Falls back to hardcoded constant if version is not discoverable
private static func getSDKVersion() -> String {
// Try CocoaPods bundle first
if let bundle = Bundle(identifier: "org.cocoapods.FlagsmithClient"),
let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String,
!version.isEmpty,
version.range(of: #"^\d+\.\d+\.\d+"#, options: .regularExpression) != nil {
return version
}

// Try SPM bundle
if let version = Bundle(for: Flagsmith.self).infoDictionary?["CFBundleShortVersionString"] as? String,
!version.isEmpty,
version.range(of: #"^\d+\.\d+\.\d+"#, options: .regularExpression) != nil {
return version
}

return sdkVersionConstant
}
private let apiManager: APIManager
private let sseManager: SSEManager
private let analytics: FlagsmithAnalytics
Expand Down
2 changes: 1 addition & 1 deletion FlagsmithClient/Classes/Internal/APIManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable {
/// - router: The path and parameters that should be requested.
/// - decoder: `JSONDecoder` used to deserialize the response data.
/// - completion: Function block executed with the result of the request.
func request<T: Decodable>(_ router: Router, using decoder: JSONDecoder = JSONDecoder(),
func request<T: Decodable & Sendable>(_ router: Router, using decoder: JSONDecoder = JSONDecoder(),
completion: @Sendable @escaping (Result<T, any Error>) -> Void)
{
request(router) { (result: Result<Data, Error>) in
Expand Down
5 changes: 3 additions & 2 deletions FlagsmithClient/Classes/Internal/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ enum Router: Sendable {
if let body = try body(using: encoder) {
request.httpBody = body
}
request.addValue(apiKey, forHTTPHeaderField: "X-Environment-Key")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "X-Environment-Key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(Flagsmith.userAgent, forHTTPHeaderField: "User-Agent")

return request
}
Expand Down
1 change: 1 addition & 0 deletions FlagsmithClient/Classes/Internal/SSEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ final class SSEManager: NSObject, URLSessionDataDelegate, @unchecked Sendable {
request.setValue("text/event-stream, application/json; charset=utf-8", forHTTPHeaderField: "Accept")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
request.setValue("keep-alive", forHTTPHeaderField: "Connection")
request.setValue(Flagsmith.userAgent, forHTTPHeaderField: "User-Agent")

completionHandler = completion
dataTask = session.dataTask(with: request)
Expand Down
38 changes: 38 additions & 0 deletions FlagsmithClient/Tests/RouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,44 @@ final class RouterTests: FlagsmithClientTestCase {
XCTAssertTrue(request.allHTTPHeaderFields?.contains(where: { $0.key == "X-Environment-Key" }) ?? false)
XCTAssertNil(request.httpBody)
}

func testUserAgentHeader() throws {
let url = try XCTUnwrap(baseUrl)
let route = Router.getFlags
let request = try route.request(baseUrl: url, apiKey: apiKey)

// Verify User-Agent header is present
XCTAssertTrue(request.allHTTPHeaderFields?.contains(where: { $0.key == "User-Agent" }) ?? false)

// Verify User-Agent header format
let userAgent = request.allHTTPHeaderFields?["User-Agent"]
XCTAssertNotNil(userAgent)
XCTAssertTrue(userAgent?.hasPrefix("flagsmith-swift-ios-sdk/") ?? false)

// Verify the format is correct (should end with a semantic version number)
let expectedPattern = "^flagsmith-swift-ios-sdk/[0-9]+\\.[0-9]+\\.[0-9]+$"
let regex = try NSRegularExpression(pattern: expectedPattern)
let range = NSRange(location: 0, length: userAgent?.count ?? 0)
let match = regex.firstMatch(in: userAgent ?? "", options: [], range: range)
let message = "User-Agent should match pattern 'flagsmith-swift-ios-sdk/<version>', got: \(userAgent ?? "nil")"
XCTAssertTrue(match != nil, message)
}

func testUserAgentHeaderFormat() {
// Test that the User-Agent format is correct
let userAgent = Flagsmith.userAgent
XCTAssertTrue(userAgent.hasPrefix("flagsmith-swift-ios-sdk/"))

// Should have a semantic version number (e.g., 3.8.4)
let versionPart = String(userAgent.dropFirst("flagsmith-swift-ios-sdk/".count))
XCTAssertTrue(versionPart.range(of: #"^\d+\.\d+\.\d+$"#, options: NSString.CompareOptions.regularExpression) != nil,
"Version part should be a semantic version number (e.g., 3.8.4), got: \(versionPart)")

// Should be the expected SDK version
// x-release-please-start-version
XCTAssertEqual(versionPart, "3.8.4", "Expected SDK version 3.8.4, got: \(versionPart)")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to update this assertion on every version bump?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(additional context here) I think this can be improved with release please, yes.

You'll need to add the file to the list of additional files here, and then add comments in here to tell release please where to update.

Suggested change
XCTAssertEqual(versionPart, "3.8.4", "Expected SDK version 3.8.4, got: \(versionPart)")
// x-release-please-start-version
XCTAssertEqual(versionPart, "3.8.4", "Expected SDK version 3.8.4, got: \(versionPart)")
// x-release-please-end

// x-release-please-end
}

func testGetIdentityRequest() throws {
let url = try XCTUnwrap(baseUrl)
Expand Down
19 changes: 12 additions & 7 deletions FlagsmithClient/Tests/TestConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,30 @@ import Foundation

struct TestConfig {
/// Real API key for integration testing
/// Set via environment variable FLAGSMITH_TEST_API_KEY or falls back to mock key
/// Set via environment variable FLAGSMITH_TEST_API_KEY or falls back to test-config.json
static let apiKey: String = {
// First priority: environment variable
if let envKey = ProcessInfo.processInfo.environment["FLAGSMITH_TEST_API_KEY"],
!envKey.isEmpty {
return envKey
}

// Check for local test config file (not committed to git)
// Note: Bundle.module is not accessible in Swift 6 from test code
// Fallback to Bundle(for:) approach

// Second priority: test-config.json file
// SPM puts test resources in a separate resource bundle in the same directory as the xctest bundle
let testBundle = Bundle(for: TestConfigObjC.self)
if let path = testBundle.path(forResource: "test-config", ofType: "json"),
let bundleName = "FlagsmithClient_FlagsmithClientTests"
let testBundleURL = URL(fileURLWithPath: testBundle.bundlePath)
let resourceBundleURL = testBundleURL.deletingLastPathComponent().appendingPathComponent("\(bundleName).bundle")

if let resourceBundle = Bundle(url: resourceBundleURL),
let path = resourceBundle.path(forResource: "test-config", ofType: "json"),
let data = FileManager.default.contents(atPath: path),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let key = json["apiKey"] as? String,
!key.isEmpty {
return key
}

// Fallback to mock key for tests that don't need real API
return "mock-test-api-key"
}()
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ let package = Package(
.enableUpcomingFeature("ExistentialAny"), // https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md
]),
.testTarget(
name: "FlagsmitClientTests",
name: "FlagsmithClientTests",
dependencies: ["FlagsmithClient"],
path: "FlagsmithClient/Tests",
exclude: ["README_Testing.md"],
Expand Down
Loading