vapor and apns

Sending Apple push notifications from a Vapor web app turns out to be pretty easy using the VaporJWT package.

import Foundation  
import CLibreSSL  
import Vapor  
import VaporJWT

public class APNSService {  
  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 secret: String
  fileprivate var prevToken: String = ""
  fileprivate var prevTokenDate = Date(timeIntervalSince1970: 0)

  fileprivate init() 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.init(validatingUTF8: privateKeyBigNum),
          let privateKeyData = Data(hexString:  "00" + privateKeyHex) else {
      throw LaunchError.setSecretFailed
    }

    self.secret = privateKeyData.base64EncodedString()

    guard !self.token().isEmpty else {
      throw LaunchError.setTokenFailed
    }
  }

  public func sendNotification(to deviceToken: String, alert: String?, badge: Int?, sound: String?, payload: [String: Node]) {
    guard let url = URL(string: "https://\(APNSService.apnsServer)/3/device/\(deviceToken)") else {
      print("invalid url for device token \(deviceToken)")
      return
    }

    var apsDict: [String: Node] = [:]

    if let alert = alert {
      apsDict["alert"] = Node(alert)
    }

    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 {
      print("failed to create json for \(bodyDict)")
      return
    }

    var request = URLRequest(url: url)

    request.httpMethod = "POST"
    request.httpBody = Data(bytes)

    request.addValue("0", forHTTPHeaderField: "apns-expiration")
    request.addValue("10", forHTTPHeaderField: "apns-priority")
    request.addValue(APNSService.topic, forHTTPHeaderField: "apns-topic")

    request.addValue("bearer \(self.token())", forHTTPHeaderField: "Authorization")

    let task = URLSession.shared.dataTask(with: request) { data, response, error in
      if let error = error {
        print("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"
          print("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: [KeyID(APNSService.keyID)],
                             payload: Node([IssuerClaim(APNSService.teamID), IssuedAtClaim()]),
                             encoding: Base64URLEncoding(),
                             signer: ES256(encodedKey: self.secret)),
          let token = try? jwt.createToken() else {
      return prevToken
    }

    APNSService.tokenQueue.sync(flags: .barrier) {
      self.prevToken = token
      self.prevTokenDate = now
    }

    return token
  }
}

fileprivate struct KeyID: Header {  
  static let name = "kid"

  var node: Node

  init(_ keyID: String) {
    node = Node(keyID)
  }
}