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.)