6.
Server-Side Pushes
Written by Scott Grosch
While you’ve successfully sent yourself a notification, doing this manually won’t be very useful. As customers run your app and register for receiving notifications, you’ll need to somehow store their device tokens so you can send them notifications at a later date.
Using third-party services
There are a slew of services online that will handle the server side for you. You can simply search Google for something along the lines of “Apple push notification companies” and you’ll find multiple examples. Some of the most popular one are:
- Amazon Simple Notification Service (SNS) (aws.amazon.com/sns/)
- Braze (https://bit.ly/2yM4hx7)
- Firebase Cloud Messaging (http://bit.ly/2Nq4b5x)
- Kumulos (https://bit.ly/2FIQ8Dy)
- OneSignal (https://bit.ly/1Ukk3WL)
- Urban Airship (https://bit.ly/1QymqCY)
Each company will vary in its pricing and API, so discussing any specific service is beyond the scope of this book. If you want to get running quickly or don’t want to deal with anything on the server side, then solutions like the above may be perfect for you.
You may find, however, that you prefer avoiding third-party services, as you can run into issues if the service changes how its API works or if the company goes out of business. These services will usually also charge a fee based on how many notifications you send.
As an iOS developer, you might already be paying for a web hosting service for your website, which gives you the tools you need to do this work yourself — and you can find multiple vendors that charge $10 or less per month. Most web hosting services provide SSH access and the ability to run a database. Since handling the server side only requires a single database table, a couple of REST endpoints and a few easy-to-write pieces of code, you may want to do this work yourself.
If you have no interest in running your own server, you can skip to Chapter 7, “Expanding the Application.”
Setting up Docker
If you’d like to follow along with this chapter but don’t have a server readily available to use, don’t fret! Utilizing Docker, you can run a local SQL server without modifying your system.
If you don’t already have Docker installed, you can go to the Docker for Mac (https://dockr.ly/2JOzJ31) site and follow the installation instructions. Since you’ll be using the Docker CLI tools, you might need to use the docker login command for the initial setup.
Setting up a SQL server
The first step is to set up a SQL server to store the device tokens that your users send to you. PostgreSQL is a great service that is readily available, but any SQL server you wish to use will do.
This book assumes basic database knowledge, so this won’t be covered in this book. You might use the psql command directly or maybe use a tool like Navicat for PostgreSQL.
However, since all of your interactions will be through your app, you technically don’t need to know anything about it!
You’ll need to create a new database user called apns, and then create a new database named apns that’s owned by your newly-created apns user. If you’re not using Docker for this, you’ll want to run commands similar to the following:
$ createuser -P -h yourServer -U postgres -W apns
$ createdb -O apns -h yourServer -U postgres -W apns
You’ll need to replace the -h argument with the name of the host running your SQL server.
If you are using Docker, then the startup command will create the user and database for you. In that case, you’ll want to run this command from Terminal:
$ docker run --name postgres \
-e POSTGRES_DB=apns \
-e POSTGRES_USER=apns \
-e POSTGRES_PASSWORD=password \
-p 5432:5432 \
-d postgres
Those \ characters allow you to run multiline commands in your shell. The Docker command will spin up a process that’s running PostgreSQL and map port 5432 on your local machine to port 5432 in the Docker image, effectively mapping the default PostgreSQL port for you.
Next, you’ll set up your server-side application, which will already handle the creation of the table for you.But, in essence, it’ll run something similar to this SQL command:
CREATE TABLE tokens (
id UUID PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
debug BOOLEAN NOT NULL
);
Setting up Vapor
Now that you have somewhere to store your device tokens, you need something to handle your web connections. For this tutorial, you’ll use Vapor for this purpose. Vapor is a very well supported implementation of server side development using Swift. Without too much code you can use it to control your SQL database as well as your RESTful API. In order to use Vapor, though, there’s a little bit of setup that needs to happen.
If you’re not familiar with Vapor, you can find a list of resources at the end of this chapter.
It’s time to create your server app, which will allow you to store your web tokens!
Open the projects/starter folder, where you’ll find a starter project called PushNotifications which has the base configuration already taken care of for Vapor. It has been modified to use PostgreSQL as the database instead of SQLite and contains empty files for your model and a controller so that Vapor knows about them.
If you don’t have Vapor installed, run the following command in Terminal:
$ brew install vapor/tap/vapor
Note: If you don’t have Homebrew already installed, install it by following the instructions at http://brew.sh.
Vapor uses Apple’s Swift Package Manager to generate the Xcode project. Instead of opening an Xcode project file, you open the Package.swift file with Xcode. After opening it, Xcode will take a moment to fetch all of your project’s dependencies.
Creating the model
Vapor projects include numerous Xcode schemes and, if you’re not using the proper one, the compile/run phases will fail with an overwhelming number of compiler errors! Always make sure that the active scheme is set to Run and My Mac.
The device token you receive from Apple is the model that you’ll store. Edit the Sources/App/Models/Token.swift file and add the following code into it:
import FluentPostgreSQL
import Vapor
final class Token: PostgreSQLUUIDModel {
static let entity = "tokens"
var id: UUID?
let token: String
let debug: Bool
init(token: String, debug: Bool) {
self.token = token
self.debug = debug
}
}
extension Token: Migration {
static func prepare(on connection: PostgreSQLConnection) -> Future<Void> {
return Database.create(self, on: connection) { builder in
try addProperties(to: builder)
builder.unique(on: \.token)
}
}
}
extension Token: Content {}
extension Token: Parameter {}
This is a simple model with three properties. When you’re creating a new token, you’ll obviously not have an ID to specify, which is why that has to be specified as an optional value. Because APNs tokens are unique, you add a unique constraint to that field in your prepare(on:) method.
The Migration code is what Vapor uses in order to properly create the database schema. This simply tells PostgreSQL to create the table if it doesn’t already exist, make a column for each property in the Token class and then ensure that the token column has a UNIQUE constraint assigned to it.
If you take a minute to think about this structure, you’ll realize that you could, in fact, use this same model for all of your iOS apps.
Note: Since you can use the same model for all of your iOS apps, there’s technically no reason to have a separate table for each app. You could, for example, expand the
Tokenclass to include anappIdentifierproperty and then you’d just need one database and one table for everything.
Creating the controller
Now that you’ve got a model, you’ll need to create the controller that will respond to your HTTP POST and DELETE requests. Edit the Sources/App/Controllers/TokenController.swift file to use the model. Navigate to the file and add the following code:
import FluentPostgreSQL
import Vapor
final class TokenController: RouteCollection {
func boot(router: Router) throws {
let routes = router.grouped("api", "token")
routes.post(Token.self, use: storeToken)
routes.delete(String.parameter, use: removeToken)
}
}
Here, you create a controller, which determines what will happen when somebody sends a request to your server. You create two new routes:
-
POST /api/token, which calls thestoreTokenfunction. The client will send the new token as JSON inside the request. -
DELETE /api/token/token_id, which calls theremoveTokenfunction.
Now you need to actually implement these functions! Add the following method to the end of the class, below the boot(router:) method:
func storeToken(_ req: Request, token: Token) throws -> Future<Token> {
return token.save(on: req)
}
When storing a token, Vapor will decode the request’s JSON body as long as it matches your Token object. If it does, then the data is saved to the database and the new JSON representation of the stored token is returned.
Next, add this method to the end of the class to delete a token:
func removeToken(_ req: Request) throws -> Future<HTTPStatus> {
let tokenStr = try req.parameters.next(String.self)
return Token.query(on: req)
.filter(\.token == tokenStr)
.delete()
.transform(to: .ok)
}
When sending a DELETE request, the token is sent as part of the URL’s query string and later used to remove the given token from the database.
Notice how it doesn’t really matter whether or not the token existed in the database during a deletion; you always return a successful response. Since Vapor works asynchronously with futures, you’ll actually return from the method before the database operation has completed. What this means is that the API method could finish before the database has actually deleted your token. For a better understanding of asynchronous database access and futures, please refer to the resources listed at the end of this chapter.
Also notice that, in your removeToken method, you’re actually taking the token value itself and not the ID of the token. The normal case is that you’ll try to send a push notification, get a failure and want to remove the failed token. There’s no reason to force your callers to store the ID of the token itself.
You might be wondering why there are no methods to get a token. If you consider the usage of the API, you need to store and delete tokens, but there’s never a case in which you would want to let someone query your tokens via the API, since your app is the one that has access to the user’s push token.
Note: Are you still thinking about the challenge to support all your apps mentioned during the model creation? The only change you’ll have to make here is accepting a second parameter in the
DELETErequest that identifies theappIdentifier.
Updating the routes
In order to tell the app how to route requests to your new controller, you’ll need to make some changes to the Sources/App/routes.swift file.
You’ll notice that the default template has these two lines commented out:
//let tokenController = TokenController()
//try router.register(collection: tokenController)
Uncomment those now. Vapor 3 adds syntax to allow you to embed all the routing for a controller into the controller itself so that you only need to edit the Sources/App/routes.swift file when you add or remove a controller from your project.
Configuring the app
Because you’re running the server locally, you’ll have to take an extra step to tell Vapor that it should respond to more than just local connections. You only have to do this during development and it’s as simple as telling Vapor what IP address it should use when listening for network connections.
Copying your IP address
Click on the Apple icon in your Mac’s menubar and then choose the System Preferences… option.
Select the Network option and then choose the Advanced… button. Finally, select TCP/IP and you’ll see your IPv4 Address. Copy your IP address and close those preference windows.
Back in your Xcode project, open up Sources/App/configure.swift and find the line that currently says this:
severConfig.hostname = "192.168.1.1"
Replace that IP address with your IP address. Now Vapor knows to accept connections from outside of your mac. Throughout the rest of this book, whenever an example refers to 192.168.1.1 you should replace that with your machine’s IP address.
Note: This is your internal IP address, not what’s visible outside your network. Do not try to use a webpage like www.whatsmyip.org to get this value! The most common way to get this value is using
ifconfigfrom Terminal. You may further filter this command by pipinggrep, like so:ifconfig | grep "inet ".
Running the migrations
There’s just one step left to make everything work. You have to tell Vapor that it should run the migrations for the Token class. While still in configure.swift, find this commented out line at the bottom of the file.
//migrations.add(model: Token.self, database: .psql)
Uncomment that line and run your project. As long as you have a PostgreSQL server running on port 5432 of localhost, your output should be similar to the following:
[ INFO ] Migrating 'psql' database (FluentProvider.swift:28)
[ INFO ] Preparing migration 'Token' (MigrationContainer.swift:50)
[ INFO ] Migrations complete (FluentProvider.swift:32)
Running default command: /Users/scott/Library/Developer/Xcode/DerivedData/dts-fdkxzqveujzmdycmruvnzrydjvwn/Build/Products/Debug/Run serve
Server starting on http://192.168.1.1:8080
If you’re getting error messages related to NIO, that usually means there’s a problem connecting to your database. Some common items you may want to look into if you get errors:
-
Is another webserver running on port 8080? Try
lsof -i :8080. -
Is the Docker instance running? Try
docker ps. -
You might need to use
docker psto find your container, and then stop it withdocker stop <container-id>, and re-run the docker setup command from earlier in this chapter. -
Are the database, user and password all configured correctly?
Testing your API
At this point, you can use any REST-capable app to test out your endpoints. A good choice is Rested, which is available as a free download from the Mac App Store at https://apple.co/2HP0lEH.
To test your POST endpoint, set up the request as follows:
-
URL:
http://192.168.1.1:8080/api/token(Use your IP address). -
METHOD:
POST. - Add a parameter called token and put any value you like.
- Add a parameter called debug and put the value
true - Select JSON-encoded as the request type. This ensures that the data is sent as JSON and that the
Content-Typeheader is set toapplication/json.
Your request will look similar to the following:
Press the Send Request button. You should see in the Response Body section at the lower-right of the image that your token was stored in the database and given a unique identifier.
Sending pushes
As surprising as it is, Apple has not provided any way for a Swift app to natively send a push notification. Sending a push notification uses HTTP/2 now, but the standard Foundation classes don’t support it well. While you could use a URLSession method to send an HTTP/2 packet, there’s no way to tell the session to stay open. What this means is that every push you send creates a new connection to the APNs and Apple will therefore eventually consider you to be attempting a denial-of-service attack.
Note: While the SwiftNIO group has added HTTP/2 support to their package, there are no battle-tested solutions yet available. The next release of this book will be updated to include using Vapor to directly send push notifications.
The current workaround is to use libcurl to send your pushes. Before doing anything though, you’ll need to make sure that the curl command built for your system supports HTTP2. Run it with the -V flag and ensure you see HTTP2 in the output:
$ curl -V
curl 7.48.0 (x86_64-pc-linux-gnu) libcurl/7.48.0 OpenSSL/1.0.2h zlib/1.2.7 libidn/1.28 libssh2/1.4.3 nghttp2/1.11.1
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: IDN IPv6 Largefile NTLM NTLM_WB SSL libz TLS-SRP **HTTP2** UnixSockets
If HTTP2 isn’t there, you can install a newer version with Homebrew. First, if you don’t have Homebrew installed, install it by following the instructions on brew.sh. Then, run the following two commands in Terminal:
$ brew install curl-openssl
$ echo 'export PATH="/usr/local/opt/curl-openssl/bin:$PATH"' >> ~/.zshrc
Once you do that, restart Terminal and run curl -V again. You should now see HTTP2 in the list of features.
On to the script! Create a new file using your favorite editor called sendPushes.php. This isn’t part of your Xcode project so store it wherever you’re keeping your webserver’s source files. You’ll create a small PHP script that will send a HTTP/2 network request to APNs.
Firstly, you’ll need to specify your Auth Key details and what the payload will be.
<?php
const AUTH_KEY_PATH = '/full/path/to/AuthKey_keyid.p8';
const AUTH_KEY_ID = '<your auth key id here>';
const TEAM_ID = '<your team id here>';
const BUNDLE_ID = 'com.raywenderlich.APNS';
$payload = [
'aps' => [
'alert' => [
'title' => 'This is the notification.',
],
'sound'=> 'default',
],
];
Fill in those values based on your specific details. Recall that the AUTH_KEY_ID is the middle part of the filename that you downloaded from Apple, and your TEAM_ID comes from your developer account’s Membership page (https://apple.co/2tXpJ2m). Be sure that you specify a fully qualified path to the Auth Key file!
Next, create a method to get your list of tokens. This will obviously be very app-specific, but as a simple example, you can just get all the registered tokens in the database.
Add the following code below your $payload variable:
$db = new PDO('pgsql:host=localhost;dbname=apns;user=apns;password=password');
function tokensToReceiveNotification($debug)
{
$sql = 'SELECT DISTINCT token FROM tokens WHERE debug = :debug';
$stmt = $GLOBALS['db']->prepare($sql);
$stmt->execute(['debug' => $debug ? 't' : 'f']);
return $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
}
Notice how you’re differentiating between debug and production tokens.
Note: Any app that was installed directly via Xcode is considered a debugging app and must be sent to a different server than apps installed via TestFlight or the App Store. More on this in a moment.
The only tricky part to sending a push notification using the newer HTTP/2 protocol is getting the authentication header right. This is the part that Apple didn’t provide much guidance on when it released its Authentication token implementation.
Append the following code:
function generateAuthenticationHeader()
{
// 1
$header = base64_encode(json_encode([
'alg' => 'ES256',
'kid' => AUTH_KEY_ID
]));
// 2
$claims = base64_encode(json_encode([
'iss' => TEAM_ID,
'iat' => time()
]));
// 3
$pkey = openssl_pkey_get_private('file://' . AUTH_KEY_PATH);
openssl_sign("$header.$claims", $signature, $pkey, 'sha256');
// 4
$signed = base64_encode($signature);
// 5
return "$header.$claims.$signed";
}
The above code takes care of generating the needed JWT authentication header. Let’s break it down:
- You specify that the encryption algorithm (
alg) is using the SHA-256 hash algorithm and that the key identifier (kid) is the 10-character identifier from your p8 file. - Next, you’ll generate the claims payload by specifying the issuer (
iss) using your 10-character Team ID, obtained from your developer account (https://apple.co/2tXpJ2m), along with the issue time (iat), when the JWT was generated, in terms of the number of seconds since the epoch, in UTC. - You read your p8 auth key file and digitally sign the header and claim into
$signature. - You take your digitally signed
$signatureand encode is using Base 64. - Finally, you wrap it up by concatenating all 3 pieces, which you’ll pass down to the
Authenticationheader.
The only signature algorithm that Apple accepts is the ES256 algorithm. Don’t try to sign the payload with any other algorithm or Apple will send a InvalidProviderToken (403) response to your request.
You should generate a new authentication header at the start of every group of pushes that you’ll be sending. Additionally, these generated tokens last for about an hour; any request sent with a token older than an hour will be rejected by Apple with a ExpiredProviderToken (403) error.
You’ll notice that nothing here actually encrypts the header. JWTs are signed and encoded, but they do nothing to provide security for sensitive data.
Now that you know what tokens you need to send and how to sign your request, you’ll open an HTTP/2 session to the APNs. Add the following function to the file:
function sendNotifications($debug) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($GLOBALS['payload']));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apns-topic: ' . BUNDLE_ID,
'authorization: bearer ' . generateAuthenticationHeader(),
'apns-push-type: alert'
]);
}
Notice how you’re explicitly telling libcurl that it should use the HTTP/2 protocol for this connection while passing your JWT as the authorization header. At this point, the session is open and signed, so you just need to loop through each token and send your payload across. Add the following code to the end of the sendNotifications function:
$removeToken = $GLOBALS['db']->prepare('DELETE FROM apns WHERE token = ?');
$server = $debug ? 'api.development' : 'api';
$tokens = tokensToReceiveNotification($debug);
This creates a PDO statement to remove a single token from the database, determines which APNs to connect to and then queries all of the tokens using your previously defined function. You’re almost done, keep going! Add this final piece of PHP code inside your sendNotification function:
foreach ($tokens as $token) {
// 1
$url = "https://$server.push.apple.com/3/device/$token";
curl_setopt($ch, CURLOPT_URL, "{$url}");
// 2
$response = curl_exec($ch);
if ($response === false) {
echo("curl_exec failed: " . curl_error($ch));
continue;
}
// 3
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($code === 400 || $code === 410) {
$json = @json_decode($response);
if ($json->reason === 'BadDeviceToken') {
$removeToken->execute([$token]);
}
}
}
curl_close($ch);
Here’s what’s happening in the above code:
- You construct the actual URL to be used to send a notification to this token.
- You try to submit the request to Apple over the cURL HTTP/2 Session you previously opened.
- If Apple said something was wrong, and the reason was that the token was bad (
BadDeviceToken), you remove this token from your database using the$removeTokenPDO statement you prepared earlier. A token will become invalid if the user uninstalls your app.
Now all that you need to do is call the function! Add this code to the end of the file:
sendNotifications(true); // Development (Sandbox)
sendNotifications(false); // Production
?>
Depending on the way your development cycle works, you’ll need to determine which type of tokens you’re sending your push notifications to. At the start of development, when you’re the only user, you’ll just call sendNotifications(true). Once you have some beta testers, you’ll have to start calling it again with false so they get notifications. There will then be a period of time where both have to go out.
What happens when you finally push your app to the App Store? That’s again dependent on your development flow.
While you continue to develop some other awesome features, you’ll probably continue to send both Sandbox and Production notifications during your development and release cycle.
To run a PHP script, simply prepend the script name with php on the command line, like so:
$ php sendPushes.php
A PHP solution should support most server types. Another option would be using Node.js for your server, in which case you’re not forced to add a PHP solution. There are multiple options on GitHub that you can use. For example, if you install the apn and pg modules using Terminal:
$ npm install apn --save
$ npm install pg --save
Your Node.js server could look a lot like this:
#!/usr/bin/env node
var apn = require('apn');
const { Client } = require('pg')
const options = {
token: {
key: '/full/path/to/AuthKey_keyid.p8',
keyId: '',
teamId: ''
},
production: false
}
const apnProvider = new apn.Provider(options);
var note = new apn.Notification();
note.expiry = Math.floor(Date.now() / 1000) + 3600; // 1 hour
note.badge = 3;
note.sound = "default";
note.alert = "Your alert here";
note.topic = "com.raywenderlich.PushNotifications";
const client = new Client({
user: 'apns',
host: 'localhost',
database: 'apns',
password: 'apns',
port: 5433
})
client.connect()
client.query('SELECT DISTINCT token FROM tokens WHERE debug = true', (err, res) => {
client.end()
const tokens = res.rows.map(row => row.token)
apnProvider.send(note, tokens).then( (response) => {
// response.sent has successful pushes
// response.failed has error details
});
})
But they disabled push!
You’ll notice that you remove tokens from your database when a failure occurs. There’s nothing there to handle the case where your user disables push notifications, nor should there be. Your user can toggle the status of push notifications at any time, and nothing requires them to go into the app to do that, since it’s done from their device’s Settings. Even if push notifications are disabled, it’s still valid for Apple to send the push. The device simply ignores the push when it arrives.
Note: Do not try detecting when pushes are off and removing the token! If the end user goes into Settings and turns them back on, but doesn’t run your app again for a while, they’ll miss all the notifications they are expecting to receive!
Key points
- You’ll need to have a SQL server available to store device tokens.
- You’ll need an API available to your iOS app to store and delete tokens.
- Do not use native Swift network commands to send push notifications until HTTP/2 becomes available, as it will appear to Apple as a denial of service attack due to repetitive opening and closing of connections.
- There are many options available for building your push server. Choose the one(s) that work best for your skillset.
Where to go from here?
As stated, if you are interested in learning more about the Vapor framework, you can check out our great set of videos at https://bit.ly/2JTxX0B as well as our recent book, Server Side Swift with Vapor at https://bit.ly/2FI9wAR.
In the next chapter, “Expanding the Application,” you’ll configure your iOS app to talk to the server that you just configured.