Chapters

Hide chapters

Push Notifications by Tutorials

Fourth Edition · iOS 16 · Swift 5 · Xcode 14

Section I: Push Notifications by Tutorials

Section 1: 15 chapters
Show chapters Hide chapters

6. Server-Side Pushes
Written by Scott Grosch

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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 that 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 ones are:

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

Note: Some examples in the rest of the book assume you are connecting to the server you’ll set up in this chapter.

Installing Docker

If you don’t already have Docker installed, please go to the Docker for Mac 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.

Generating the Vapor Project

Now it’s time to build your web service. For this tutorial, you’ll implement the web service with Vapor. 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. 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.

$ brew untap vapor/tap/vapor
$ brew install vapor
$ vapor new WebService --fluent.db Postgres --no-leaf
$ cd WebService
$ docker-compose up db
- '32768:5432'

Adding Support for Tokens

In Finder, navigate to your WebService folder and double-click on the Package.swift file.

Defining the model

The device token you receive from Apple is the model that you’ll store. Create the Sources/App/Models/Token.swift file and add the following code into it:

import Fluent
import Vapor

final class Token: Model {
  // 1
  static let schema = "tokens"

  // 2
  @ID(key: .id)
  var id: UUID?

  // 3
  @Field(key: "token")
  var token: String

  @Field(key: "debug")
  var debug: Bool

  // 4
  init() { }

  init(token: String, debug: Bool) {
    self.token = token
    self.debug = debug
  }
}

Configuring the Database Table

Now Xcode knows the structure of your model, but it doesn’t yet exist in the database. Vapor will handle that task for you as well!

import Vapor
import Fluent

struct CreateToken: AsyncMigration {
  func prepare(on database: Database) async throws {
    try await database.schema(Token.schema)
      .id()
      .field("token", .string, .required)
      .field("debug", .bool, .required)
      .unique(on: "token")
      .create()
  }

  func revert(on database: Database) async throws {
    try await database.schema(Token.schema).delete()
  }
}

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. Controllers in Vapor are similar to a UIViewController in Swift. They are what controls the implementation.

Creating Tokens

Create the Sources/App/Controllers/TokenController.swift file and add the following code:

import Fluent
import Vapor

struct TokenController {
  func create(req: Request) async throws -> HTTPStatus {
    // 1
    let token = try req.content.decode(Token.self)
    // 2
    try await token.create(on: req.db)
    // 3
    return .created
  }
}

Deleting Tokens

Now that you have a way to create tokens, you should probably handle the need to delete tokens which are no longer valid. Add this method to your controller:

func delete(req: Request) async throws -> HTTPStatus {
  // 1
  guard let token = req.parameters.get("token") else {
    return .badRequest
  }

  // 2
  guard let row = try await Token.query(on: req.db)
    // 3
    .filter(\.$token == token)
    // 4
    .first()
  else {
    // 5
    return .notFound
  }

  // 6
  try await row.delete(on: req.db)
  // 7
  return .noContent
}

Setting up Routes

In the delete method you just implemented, you’re expecting the caller to pass the token which should be deleted as part of the request. When the calling HTTP client connects to a URL like this:

https://..../token/0549f2c6d0d2887b0f8122b8b1ac45
extension TokenController: RouteCollection {
  func boot(routes: RoutesBuilder) throws {
    let tokens = routes.grouped("token")
    tokens.post(use: create)
    tokens.delete(":token", use: delete)
  }
}
try app.register(collection: TokenController())

Configuring the App

Because you’re running the server locally during debugging, 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.

if app.environment != .production {
  app.http.server.configuration.hostname = "0.0.0.0"
}

Registering 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, replace this line:

app.migrations.add(CreateTodo())
app.migrations.add(CreateToken())
try! app.autoMigrate().wait()

Testing Your API

At this point, you can use any REST-capable app to test out your endpoints. As a developer, you’re likely already familiar with the curl command. Open a Terminal window and enter the following:

curl -i -X POST -H 'Content-Type: application/json' \
  -d '{"debug": true, "token": "xyz"}' localhost:8080/token

Running Your iOS App

Now that your server is operational and has an endpoint to store a token, you can make your iOS app send the token to the server once it registers for push notifications. Chapter 7, “Expanding the Application” already includes a ready-made app that performs this task in the final folder of its materials. With your Vapor application still running, open the PushNotfications iOS app from Chapter 7’s final materials in a separate Xcode window.

{
  "token" : "74bbbd678ccc82021ea59a4681d702d48fbc415ea87f99ac721fdebc30716ff7",
  "debug" : true
}

Sending Pushes

While you’re used to Apple providing libraries for iOS development, server-side Swift is built around community-made packages for specific tasks. Vapor has their own APNs package that makes it easy to send notifications through Apple’s servers.

Send With Vapor

Back in your Vapor project, edit the Package.swift file. Add the following line to the top-most dependencies key to add a new package:

.package(url: "https://github.com/vapor/apns", from: "2.0.0")
.product(name: "APNS", package: "apns")
import APNS
let apnsEnvironment: APNSwiftConfiguration.Environment
apnsEnvironment = app.environment == .production ? .production : .sandbox

let auth: APNSwiftConfiguration.AuthenticationMethod = try .jwt(
  key: .private(filePath: "/full/path/to/AuthKey_...p8"),
  keyIdentifier: "...",
  teamIdentifier: "..."
)

app.apns.configuration = .init(
  authenticationMethod: auth,
  topic: "com.yourcompany.PushNotifications",
  environment: apnsEnvironment)
import APNS
func notify(req: Request) async throws -> HTTPStatus {
  let tokens = try await Token.query(on: req.db).all()

  guard !tokens.isEmpty else {
    return .noContent
  }
}
let alert = APNSwiftAlert(title: "Hello!", body: "How are you today?")
  // 1
  return try await withCheckedThrowingContinuation { continuation in
    do {
      try tokens.map { token in
        // 2
        req.apns.send(alert, to: token.token)
          // 3
          .flatMapError {
            // 4
            guard
              case let APNSwiftError.ResponseError.badRequest(response) = $0,
              response == .badDeviceToken
            else {
              // 5
              return req.db.eventLoop.future()
            }

            // 6
            return token.delete(on: req.db)
          }
      }
      // 7
      .flatten(on: req.eventLoop)
      .wait()
    } catch {
      // 8
      continuation.resume(throwing: error)
    }

    // 9
    continuation.resume(returning: .noContent)
  }
for token in tokens {
  do {
    try await req.apns.send(alert, to: token.token)
  } catch {
    if let APNSwiftError.ResponseError.badRequest(response) = error, response == .badDevice {
      try? await token.delete(on: req.db)
    }
  }
}

return .noContent
tokens.post("notify", use: notify)
curl -X POST localhost:8080/token/notify

Send With curl and PHP

Before it was possible to use Swift, PHP with libcurl was the most common solution used by developers to send a push notification. If you wish to use PHP, 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
$ brew install curl-openssl
$ echo 'export PATH="/usr/local/opt/curl/bin:$PATH"' >> ~/.zshrc
<?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.yourcompany.PushNotifications';

$payload = [
  'aps' => [
    'alert' => [
      'title' => 'Hello!',
      'body' => 'How are you today?',
    ],
    'sound'=> 'default',
  ],
];
$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);
}
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";
}
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'
  ]);
}
$removeToken = $GLOBALS['db']->prepare('DELETE FROM apns WHERE token = ?');
$server = $debug ? 'api.development' : 'api';
$tokens = tokensToReceiveNotification($debug);
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);
sendNotifications(true); // Development (Sandbox)
sendNotifications(false); // Production
?>
$ php sendPushes.php
$ brew install php

What About JavaScript?

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
#!/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 Pushes!

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.

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 Foundation network commands to send push notifications. Apple will consider that as a denial of service attack due to the 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 as well as our Vapor book, Server-Side Swift with Vapor.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now