vapor and apns
Sending Apple push notifications from a Vapor web app turns out to be pretty easy using the JWT package.
import CTLS
import Foundation
import HTTP
import JWT
import Vapor
public class APNSService: Service {
public enum LaunchError: Error {
case loadAuthKeyFailed
case setSecretFailed
case setTokenFailed
}
fileprivate static let topic = "<your.app.bundle.id>"
fileprivate static let teamID = "<your team id>"
fileprivate static let keyID = "<your key id>"
#if Xcode
fileprivate static let apnsServer = "api.development.push.apple.com"
#else
fileprivate static let apnsServer = "api.push.apple.com"
#endif
fileprivate static let tokenUpdateInterval = TimeInterval(50 * 60)
fileprivate static let tokenQueue = DispatchQueue(label: "token", attributes: .concurrent)
fileprivate let privateKey: Data
fileprivate var prevToken: String = ""
fileprivate var prevTokenDate = Date(timeIntervalSince1970: 0)
public init(config: Config) throws {
var pKey = EVP_PKEY_new()
let fp = fopen("</path/to/your/.p8 file>", "r")
guard fp != nil else {
throw LaunchError.loadAuthKeyFailed
}
PEM_read_PrivateKey(fp, &pKey, nil, nil)
fclose(fp)
let ecKey = EVP_PKEY_get1_EC_KEY(pKey)
EC_KEY_set_conv_form(ecKey, POINT_CONVERSION_UNCOMPRESSED)
guard let privateKeyBigNum = BN_bn2hex(EC_KEY_get0_private_key(ecKey)),
let privateKeyHex = String(validatingUTF8: privateKeyBigNum),
let privateKeyData = Data(hexString: "00" + privateKeyHex) else {
throw LaunchError.setSecretFailed
}
self.privateKey = privateKeyData
guard !self.token().isEmpty else {
throw LaunchError.setTokenFailed
}
}
public func sendNotification(to deviceToken: String, title: String?, body: String?, badge: Int?, sound: String?, payload: [String: Node]) {
guard let url = URL(string: "https://\(APNSService.apnsServer)/3/device/\(deviceToken)") else {
Logger.logError("invalid url for device token \(deviceToken)")
return
}
var alertDict: [String: Node] = [:]
if let title = title {
alertDict["title"] = Node(title)
}
if let body = body {
alertDict["body"] = Node(body)
}
var apsDict: [String: Node] = [:]
if alertDict.count > 0 {
apsDict["alert"] = Node(alertDict)
}
if let badge = badge {
apsDict["badge"] = Node(badge)
}
if let sound = sound {
apsDict["sound"] = Node(sound)
}
var bodyDict = payload
bodyDict["aps"] = Node(apsDict)
guard let json = try? JSON(node: bodyDict),
let bytes = try? json.makeBytes() else {
Logger.logError("failed to create json for \(bodyDict)")
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = Data(bytes)
request.setValue("0", forHTTPHeaderField: "apns-expiration")
request.setValue("10", forHTTPHeaderField: "apns-priority")
request.setValue(APNSService.topic, forHTTPHeaderField: "apns-topic")
request.setValue("bearer \(self.token())", forHTTPHeaderField: HeaderKey.authorization.key)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
Logger.logError("send push notification failed with error \(error)")
}
if let response = response as? HTTPURLResponse {
if response.statusCode < 200 || response.statusCode > 299 {
let reason = String(data: data ?? Data(), encoding: .utf8) ?? "nil"
Logger.logError("send push notification failed with status code \(response.statusCode), reason \(reason)")
}
}
}
task.resume()
}
fileprivate func token() -> String {
let now = Date()
var prevToken = ""
var prevTokenDate = Date()
APNSService.tokenQueue.sync() {
prevToken = self.prevToken
prevTokenDate = self.prevTokenDate
}
if !prevToken.isEmpty && now.timeIntervalSince(prevTokenDate) < APNSService.tokenUpdateInterval {
return prevToken
}
guard let jwt = try? JWT(additionalHeaders: [KeyIDHeader(identifier: APNSService.keyID)],
payload: JSON([IssuerClaim(string: APNSService.teamID), IssuedAtClaim(date: Date())]),
signer: ES256(key: Bytes(self.privateKey))),
let token = try? jwt.createToken() else {
return prevToken
}
APNSService.tokenQueue.sync(flags: .barrier) {
self.prevToken = token
self.prevTokenDate = now
}
return token
}
}
(Updated for Vapor 2. This is not HTTP/2, too much traffic will get you banned.)