diff --git a/Package.swift b/Package.swift index 42f3f70..d8168d7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version:5.2 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "FCM", platforms: [ - .macOS(.v10_15) + .macOS(.v13) ], products: [ //Vapor client for Firebase Cloud Messaging @@ -14,7 +14,7 @@ let package = Package( dependencies: [ // 💧 A server-side Swift web framework. .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), - .package(url: "https://github.com/vapor/jwt.git", from: "4.0.0"), + .package(url: "https://github.com/vapor/jwt.git", exact: "5.0.0-rc.1"), ], targets: [ .target(name: "FCM", dependencies: [ diff --git a/Sources/FCM/FCM.swift b/Sources/FCM/FCM.swift index bff52c9..5a47094 100644 --- a/Sources/FCM/FCM.swift +++ b/Sources/FCM/FCM.swift @@ -4,7 +4,7 @@ import JWT // MARK: Engine -public struct FCM { +public struct FCM: Sendable { let application: Application let client: Client @@ -62,18 +62,20 @@ extension FCM { nonmutating set { application.storage[ConfigurationKey.self] = newValue if let newValue = newValue { - warmUpCache(with: newValue.email) + Task { + await warmUpCache(with: newValue.email) + } } } } - private func warmUpCache(with email: String) { + private func warmUpCache(with email: String) async { if gAuth == nil { gAuth = GAuthPayload(iss: email, sub: email, scope: scope, aud: audience) } if jwt == nil { do { - jwt = try generateJWT() + jwt = try await generateJWT() } catch { fatalError("FCM Unable to generate JWT: \(error)") } diff --git a/Sources/FCM/FCMConfiguration.swift b/Sources/FCM/FCMConfiguration.swift index 5ddf063..a55d60c 100644 --- a/Sources/FCM/FCMConfiguration.swift +++ b/Sources/FCM/FCMConfiguration.swift @@ -1,7 +1,7 @@ import Foundation import Vapor -public struct FCMConfiguration { +public struct FCMConfiguration : @unchecked Sendable { let email, projectId, key: String let serverKey, senderId: String? diff --git a/Sources/FCM/FCMError.swift b/Sources/FCM/FCMError.swift index 3601568..2ae1551 100644 --- a/Sources/FCM/FCMError.swift +++ b/Sources/FCM/FCMError.swift @@ -30,7 +30,7 @@ public struct GoogleError: Error, Decodable { public struct FCMError: Error, Decodable { public let errorCode: ErrorCode - public enum ErrorCode: String, Decodable { + public enum ErrorCode: String, Decodable, Sendable { case unspecified = "UNSPECIFIED_ERROR" case invalid = "INVALID_ARGUMENT" case unregistered = "UNREGISTERED" @@ -45,15 +45,20 @@ public struct FCMError: Error, Decodable { extension EventLoopFuture where Value == ClientResponse { func validate() -> EventLoopFuture { return flatMapThrowing { (response) in - guard 200 ..< 300 ~= response.status.code else { - if let error = try? response.content.decode(GoogleError.self) { - throw error - } - let body = response.body.map(String.init) ?? "" - throw Abort(.internalServerError, reason: "FCM: Unexpected error '\(body)'") - } - + try response.validate() return response } } } + +extension ClientResponse { + func validate() throws { + guard 200 ..< 300 ~= self.status.code else { + if let error = try? self.content.decode(GoogleError.self) { + throw error + } + let body = self.body.map(String.init) ?? "" + throw Abort(.internalServerError, reason: "FCM: Unexpected error '\(body)'") + } + } +} diff --git a/Sources/FCM/FCMMessage.swift b/Sources/FCM/FCMMessage.swift index 208cb07..697bc50 100644 --- a/Sources/FCM/FCMMessage.swift +++ b/Sources/FCM/FCMMessage.swift @@ -2,7 +2,7 @@ import Foundation public typealias FCMMessageDefault = FCMMessage -public class FCMMessage: Codable where APNSPayload: FCMApnsPayloadProtocol { +public class FCMMessage: @unchecked Sendable, Codable where APNSPayload: FCMApnsPayloadProtocol { /// Output Only. /// The identifier of the message sent, /// in the format of projects/*/messages/{message_id}. diff --git a/Sources/FCM/GAuthPayload.swift b/Sources/FCM/GAuthPayload.swift index d85eb54..1eec3da 100644 --- a/Sources/FCM/GAuthPayload.swift +++ b/Sources/FCM/GAuthPayload.swift @@ -35,7 +35,7 @@ struct GAuthPayload: JWTPayload { self.aud = aud } - func verify(using signer: JWTSigner) throws { + func verify(using algorithm: some JWTAlgorithm) throws { // not used } diff --git a/Sources/FCM/Helpers/FCM+AccessToken.swift b/Sources/FCM/Helpers/FCM+AccessToken.swift index 18efe8b..e4c5777 100644 --- a/Sources/FCM/Helpers/FCM+AccessToken.swift +++ b/Sources/FCM/Helpers/FCM+AccessToken.swift @@ -2,27 +2,29 @@ import Foundation import Vapor extension FCM { - func getAccessToken() -> EventLoopFuture { + func getAccessToken() async throws -> String { guard let gAuth = gAuth else { fatalError("FCM gAuth can't be nil") } if !gAuth.hasExpired, let token = accessToken { - return client.eventLoop.future(token) + return token } - - return client.post(URI(string: audience)) { (req) in + + let jwt = try await self.getJWT() + + let response = try await client.post(URI(string: audience)) { (req) in try req.content.encode([ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", - "assertion": try self.getJWT(), + "assertion": jwt, ]) } - .validate() - .flatMapThrowing { res -> String in - struct Result: Codable { - let access_token: String - } - - return try res.content.decode(Result.self, using: JSONDecoder()).access_token + + try response.validate() + + struct Result: Codable { + let access_token: String } + + return try response.content.decode(Result.self, using: JSONDecoder()).access_token } } diff --git a/Sources/FCM/Helpers/FCM+BatchSend.swift b/Sources/FCM/Helpers/FCM+BatchSend.swift index b722cab..119b5c5 100644 --- a/Sources/FCM/Helpers/FCM+BatchSend.swift +++ b/Sources/FCM/Helpers/FCM+BatchSend.swift @@ -2,45 +2,28 @@ import Foundation import Vapor extension FCM { - @available(*, deprecated, message: "🧨 Requests to the endpoint will start failing after 6/21/2024. Migrate to the standard send method.") - public func batchSend(_ message: FCMMessageDefault, tokens: String...) -> EventLoopFuture<[String]> { - _send(message, tokens: tokens) - } - - @available(*, deprecated, message: "🧨 Requests to the endpoint will start failing after 6/21/2024. Migrate to the standard send method.") - public func batchSend(_ message: FCMMessageDefault, tokens: String..., on eventLoop: EventLoop) -> EventLoopFuture<[String]> { - _send(message, tokens: tokens).hop(to: eventLoop) - } - - @available(*, deprecated, message: "🧨 Requests to the endpoint will start failing after 6/21/2024. Migrate to the standard send method.") - public func batchSend(_ message: FCMMessageDefault, tokens: [String]) -> EventLoopFuture<[String]> { - _send(message, tokens: tokens) - } - - @available(*, deprecated, message: "🧨 Requests to the endpoint will start failing after 6/21/2024. Migrate to the standard send method.") - public func batchSend(_ message: FCMMessageDefault, tokens: [String], on eventLoop: EventLoop) -> EventLoopFuture<[String]> { - _send(message, tokens: tokens).hop(to: eventLoop) - } - private func _send(_ message: FCMMessageDefault, tokens: [String]) -> EventLoopFuture<[String]> { + private func _send(_ message: FCMMessageDefault, tokens: [String]) async throws -> [String] { guard let configuration = self.configuration else { fatalError("FCM not configured. Use app.fcm.configuration = ...") } let urlPath = URI(string: actionsBaseURL + configuration.projectId + "/messages:send").path - - return getAccessToken().flatMap { accessToken in - tokens.chunked(into: 500).map { chunk in - self._sendChunk( - message, - tokens: chunk, - urlPath: urlPath, - accessToken: accessToken - ) - } - .flatten(on: self.client.eventLoop) - .map { $0.flatMap { $0 } } + + let accessToken = try await getAccessToken() + + var result = [String]() + for chunk in tokens.chunked(into: 500) { + let partial = try await self._sendChunk( + message, + tokens: chunk, + urlPath: urlPath, + accessToken: accessToken + ) + result.append(contentsOf: partial) } + + return result } private func _sendChunk( @@ -48,7 +31,7 @@ extension FCM { tokens: [String], urlPath: String, accessToken: String - ) -> EventLoopFuture<[String]> { + ) async throws -> [String] { var body = ByteBufferAllocator().buffer(capacity: 0) let boundary = "subrequest_boundary" @@ -56,81 +39,77 @@ extension FCM { let message: FCMMessageDefault } - do { - let parts: [MultipartPart] = try tokens.map { token in - var partBody = ByteBufferAllocator().buffer(capacity: 0) + let parts: [MultipartPart] = try tokens.map { token in + var partBody = ByteBufferAllocator().buffer(capacity: 0) - partBody.writeString(""" - POST \(urlPath)\r - Content-Type: application/json\r - accept: application/json\r - \r + partBody.writeString(""" + POST \(urlPath)\r + Content-Type: application/json\r + accept: application/json\r + \r - """) + """) - let message = FCMMessageDefault( - token: token, - notification: message.notification, - data: message.data, - name: message.name, - android: message.android ?? androidDefaultConfig, - webpush: message.webpush ?? webpushDefaultConfig, - apns: message.apns ?? apnsDefaultConfig - ) + let message = FCMMessageDefault( + token: token, + notification: message.notification, + data: message.data, + name: message.name, + android: message.android ?? androidDefaultConfig, + webpush: message.webpush ?? webpushDefaultConfig, + apns: message.apns ?? apnsDefaultConfig + ) - try partBody.writeJSONEncodable(Payload(message: message)) + try partBody.writeJSONEncodable(Payload(message: message)) - return MultipartPart(headers: ["Content-Type": "application/http"], body: partBody) - } - - try MultipartSerializer().serialize(parts: parts, boundary: boundary, into: &body) - } catch { - return client.eventLoop.makeFailedFuture(error) + return MultipartPart(headers: ["Content-Type": "application/http"], body: partBody) } + try MultipartSerializer().serialize(parts: parts, boundary: boundary, into: &body) + var headers = HTTPHeaders() headers.contentType = .init(type: "multipart", subType: "mixed", parameters: ["boundary": boundary]) headers.bearerAuthorization = .init(token: accessToken) - return self.client + let response = try await self.client .post(URI(string: batchURL), headers: headers) { req in req.body = body } - .validate() - .flatMapThrowing { (res: ClientResponse) in - guard - let boundary = res.headers.contentType?.parameters["boundary"] - else { - throw Abort(.internalServerError, reason: "FCM: Missing \"boundary\" in batch response headers") - } - guard - let body = res.body - else { - throw Abort(.internalServerError, reason: "FCM: Missing response body from batch operation") - } + + try response.validate() - struct Result: Decodable { - let name: String - } + guard + let boundary = response.headers.contentType?.parameters["boundary"] + else { + throw Abort(.internalServerError, reason: "FCM: Missing \"boundary\" in batch response headers") + } + guard + let body = response.body + else { + throw Abort(.internalServerError, reason: "FCM: Missing response body from batch operation") + } - let jsonDecoder = JSONDecoder() - var result: [String] = [] - - let parser = MultipartParser(boundary: boundary) - parser.onBody = { body in - let bytes = body.readableBytesView - if let indexOfBodyStart = bytes.firstIndex(of: 0x7B) /* '{' */ { - body.moveReaderIndex(to: indexOfBodyStart) - if let name = try? jsonDecoder.decode(Result.self, from: body).name { - result.append(name) - } - } - } + struct Result: Decodable { + let name: String + } - try parser.execute(body) + let jsonDecoder = JSONDecoder() + var result: [String] = [] - return result + let parser = MultipartParser(boundary: boundary) + parser.onBody = { body in + let bytes = body.readableBytesView + if let indexOfBodyStart = bytes.firstIndex(of: 0x7B) /* '{' */ { + body.moveReaderIndex(to: indexOfBodyStart) + if let name = try? jsonDecoder.decode(Result.self, from: body).name { + result.append(name) + } } + } + + try parser.execute(body) + + return result } } diff --git a/Sources/FCM/Helpers/FCM+CreateTopic.swift b/Sources/FCM/Helpers/FCM+CreateTopic.swift index 3346c9c..f2ec2ca 100644 --- a/Sources/FCM/Helpers/FCM+CreateTopic.swift +++ b/Sources/FCM/Helpers/FCM+CreateTopic.swift @@ -2,23 +2,23 @@ import Foundation import Vapor extension FCM { - public func createTopic(_ name: String? = nil, tokens: String...) -> EventLoopFuture { - createTopic(name, tokens: tokens) - } +// public func createTopic(_ name: String? = nil, tokens: String...) async throws -> String { +// try await createTopic(name, tokens: tokens) +// } +// +// public func createTopic(_ name: String? = nil, tokens: String..., on eventLoop: EventLoop) async throws -> String { +// try await createTopic(name, tokens: tokens).hop(to: eventLoop) +// } - public func createTopic(_ name: String? = nil, tokens: String..., on eventLoop: EventLoop) -> EventLoopFuture { - createTopic(name, tokens: tokens).hop(to: eventLoop) + public func createTopic(_ name: String? = nil, tokens: [String]) async throws -> String { + try await _createTopic(name, tokens: tokens) } - public func createTopic(_ name: String? = nil, tokens: [String]) -> EventLoopFuture { - _createTopic(name, tokens: tokens) - } +// public func createTopic(_ name: String? = nil, tokens: [String], on eventLoop: EventLoop) async throws -> String { +// try await _createTopic(name, tokens: tokens).hop(to: eventLoop) +// } - public func createTopic(_ name: String? = nil, tokens: [String], on eventLoop: EventLoop) -> EventLoopFuture { - _createTopic(name, tokens: tokens).hop(to: eventLoop) - } - - private func _createTopic(_ name: String? = nil, tokens: [String]) -> EventLoopFuture { + private func _createTopic(_ name: String? = nil, tokens: [String]) async throws -> String { guard let configuration = self.configuration else { fatalError("FCM not configured. Use app.fcm.configuration = ...") } @@ -27,27 +27,33 @@ extension FCM { } let url = self.iidURL + "batchAdd" let name = name ?? UUID().uuidString - return getAccessToken().flatMap { accessToken -> EventLoopFuture in - var headers = HTTPHeaders() - headers.add(name: .authorization, value: "key=\(serverKey)") + + let _ = try await getAccessToken() + var headers = HTTPHeaders() + headers.add(name: .authorization, value: "key=\(serverKey)") - return self.client.post(URI(string: url), headers: headers) { (req) in - struct Payload: Content { - let to: String - let registration_tokens: [String] - - init(to: String, registration_tokens: [String]) { - self.to = "/topics/\(to)" - self.registration_tokens = registration_tokens - } + let response = try await self.client.post(URI(string: url), headers: headers) { (req) in + struct Payload: Content { + let to: String + let registration_tokens: [String] + + init(to: String, registration_tokens: [String]) { + self.to = "/topics/\(to)" + self.registration_tokens = registration_tokens } - let payload = Payload(to: name, registration_tokens: tokens) - try req.content.encode(payload) } + let payload = Payload(to: name, registration_tokens: tokens) + try req.content.encode(payload) } - .validate() - .map { _ in - return name + + guard 200 ..< 300 ~= response.status.code else { + if let error = try? response.content.decode(GoogleError.self) { + throw error + } + let body = response.body.map(String.init) ?? "" + throw Abort(.internalServerError, reason: "FCM: Unexpected error '\(body)'") } + + return name } } diff --git a/Sources/FCM/Helpers/FCM+DeleteTopic.swift b/Sources/FCM/Helpers/FCM+DeleteTopic.swift index af49c6d..6f625ec 100644 --- a/Sources/FCM/Helpers/FCM+DeleteTopic.swift +++ b/Sources/FCM/Helpers/FCM+DeleteTopic.swift @@ -2,49 +2,50 @@ import Foundation import Vapor extension FCM { - public func deleteTopic(_ name: String, tokens: String...) -> EventLoopFuture { - deleteTopic(name, tokens: tokens) + public func deleteTopic(_ name: String, tokens: String...) async throws { + try await deleteTopic(name, tokens: tokens) } - public func deleteTopic(_ name: String, tokens: String..., on eventLoop: EventLoop) -> EventLoopFuture { - deleteTopic(name, tokens: tokens).hop(to: eventLoop) - } +// public func deleteTopic(_ name: String, tokens: String..., on eventLoop: EventLoop) -> EventLoopFuture { +// deleteTopic(name, tokens: tokens).hop(to: eventLoop) +// } - public func deleteTopic(_ name: String, tokens: [String]) -> EventLoopFuture { - _deleteTopic(name, tokens: tokens) + public func deleteTopic(_ name: String, tokens: [String]) async throws { + try await _deleteTopic(name, tokens: tokens) } - public func deleteTopic(_ name: String, tokens: [String], on eventLoop: EventLoop) -> EventLoopFuture { - _deleteTopic(name, tokens: tokens).hop(to: eventLoop) - } +// public func deleteTopic(_ name: String, tokens: [String], on eventLoop: EventLoop) -> EventLoopFuture { +// _deleteTopic(name, tokens: tokens).hop(to: eventLoop) +// } - private func _deleteTopic(_ name: String, tokens: [String]) -> EventLoopFuture { + private func _deleteTopic(_ name: String, tokens: [String]) async throws { guard let configuration = self.configuration else { fatalError("FCM not configured. Use app.fcm.configuration = ...") } guard let serverKey = configuration.serverKey else { fatalError("FCM: DeleteTopic: Server Key is missing.") } + + let _ = try await getAccessToken() + var headers = HTTPHeaders() + headers.add(name: .authorization, value: "key=\(serverKey)") + let url = self.iidURL + "batchRemove" - return getAccessToken().flatMap { accessToken -> EventLoopFuture in - var headers = HTTPHeaders() - headers.add(name: .authorization, value: "key=\(serverKey)") - - return self.client.post(URI(string: url), headers: headers) { (req) in - struct Payload: Content { - let to: String - let registration_tokens: [String] - - init(to: String, registration_tokens: [String]) { - self.to = "/topics/\(to)" - self.registration_tokens = registration_tokens - } + + let response = try await self.client.post(URI(string: url), headers: headers) { (req) in + struct Payload: Content { + let to: String + let registration_tokens: [String] + + init(to: String, registration_tokens: [String]) { + self.to = "/topics/\(to)" + self.registration_tokens = registration_tokens } - let payload = Payload(to: name, registration_tokens: tokens) - try req.content.encode(payload) } + let payload = Payload(to: name, registration_tokens: tokens) + try req.content.encode(payload) } - .validate() - .map { _ in () } + + try response.validate() } } diff --git a/Sources/FCM/Helpers/FCM+GetTopics.swift b/Sources/FCM/Helpers/FCM+GetTopics.swift index a6d5380..7572941 100644 --- a/Sources/FCM/Helpers/FCM+GetTopics.swift +++ b/Sources/FCM/Helpers/FCM+GetTopics.swift @@ -2,7 +2,7 @@ import Foundation import Vapor extension FCM { - public func getTopics(token: String, on eventLoop: EventLoop) -> EventLoopFuture<[String]> { + public func getTopics(token: String, on eventLoop: EventLoop) async throws -> [String] { guard let configuration = self.configuration else { fatalError("FCM not configured. Use app.fcm.configuration = ...") } @@ -10,27 +10,25 @@ extension FCM { fatalError("FCM: GetTopics: Server Key is missing.") } let url = self.iidURL + "info/\(token)?details=true" - return getAccessToken().flatMap { accessToken -> EventLoopFuture in - var headers = HTTPHeaders() - headers.add(name: .authorization, value: "key=\(serverKey)") + + let _ = try await getAccessToken() + var headers = HTTPHeaders() + headers.add(name: .authorization, value: "key=\(serverKey)") - return self.client.get(URI(string: url), headers: headers) - } - .validate() - .flatMapThrowing { response in - struct Result: Codable { - let rel: Relations + let response = try await self.client.get(URI(string: url), headers: headers) + + struct Result: Codable { + let rel: Relations - struct Relations: Codable { - let topics: [String: TopicMetadata] - } + struct Relations: Codable { + let topics: [String: TopicMetadata] + } - struct TopicMetadata: Codable { - let addDate: String - } + struct TopicMetadata: Codable { + let addDate: String } - let result = try response.content.decode(Result.self, using: JSONDecoder()) - return Array(result.rel.topics.keys) } + let result = try response.content.decode(Result.self, using: JSONDecoder()) + return Array(result.rel.topics.keys) } } diff --git a/Sources/FCM/Helpers/FCM+JWT.swift b/Sources/FCM/Helpers/FCM+JWT.swift index f7fe2d3..781f49d 100644 --- a/Sources/FCM/Helpers/FCM+JWT.swift +++ b/Sources/FCM/Helpers/FCM+JWT.swift @@ -2,7 +2,7 @@ import Foundation import JWT extension FCM { - func generateJWT() throws -> String { + func generateJWT() async throws -> String { guard let configuration = self.configuration else { fatalError("FCM not configured. Use app.fcm.configuration = ...") } @@ -14,17 +14,17 @@ extension FCM { } gAuth = gAuth.updated() self.gAuth = gAuth - let pk = try RSAKey.private(pem: pemData) - let signer = JWTSigner.rs256(key: pk) - return try signer.sign(gAuth) + let pk = try Insecure.RSA.PrivateKey(pem: pemData) + let keys = await JWTKeyCollection().add(rsa: pk, digestAlgorithm: .sha256) + return try await keys.sign(gAuth) } - func getJWT() throws -> String { + func getJWT() async throws -> String { guard let gAuth = gAuth else { fatalError("FCM gAuth can't be nil") } if !gAuth.hasExpired, let jwt = jwt { return jwt } - let jwt = try generateJWT() + let jwt = try await generateJWT() self.jwt = jwt return jwt } diff --git a/Sources/FCM/Helpers/FCM+SendMessage.swift b/Sources/FCM/Helpers/FCM+SendMessage.swift index e4106be..2f95050 100644 --- a/Sources/FCM/Helpers/FCM+SendMessage.swift +++ b/Sources/FCM/Helpers/FCM+SendMessage.swift @@ -2,15 +2,11 @@ import Foundation import Vapor extension FCM { - public func send(_ message: FCMMessageDefault) -> EventLoopFuture { - _send(message) + public func send(_ message: FCMMessageDefault) async throws -> String { + try await _send(message) } - public func send(_ message: FCMMessageDefault, on eventLoop: EventLoop) -> EventLoopFuture { - _send(message).hop(to: eventLoop) - } - - private func _send(_ message: FCMMessageDefault) -> EventLoopFuture { + private func _send(_ message: FCMMessageDefault) async throws -> String { guard let configuration = self.configuration else { fatalError("FCM not configured. Use app.fcm.configuration = ...") } @@ -28,25 +24,25 @@ extension FCM { } let url = actionsBaseURL + configuration.projectId + "/messages:send" - return getAccessToken().flatMap { accessToken -> EventLoopFuture in - var headers = HTTPHeaders() - headers.bearerAuthorization = .init(token: accessToken) - - return self.client.post(URI(string: url), headers: headers) { (req) in - struct Payload: Content { - let message: FCMMessageDefault - } - let payload = Payload(message: message) - try req.content.encode(payload) + + let accessToken = try await getAccessToken() + var headers = HTTPHeaders() + headers.bearerAuthorization = .init(token: accessToken) + + let response = try await self.client.post(URI(string: url), headers: headers) { (req) in + struct Payload: Content { + let message: FCMMessageDefault } + let payload = Payload(message: message) + try req.content.encode(payload) } - .validate() - .flatMapThrowing { res in - struct Result: Decodable { - let name: String - } - let result = try res.content.decode(Result.self) - return result.name + + try response.validate() + + struct Result: Decodable { + let name: String } + let result = try response.content.decode(Result.self) + return result.name } } diff --git a/Tests/FCMTests/FCMTests.swift b/Tests/FCMTests/FCMTests.swift index 1994dc8..e4fd554 100644 --- a/Tests/FCMTests/FCMTests.swift +++ b/Tests/FCMTests/FCMTests.swift @@ -5,9 +5,4 @@ final class FCMTests: XCTestCase { func testExample() { XCTAssertFalse(false) } - - - static var allTests = [ - ("testExample", testExample), - ] }