diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a44c250 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ + + +## Description + + + +## Regression Test Recommendations + + + +## Type of Change + + + +- [ ] โœจ 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 + + diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index e8c0e88..42af25c 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -17,6 +17,41 @@ typealias CompletionHandler = @Sendable (Result) -> 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/ + /// 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 diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index 38622fd..d6e4b13 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -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(_ router: Router, using decoder: JSONDecoder = JSONDecoder(), + func request(_ router: Router, using decoder: JSONDecoder = JSONDecoder(), completion: @Sendable @escaping (Result) -> Void) { request(router) { (result: Result) in diff --git a/FlagsmithClient/Classes/Internal/Router.swift b/FlagsmithClient/Classes/Internal/Router.swift index 3fd7c85..c3f8481 100644 --- a/FlagsmithClient/Classes/Internal/Router.swift +++ b/FlagsmithClient/Classes/Internal/Router.swift @@ -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 } diff --git a/FlagsmithClient/Classes/Internal/SSEManager.swift b/FlagsmithClient/Classes/Internal/SSEManager.swift index b760aad..4fc9fb9 100644 --- a/FlagsmithClient/Classes/Internal/SSEManager.swift +++ b/FlagsmithClient/Classes/Internal/SSEManager.swift @@ -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) diff --git a/FlagsmithClient/Tests/RouterTests.swift b/FlagsmithClient/Tests/RouterTests.swift index 5b34ec5..b8e70fa 100644 --- a/FlagsmithClient/Tests/RouterTests.swift +++ b/FlagsmithClient/Tests/RouterTests.swift @@ -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/', 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)") + // x-release-please-end + } func testGetIdentityRequest() throws { let url = try XCTUnwrap(baseUrl) diff --git a/FlagsmithClient/Tests/TestConfig.swift b/FlagsmithClient/Tests/TestConfig.swift index 73edace..b33943b 100644 --- a/FlagsmithClient/Tests/TestConfig.swift +++ b/FlagsmithClient/Tests/TestConfig.swift @@ -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" }() diff --git a/Package.swift b/Package.swift index 2e4d979..ef3da52 100644 --- a/Package.swift +++ b/Package.swift @@ -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"],