[Feedback] NIOAPNS: NIO-based Apple Push Notification Service

After the discussion thread, we are proposing this as a final revision of this proposal and enter the proposal review phase which will run until the 31st of May 2019.

We have integrated most of the feedback from the discussion thread so even if you have read the previous version, you will find some changes that you hopefully agree with. To highlight a few of the major changes:

The feedback model will be very similar to the one known from Swift Evolution. The community is asked to provide feedback in the way outlined below and after the review period finishes, the SSWG will -- based on the community feedback -- decide whether to promote the proposal to the Sandbox maturity level or not.

What goes into a review of a proposal?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the evolution of the server-side Swift ecosystem.

When reviewing a proposal, here are some questions to consider:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough?
  • Does this proposal fit well with the feel and direction of Swift on Server?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Thank you for contributing to the Swift Server Work Group!

What happens if the proposal gets accepted?

If this proposal gets accepted, a version (likely 0.2.0 ) will be tagged. The development (in form of pull requests) will continue as a regular open-source project.

NIO-based Apple Push Notification Service

Package Description

Apple push notification service implementation built on Swift NIO.

Package name nio-apns
Module Name NIOAPNS
Status Active review (10th...31st May, 2019)
Proposed Maturity Level Sandbox
License Apache 2
Dependencies SwiftNIO 2.x, SwiftNIOSSL 2.x, SwiftNIOHTTP2 1.x

Introduction

NIOAPNS is a module thats gives server side swift applications the ability to use the Apple push notification service.

Motivation

APNS is used to push billions of pushes a day, (7 billion per day in 2012). Many of us using Swift on the backend are using it to power our iOS applications. Having a community supported APNS implementation would go a long way to making it the fastest, free-ist, and simplest solution that exists.

All of the currently maintained libraries either have framework specific dependencies, are not built with NIO, or do not provide enough extensibility while providing "out of the box" capabilities.

Proposed Solution

NIOAPNS provides the essential types for interacting with APNS Server (both production and sandbox).

What it does do

  • Provides an API for handling connection to Apples HTTP2 APNS server.
  • Provides proper error messages that APNS might respond with.
  • Uses custom/non dependency implementations of JSON Web Token specific to APNS (using rfc7519.
  • Imports OpenSSL for SHA256 and ES256.
  • Provides an interface for signing your Push Notifications.
  • Signs your token request.
  • Sends push notifications to a specific device.
  • Adheres to guidelines Apple Provides.

What it won't do.

  • Store/register device tokens.
  • Build an HTTP2 generic client.
  • Google Cloud Message.
  • Refresh your token no more than once every 20 minutes and no less than once every 60 minutes. (up to connection handler)

Future considerations and dependencies

  • BoringSSL
  • swift-log
  • swift-metrics
  • swift-jwt?
  • swift-http2-client?

APNSConfiguration

APNSConfiguration is a structure that provides the system with common configuration.

public struct APNSConfiguration {
    public var keyIdentifier: String
    public var teamIdentifier: String
    public var signer: APNSSigner
    public var topic: String
    public var environment: Environment
    public var tlsConfiguration: TLSConfiguration

    public var url: URL {
        switch environment {
        case .production:
            return URL(string: "https://api.push.apple.com")!
        case .sandbox:
            return URL(string: "https://api.development.push.apple.com")!
        }
    }

Example APNSConfiguration

let signer = ...
let apnsConfig = try APNSConfiguration(keyIdentifier: "9UC9ZLQ8YW",
                                   teamIdentifier: "ABBM6U9RM5",
                                   signer: signer),
                                   topic: "com.grasscove.Fern",
                                   environment: .sandbox)

Signer

APNSSigner provides a structure to sign the payloads with. This should be loaded into memory at the configuration level. It requires the data to be in a ByteBuffer format. We've provided a convenience initializer for users to do this from filePath. This should only be done once, and not on an EventLoop.

let signer = try! APNSSigner(filePath: "/Users/kylebrowning/Downloads/AuthKey_9UC9ZLQ8YW.p8")

APNSConnection

APNSConnection is a class with methods thats provides a wrapper to NIO's ClientBootstrap. The swift-nio-http2 dependency is utilized here. It also provides a function to send a notification to a specific device token string.

Example APNSConnection

let apnsConfig = ...
let apns = try APNSConnection.connect(configuration: apnsConfig, on: group.next()).wait()

Alert

Alert is the actual meta data of the push notification alert someone wishes to send. More details on the specifics of each property are provided here. They follow a 1-1 naming scheme listed in Apple's documentation

Example Alert

let alert = Alert(title: "Hey There", subtitle: "Full moon sighting", body: "There was a full moon last night did you see it")

APSPayload

APSPayload is the meta data of the push notification. Things like the alert, badge count. More details on the specifics of each property are provided here. They follow a 1-1 naming scheme listed in Apple's documentation

Example APSPayload

let alert = ...
let aps = APSPayload(alert: alert, badge: 1, sound: .normal("cow.wav"))

Putting it all together

let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let url = URL(fileURLWithPath: "/Users/kylebrowning/Downloads/AuthKey_9UC9ZLQ8YW.p8")
let data: Data
do {
    data = try Data(contentsOf: url)
} catch {
    throw APNSError.SigningError.certificateFileDoesNotExist
}
var byteBuffer = ByteBufferAllocator().buffer(capacity: data.count)
byteBuffer.writeBytes(data)
let signer = try! APNSSigner.init(buffer: byteBuffer)

let apnsConfig = APNSConfiguration(keyIdentifier: "9UC9ZLQ8YW",
                                       teamIdentifier: "ABBM6U9RM5",
                                       signer: signer,
                                       topic: "com.grasscove.Fern",
                                       environment: .sandbox)

let apns = try APNSConnection.connect(configuration: apnsConfig, on: group.next()).wait()
let alert = Alert(title: "Hey There", subtitle: "Full moon sighting", body: "There was a full moon last night did you see it")
let aps = APSPayload(alert: alert, badge: 1, sound: .normal("cow.wav"))
let notification = BasicNotification(aps: aps)
let res = try apns.send(notification, to: "de1d666223de85db0186f654852cc960551125ee841ca044fdf5ef6a4756a77e").wait()
try apns.close().wait()
try group.syncShutdownGracefully()

Custom Notification Data

Apple provides engineers with the ability to add custom payload data to each notification. In order to facilitate this we have the APNSNotification.

Example

struct AcmeNotification: APNSNotification {
    let acme2: [String]
    let aps: APSPayload

    init(acme2: [String], aps: APSPayload) {
        self.acme2 = acme2
        self.aps = aps
    }
}

let apns: APNSConnection: = ...
let aps: APSPayload = ...
let notification = AcmeNotification(acme2: ["bang", "whiz"], aps: aps)
let res = try apns.send(notification, to: "de1d666223de85db0186f654852cc960551125ee841ca044fdf5ef6a4756a77e").wait()

Maturity

We are requesting a Sandbox maturity level

This package meets the following criteria according to the SSWG Incubation Process:

Alternatives Considered

N/A

5 Likes

I think the overall design and API makes a lot of sense and fits well in NIO server land. I have one specific use case that is currently not supported though (I think): Let's assume you want to build an APNS "relay" micro service for your deployment / organization. This service would establish and maintain the authenticated connection to APNS and relay push requests from other services. This service should and can not know what kind of custom payloads the other services might want to send in their notifications. Without having tested it, I believe the current API would not allow this use case, because you always have to send a typed Notification through the connection. Or is there a way to send an opaque notification through the APNS connection? Or even better: validation of the "metadata" could happen in this potential relay service but the application specific payload could stay opaque to the connection.

Hm, maybe it is possible to define an AnyNotification with something (hacky?) like AnyCodable?

Hrmm, is that not what APNSNotification is?

But how would you encode/decode custom payload fields in APNSNotification that the app does not know about?

Can you define what you mean by App?

APNSNotification is Codable so in the bottom of the proposal we have.

struct AcmeNotification: APNSNotification {
    let acme2: [String]
    let aps: APSPayload

    init(acme2: [String], aps: APSPayload) {
        self.acme2 = acme2
        self.aps = aps
    }
}
/// This is a protocol which allows developers to construct their own Notification payload
public protocol APNSNotification: Codable {
    var aps: APSPayload { get }
}

I guess im failing to understand what your needs are.

Notification is a generic type that will only invoke this method if it conforms to APNSNotification that must be Codable

aps is the only required property by the APNS, but any custom fields you add are up to you and the Server Side App will fully encode the object it gets as long as it is Codable

public func send<Notification>(_ notification: Notification, to deviceToken: String, expiration: Int? = nil, priority: Int? = nil, collapseIdentifier: String? = nil) -> EventLoopFuture<Void>
        where Notification: APNSNotification

I guess what you are saying is you want to be able to make a POST to your micro service with payloads that are not known by the app that is running the NIOAPNS. So how do you decode/encode them into something that conforms to APNSNotification?

Exactly, that is what I mean! :slight_smile:

Great question. Not currently possible. I'll have to think about it. Open to suggestions.

Id prefer to say that's not something this library would be able to do, basically it's out of scope.

It could be something that sits on top of this one. Im not sure how much it would benefit everyone that uses this.

All that being said, if there's an easily manageable solution we can come up with, I don't see why not.

Another use case might be an app that sends JSON payloads from some NoSQL DB that contains arbitrary user generated data, where the app does not necessarily know all the fields. Maybe not the best idea... but I still think that it is quite a severe limitation that you can not send valid but unknown JSON as payload through this client. I’ll think a bit how this could be supported...

This generally looks really good, thanks so much for the pitch!

One note: you have a type called APSPayload: that type really seems like it’s supposed to be called APNSPayload. Is there a reason it isn’t?

Otherwise, I’m +1 on this pitch and would recommend having it move forward.

2 Likes

As discussed before it is called so because that's what Apple calls it.
Nobody knows why… :slight_smile:

See the Documentation

Previous justification: [Discussion] NIOAPNS: NIO-based Apple Push Notification Service - #8 by kylebrowning

1 Like

I think the APSPayload type is under-specified. For instance, a way to specify mutable-content: 1 does not appear to be supported. This is used for iOS rich notification service extensions. The sound key can either be a string or a dictionary; the dictionary is used for critical alerts. The currently proposed API only exposes a String property. (Here's a more modern reference than the one linked in the source comments.)

Further, contentAvailable is a boolean value. I realise the underlying raw value is an integer '1' when encoded, but from an API surface I believe it should be exposed in the APSPayload as Bool. The same would be true for mutableContent — when it is added. I also think there is an argument to name them hasContentAvailable and hasMutableContent, to better align with Swift naming guidelines.

(Aside: Safari Push Notifications require an additional url-args key for the aps dictionary, although I'm not sure if Safari notifications support HTTP/2 or if they still require the older binary interface.)

Along that same line, is it a concern that the structures are so tightly coupled to the current push notifications API, specifically for the APSPayload and Alert types? If Apple adds new notification payload fields in the next version of iOS or macOS, this framework will require changes to keep up on features, or otherwise become obsolete. Apple updates the push notifications API pretty frequently (critical alerts are new in iOS 12, and the mutable content property was added in iOS 10). Maybe it is too tightly coupled?

2 Likes

Thanks for your thoughtful review.

So I'll fix the first points you mention in a PR shortly and post here.

To address the latter part of your comment. I do not believe it to be a concern.

This is an open source library, sponsored by Vapor, and others will have push access. When those things change, we have roughly 6 months from WWDC(June), to September(iOS release) to push an update.

Being tightly coupled allows for less errors, but does requires more effort to maintain longterm. Something Im willing to do and is also the reason pitches require sponsorship, as well as two developers.

Furthermore Im only requesting to be accepted as sandbox level. If we want to graduate to another level, it will require more commiters than just myself.

1 Like

I believe this was answered for you Cory, but if you have any other questions let me know.

A couple of other nitpicks for consideration:

  • Why is SigningMode a struct? Should it not be an enum with three cases? Maybe it should only have two cases, data and custom, as the file case is only a very thin wrapper of straightforward Data(contentsOf:) logic.
  • I'm not sure BasicNotification holds it weight. Almost every notification sent nowadays requires some amount of custom associated data. I think providing the APNSNotification protocol only and asking clients to conform to it is practical and cuts down on API surface area. The BasicNotification is really only useful for a demo snippet.

OKay heres a PR with all the changes @bzamayo.

I elected to keep the File case because it makes development a lot easier for the engineer.

One thing Im not sure on. There is no way to encode just a single property to a certain value. Id have to write the encoding code for every property which seems overkill.

What I've done is made the initializer interface be a boolean, but the stored property is an Int.

Apple also defines the value as a Number in their documentation so Im okay with the underlying property being an Integer.

Yeah, there's no easy way of overriding just a couple of parameters without implementing encode(to:) and losing all automatic synthesis. I think your compromise is fine.

Thanks for working on this. It looks great and I'm definitely going to use it in the future.

1 Like

Cool, merged!

Thanks @kylebrowning, this looks really good. I just have feedback regarding the types left, this is mostly expanding a few points from my previous feedback.

ByteBuffer as the buffer type

This package uses Data as the data type for buffers everywhere. I totally think we should support Data but we should also support ByteBuffer. In the NIO ecosystem, you most likely already have ByteBuffers, now if you use this package, you'd go ByteBuffer -> Data -> ByteBuffer to actually write it to the wire. So we have two unnecessary conversions here.

I think all types should be changed to use ByteBuffer as the main type and have convenience functions that allow the users to use Data too. That would make nothing slower but will make everything that uses ByteBuffer (which is what you get from the network) massively faster.

Top-level types

I think we have too many top-level types here. This is a (hopefully) complete list of the types we have:

NIOAPNS module

  • public struct APNSConfiguration

  • public final class APNSConnection

  • public enum APNSErrors

  • public enum APNSResponseError

why do we need this? This should be part of or nested inside of APNSErrors

  • public enum APNSTokenError

why do we need this? This should be part of or nested inside of APNSErrors

  • public struct APNSError

why do we have this? This should be part of or nested inside of APNSErrors

  • public enum APNSSoundType

Couldn't this be nested in APNSNotification?

  • public protocol APNSNotification

  • public struct APNSSoundDictionary

this one too?

  • public struct APSPayload

  • public struct Alert

This name is way to general, we should nest this into something too.

  • public struct APNSRequestContext

what does this do, does it really need to be a top-level type?

NIOAPNSJWT module

  • public enum APNSSignatureError

Why is this in the NIOAPNSJWT module?

  • public class DataSigner

can we nest this in a enum APNSSigners {}?

  • public class FileSigner

this one too?

  • public struct JWT

  • public protocol APNSSigner

Why is this in the JWT module?

  • public enum SigningMode

I think we should also nest this into something

1 Like

Ive done the namespace changes here.

Will work on ByteBuffer after we shore up the namespace.

1 Like