From d54977f36188c8e266b2c2d3b4d4a84b4a5a3307 Mon Sep 17 00:00:00 2001 From: Morten Bjerg Gregersen Date: Tue, 1 Oct 2024 14:11:46 +0200 Subject: [PATCH] Make BagbutikService an actor and the request types Sendable --- .../Parameters/EndpointParameter.swift | 2 +- .../Bagbutik-Core/Parameters/Parameters.swift | 2 +- .../Service/BagbutikService.swift | 6 +++- .../Bagbutik-Core/Service/HTTPMethod.swift | 2 +- Sources/Bagbutik-Core/Service/JWT.swift | 28 +++++++++++++------ Sources/Bagbutik-Core/Service/Request.swift | 2 +- .../Bagbutik-Core/Service/RequestBody.swift | 2 +- .../Service/Responses/EmptyResponse.swift | 2 +- .../BagbutikServiceTests.swift | 14 ++++++---- Tests/Bagbutik-CoreTests/JWTTests.swift | 6 ++-- 10 files changed, 43 insertions(+), 23 deletions(-) diff --git a/Sources/Bagbutik-Core/Parameters/EndpointParameter.swift b/Sources/Bagbutik-Core/Parameters/EndpointParameter.swift index 2d95eac48..4b31094c6 100644 --- a/Sources/Bagbutik-Core/Parameters/EndpointParameter.swift +++ b/Sources/Bagbutik-Core/Parameters/EndpointParameter.swift @@ -1,5 +1,5 @@ /// Parameter for an endpoint -public protocol EndpointParameter { +public protocol EndpointParameter: Sendable { /// The name of the case to use as value for the parameter. var caseName: String { get } } diff --git a/Sources/Bagbutik-Core/Parameters/Parameters.swift b/Sources/Bagbutik-Core/Parameters/Parameters.swift index 998904b87..5f87370d8 100644 --- a/Sources/Bagbutik-Core/Parameters/Parameters.swift +++ b/Sources/Bagbutik-Core/Parameters/Parameters.swift @@ -1,7 +1,7 @@ import Foundation /// The parameters for a `Request`. -public struct Parameters { +public struct Parameters: Sendable { /// Fields to return for included related types. public let fields: [FieldParameter]? /// Attributes, relationships, and IDs by which to filter diff --git a/Sources/Bagbutik-Core/Service/BagbutikService.swift b/Sources/Bagbutik-Core/Service/BagbutikService.swift index 930ba1c3f..a83b3f55a 100644 --- a/Sources/Bagbutik-Core/Service/BagbutikService.swift +++ b/Sources/Bagbutik-Core/Service/BagbutikService.swift @@ -29,7 +29,7 @@ public typealias FetchData = (_ request: URLRequest, _ delegate: URLSessionTaskD If the JWT has expired, it will be renewed before the request is performed. */ -public class BagbutikService { +public actor BagbutikService { internal var jwt: JWT private let fetchData: FetchData @@ -165,4 +165,8 @@ public class BagbutikService { } throw ServiceError.unknown(data: data) } + + internal func replaceJWT(_ jwt: JWT) { + self.jwt = jwt + } } diff --git a/Sources/Bagbutik-Core/Service/HTTPMethod.swift b/Sources/Bagbutik-Core/Service/HTTPMethod.swift index 74d94831e..fb97dc351 100644 --- a/Sources/Bagbutik-Core/Service/HTTPMethod.swift +++ b/Sources/Bagbutik-Core/Service/HTTPMethod.swift @@ -6,7 +6,7 @@ import Foundation Documentation borrowed from: */ -public enum HTTPMethod: String { +public enum HTTPMethod: String, Sendable { /// The GET method requests a representation of the specified resource. case get = "GET" /// The PUT method replaces all current representations of the target resource with the request payload. diff --git a/Sources/Bagbutik-Core/Service/JWT.swift b/Sources/Bagbutik-Core/Service/JWT.swift index 924ffc344..fd764cfab 100644 --- a/Sources/Bagbutik-Core/Service/JWT.swift +++ b/Sources/Bagbutik-Core/Service/JWT.swift @@ -13,7 +13,7 @@ import Foundation Full documentation for how JWT is used with the API: */ -public struct JWT { +public struct JWT: Sendable { /// A value telling if the JWT has expired. public var isExpired: Bool { payload.isExpired } /// The signature to use in the authorization header when performing requests. @@ -22,7 +22,7 @@ public struct JWT { private var payload: Payload private let privateKey: String - internal var dateFactory: (TimeInterval) -> Date { + internal var dateFactory: DateFactory { didSet { payload.dateFactory = dateFactory } } @@ -38,7 +38,7 @@ public struct JWT { - privateKey: The contents of your private key from App Store Connect. Starting with `-----BEGIN PRIVATE KEY-----`. */ public init(keyId: String, issuerId: String, privateKey: String) throws { - try self.init(keyId: keyId, issuerId: issuerId, privateKey: privateKey, dateFactory: Date.init(timeIntervalSinceNow:)) + try self.init(keyId: keyId, issuerId: issuerId, privateKey: privateKey, dateFactory: DateFactory()) } /** @@ -57,7 +57,7 @@ public struct JWT { try self.init(keyId: keyId, issuerId: issuerId, privateKey: privateKey) } - init(keyId: String, issuerId: String, privateKey: String, dateFactory: @escaping (TimeInterval) -> Date) throws { + init(keyId: String, issuerId: String, privateKey: String, dateFactory: DateFactory) throws { header = Header(kid: keyId) payload = Payload(iss: issuerId, dateFactory: dateFactory) self.privateKey = privateKey @@ -87,14 +87,14 @@ public struct JWT { let typ = "kid" } - private struct Payload: Encodable { + private struct Payload: Encodable, Sendable { let iss: String private(set) var exp: Int let aud = "appstoreconnect-v1" - var dateFactory: (TimeInterval) -> Date + var dateFactory: DateFactory var isExpired: Bool { Date(timeIntervalSince1970: TimeInterval(exp)) < Date(timeIntervalSinceNow: 0) } - init(iss: String, dateFactory: @escaping (TimeInterval) -> Date) { + init(iss: String, dateFactory: DateFactory) { self.iss = iss self.dateFactory = dateFactory exp = 0 @@ -113,7 +113,19 @@ public struct JWT { } mutating func renewExp() { - exp = Int(dateFactory(20 * 60).timeIntervalSince1970) + exp = Int(dateFactory.createDate(fromTimeIntervalSinceNow: 20 * 60).timeIntervalSince1970) } } } + +internal struct DateFactory: Sendable { + let now: Date? + + init(now: Date? = nil) { + self.now = now + } + + func createDate(fromTimeIntervalSinceNow timeIntervalSinceNow: TimeInterval) -> Date { + (now ?? .now).addingTimeInterval(timeIntervalSinceNow) + } +} diff --git a/Sources/Bagbutik-Core/Service/Request.swift b/Sources/Bagbutik-Core/Service/Request.swift index 6b7d7fc5f..9395be710 100644 --- a/Sources/Bagbutik-Core/Service/Request.swift +++ b/Sources/Bagbutik-Core/Service/Request.swift @@ -7,7 +7,7 @@ import FoundationNetworking private let baseUrl = URL(string: "https://api.appstoreconnect.apple.com")! /// A description of a request. This will internally be mapped to a real URL request. -public struct Request { +public struct Request: Sendable { /// The path of the endpoint. public let path: String /// The HTTP method to use for the request. diff --git a/Sources/Bagbutik-Core/Service/RequestBody.swift b/Sources/Bagbutik-Core/Service/RequestBody.swift index d44391f0a..ad035c4e2 100644 --- a/Sources/Bagbutik-Core/Service/RequestBody.swift +++ b/Sources/Bagbutik-Core/Service/RequestBody.swift @@ -1,7 +1,7 @@ import Foundation /// A protocol used for structs to be sent with `Request`s. -public protocol RequestBody: Encodable { +public protocol RequestBody: Encodable, Sendable { /// A JSON representation of the struct. var jsonData: Data { get } } diff --git a/Sources/Bagbutik-Core/Service/Responses/EmptyResponse.swift b/Sources/Bagbutik-Core/Service/Responses/EmptyResponse.swift index bdd0f0949..73360fee5 100644 --- a/Sources/Bagbutik-Core/Service/Responses/EmptyResponse.swift +++ b/Sources/Bagbutik-Core/Service/Responses/EmptyResponse.swift @@ -1,5 +1,5 @@ /// A response with no properties. -public struct EmptyResponse: Codable { +public struct EmptyResponse: Codable, Sendable { /// Creates a new empty response. public init() {} } diff --git a/Tests/Bagbutik-CoreTests/BagbutikServiceTests.swift b/Tests/Bagbutik-CoreTests/BagbutikServiceTests.swift index 500d17ab6..f1b858208 100644 --- a/Tests/Bagbutik-CoreTests/BagbutikServiceTests.swift +++ b/Tests/Bagbutik-CoreTests/BagbutikServiceTests.swift @@ -18,9 +18,9 @@ final class BagbutikServiceTests: XCTestCase { func setUpService(expiredJWT: Bool = false) throws { mockURLSession = .init() - let dateFactory = expiredJWT ? { _ in Date.distantPast } : Date.init(timeIntervalSinceNow:) + let dateFactory = DateFactory(now: expiredJWT ? Date.distantPast : Date.now) jwt = try JWT(keyId: JWTTests.keyId, issuerId: JWTTests.issuerId, privateKey: JWTTests.privateKey, dateFactory: dateFactory) - let fetchData = mockURLSession.data(for:delegate:) + nonisolated(unsafe) let fetchData = mockURLSession.data(for:delegate:) service = .init(jwt: jwt, fetchData: fetchData) } @@ -149,16 +149,20 @@ final class BagbutikServiceTests: XCTestCase { } } + @MainActor func testJWTRenewal() async throws { try setUpService(expiredJWT: true) - XCTAssertTrue(service.jwt.isExpired) - service.jwt.dateFactory = Date.init(timeIntervalSinceNow:) + let isExpiredBefore = await service.jwt.isExpired + XCTAssertTrue(isExpiredBefore) + jwt.dateFactory = .init() + await service.replaceJWT(jwt) let request: Request = .getAppV1(id: "app-id") let expectedResponse = AppResponse(data: .init(id: "app-id", links: .init(self: "")), links: .init(self: "")) mockURLSession.responsesByUrl[request.asUrlRequest().url!] = try (data: jsonEncoder.encode(expectedResponse), type: .http(statusCode: 200)) _ = try await service.request(request) - XCTAssertFalse(service.jwt.isExpired) + let isExpiredAfter = await service.jwt.isExpired + XCTAssertFalse(isExpiredAfter) } } diff --git a/Tests/Bagbutik-CoreTests/JWTTests.swift b/Tests/Bagbutik-CoreTests/JWTTests.swift index 000c6ea57..ba46fbea2 100644 --- a/Tests/Bagbutik-CoreTests/JWTTests.swift +++ b/Tests/Bagbutik-CoreTests/JWTTests.swift @@ -13,15 +13,15 @@ final class JWTTests: XCTestCase { """ func testInitialEncodedSignature() throws { - let jwt = try JWT(keyId: Self.keyId, issuerId: Self.issuerId, privateKey: Self.privateKey, dateFactory: { _ in Date.distantFuture }) + let jwt = try JWT(keyId: Self.keyId, issuerId: Self.issuerId, privateKey: Self.privateKey, dateFactory: .init(now: Date.distantFuture)) XCTAssertFalse(jwt.isExpired) XCTAssertTrue(jwt.encodedSignature.hasPrefix("eyJ")) } func testInitialEncodedSignature_Renew() throws { - var jwt = try JWT(keyId: Self.keyId, issuerId: Self.issuerId, privateKey: Self.privateKey, dateFactory: { _ in Date.distantPast }) + var jwt = try JWT(keyId: Self.keyId, issuerId: Self.issuerId, privateKey: Self.privateKey, dateFactory: .init(now: Date.distantPast)) XCTAssertTrue(jwt.isExpired) - jwt.dateFactory = Date.init(timeIntervalSinceNow:) + jwt.dateFactory = .init() try jwt.renewEncodedSignature() XCTAssertFalse(jwt.isExpired) }