APNS Server Written in Go

I created a weather app for iOS and incrementally added features to it over the past few years. One of the earlier features that I added was the ability to support push notifications. This required developing a back-end server that my users' devices could register with. Once registered, my app users could select various locations at which to receive push notifications if the temperature was likely to approach freezing in the coming days. This was a feature intended for gardeners who may want to be notified if a frost is possible early in the growing season.

The work of retrieving a location's forecast will be performed on the server. If a temperature near freezing is forecasted for a given location in a set number of days the server will then send a request to Apple Push Notification Services (APNS). APNS is a server (or set of servers) used by Apple that will process an APNS request and forward the notification to the specified devices. This page will explore the steps involved in creating such a server using Go.

Type definitions and working with user data

  • I've defined structs TokenRequest, Location, and LocationAddRequest to unmarshall JSON data sent by user devices.
  • A TokenLocationMap is declared. The ReadRemoteTableContents function will read the contents of a DynamoDB table into this map. The map is used to associate one or more Location objects with a device token.
  • The RegisterToken function is a handler function mapped to the /register endpoint of my server. This endpoint will be contacted when app users consent to receiving push notifications, and stores a token that uniquely identifies a specific device.
  • The HandleLocationAdd and HandleLocationRemove are endpoint functions that are used to associate locations with a device token in the map described above.
  • Both of these functions will call HandleLocationUpdate which contains logic for adding or removing locations for a given key in the table.
  • These endpoint functions will be entered as users toggle the preference to receive frost alerts at specific locations as shown in the image from my app Orange Weather below:


Frost alert menu in Orange Weather

  • I've also declared some helper functions to determine if a location is already present in the table (this is a corner case where client requests may not have made it to my server but very important to avoid duplicate notifications) and determine where in the Location slice associated with a token a specific location exists.
package gonotify

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
)

// Struct to unmarshall JSON object describing a token
type TokenRequest struct {
    Token string `json:"token"`
}

// Struct to represent a geographical Location and associated attributes
type Location struct {
    Latitude  string `json:"latitude"`
    Longitude string `json:"longitude"`
    Name      string `json:"name"`
    Unit      string `json:"unit"`
}

// Struct used to deserialize a payload sent when adding or removing a location
type LocationAddRequest struct {
    Token    string   `json:"token"`
    Location Location `json:"location"`
}

// A map with token keys and location values
var TokenLocationMap map[string][]Location

// A context used to call DynamoDB methods
var ctx = context.Background()

func ReadRemoteTableContents() error {
    result, isEmpty, err := RetrieveTokenLocationMap(ctx)
    if err != nil {
        return fmt.Errorf("error reading remote table contents: %v", err)
    }

    if isEmpty {
        TokenLocationMap = make(map[string][]Location)
        fmt.Println("DynamoDB table was found to be empty.")
    } else {
        TokenLocationMap = result
        fmt.Println("DynamoDB table was read successfully.")
    }

    return nil
}

// Called when the register endpoint is contacted
// Expects to receive POST data describing an iOS device token
func RegisterToken(res http.ResponseWriter, req *http.Request) {
    if req.URL.Path != "/register" {
        http.NotFound(res, req)
        return
    }

    var tokenRequest TokenRequest
    decoder := json.NewDecoder(req.Body)

    if err := decoder.Decode(&tokenRequest); err != nil {
        log.Println("Could not create new token from register request:", err)
        http.Error(res, "Invalid request body", http.StatusBadRequest)
        return
    }

    newToken := tokenRequest.Token

    if _, exists := TokenLocationMap[newToken]; !exists {
        TokenLocationMap[newToken] = []Location{}
        if err := UpdateTokenLocation(ctx, newToken, TokenLocationMap[newToken]); err != nil {
            http.Error(res, "API failed to register token", http.StatusInternalServerError)
            return
        }
        fmt.Println("Added token:", newToken)
    }

    res.WriteHeader(http.StatusCreated)
}

func HandleLocationAdd(res http.ResponseWriter, req *http.Request) {
    handleLocationUpdate(res, req, true)
}

func HandleLocationRemove(res http.ResponseWriter, req *http.Request) {
    handleLocationUpdate(res, req, false)
}

func locationExists(locations []Location, location Location) bool {
    for _, loc := range locations {
        if loc == location {
            return true
        }
    }
    return false
}

func findLocationIndex(locations []Location, location Location) int {
    for i, loc := range locations {
        if loc == location {
            return i
        }
    }
    return -1
}
                        
func handleLocationUpdate(res http.ResponseWriter, req *http.Request, isAdd bool) {
    var requestBody LocationAddRequest
    decoder := json.NewDecoder(req.Body)

    if err := decoder.Decode(&requestBody); err != nil {
        log.Println("Could not parse request:", err)
        http.Error(res, "Invalid request body", http.StatusBadRequest)
        return
    }

    token := requestBody.Token
    location := requestBody.Location

    if locations, exists := TokenLocationMap[token]; !exists {
        if isAdd {
            TokenLocationMap[token] = []Location{location}
            if err := UpdateTokenLocation(ctx, token, TokenLocationMap[token]); err != nil {
                http.Error(res, "API failed to register token", http.StatusInternalServerError)
                return
            }
            fmt.Println("Location added for the token:", token)
        } else {
            fmt.Println("Token not found:", token)
        }
    } else {
        if isAdd {
            if !locationExists(locations, location) {
                TokenLocationMap[token] = append(locations, location)
                if err := UpdateTokenLocation(ctx, token, TokenLocationMap[token]); err != nil {
                    http.Error(res, "API failed to add location", http.StatusInternalServerError)
                    return
                }
                fmt.Println("Location added for the token:", token)
            } else {
                fmt.Println("Location already exists for the token:", token)
            }
        } else {
            if index := findLocationIndex(locations, location); index != -1 {
                TokenLocationMap[token] = append(locations[:index], locations[index+1:]...)
                if err := UpdateTokenLocation(ctx, token, TokenLocationMap[token]); err != nil {
                    http.Error(res, "API failed to remove location", http.StatusInternalServerError)
                    return
                }
                fmt.Println("Location removed for the token:", token)
            } else {
                fmt.Println("Location not found for the token:", token)
            }
        }
    }

    res.WriteHeader(http.StatusCreated)
}
                    

Working with DynamoDB

  • The function UpdateTokenLocationMap creates an attributeValueList to represent a slice of Location types.
  • This is necessary since a Location type contains nested fields such as latitude, longitude, name of the location, and units of temperature to use if an alert is sent.
  • This attributeValueList is the value in the table's key-value relationship scheme. The device token is the key.
  • The RetrieveTokenLocationMap function will be used as shown above when the server first starts up, in order to read the persisted user data from DynamoDB storage into program memory.
package gonotify

import (
    "context"
    "errors"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

const dynamoDBTableName = "DeviceTokensAndLocations"

var svc *dynamodb.Client

func InitializeDynamoDBClient(client *dynamodb.Client) {
    svc = client
}

func UpdateTokenLocation(ctx context.Context, token string, locations []Location) error {
    // Convert locations to DynamoDB AttributeValue
    avList, err := attributeValueList(locations)
    if err != nil {
        return errors.New("error constructing avList: " + err.Error())
    }

    // Prepare input for PutItem operation
    input := &dynamodb.PutItemInput{
        TableName: aws.String(dynamoDBTableName),
        Item: map[string]types.AttributeValue{
            "Token":     &types.AttributeValueMemberS{Value: token},
            "Locations": avList,
        },
    }

    // Perform PutItem operation
    _, err = svc.PutItem(context.Background(), input)
    if err != nil {
        return errors.New("error putting item into DynamoDB: " + err.Error())
    }

    return nil
}      

// Helper function to convert a slice of locations to DynamoDB AttributeValue
func attributeValueList(locations []Location) (types.AttributeValue, error) {
    avList := make([]types.AttributeValue, len(locations))
    for i, loc := range locations {
        avMap := map[string]types.AttributeValue{
            "Latitude":  &types.AttributeValueMemberN{Value: loc.Latitude},
            "Longitude": &types.AttributeValueMemberN{Value: loc.Longitude},
            "Name":      &types.AttributeValueMemberS{Value: loc.Name},
            "Unit":      &types.AttributeValueMemberS{Value: loc.Unit},
        }
        avList[i] = &types.AttributeValueMemberM{Value: avMap}
    }
    return &types.AttributeValueMemberL{Value: avList}, nil
}

func RetrieveTokenLocationMap(ctx context.Context) (map[string][]Location, bool, error) {

    // Prepare input for Scan operation
    input := &dynamodb.ScanInput{
        TableName: aws.String(dynamoDBTableName),
    }

    // Perform Scan operation
    result, err := svc.Scan(context.Background(), input)
    if err != nil {
        return nil, true, errors.New("Error scanning DynamoDB table: " + err.Error())
    }

    // Convert DynamoDB items to map[string][]Location
    tokenLocationMap := make(map[string][]Location)
    for _, item := range result.Items {
        token := item["Token"].(*types.AttributeValueMemberS).Value
        locationsAttribute := item["Locations"].(*types.AttributeValueMemberL).Value

        var locations []Location
        for _, locationAttr := range locationsAttribute {
            lat := locationAttr.(*types.AttributeValueMemberM).Value["Latitude"].(*types.AttributeValueMemberN).Value
            lon := locationAttr.(*types.AttributeValueMemberM).Value["Longitude"].(*types.AttributeValueMemberN).Value
            name := locationAttr.(*types.AttributeValueMemberM).Value["Name"].(*types.AttributeValueMemberS).Value
            unit := locationAttr.(*types.AttributeValueMemberM).Value["Unit"].(*types.AttributeValueMemberS).Value

            locations = append(locations, Location{
                Latitude:  lat,
                Longitude: lon,
                Name:      name,
                Unit:      unit,
            })
        }

        tokenLocationMap[token] = locations
    }

    isEmpty := len(result.Items) == 0

    return tokenLocationMap, isEmpty, nil
}  
                    


Working with weather forecasts

  • I've defined a ForecastResponse struct that will be used to unmarshall the JSON returned by a call to the Open Meteo API.
  • This is a free API that doesn't require a key and offers highly customizable weather data requests.
  • The getForecastAndNotifyfunction requests a forecast from the API, unmarshalls the response, and parses this forecast to determine if the location is forecasted to experience a near-freezing temperature.
  • If such a temperature is present in the forecast, an APNS request will be forwarded to APNS servers for the device token(s) that are associated with that location,
  • The CheckAllLocationsForFrost function iterates over the TokenLocationMap to determine if any location in the map should be notified of the potential for frost. Notice the capitalization, in Go this means this function will be exported, or made available for code outside the package it is declared within. In this case this function will be called from the main package which contains logic for running the server.
  • The image below demonstrates what this notification will look like on a user's device:


Frost alert notifications

package gonotify

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

// Struct describing the contents of a forecast object returned by Open Meteo
type ForecastResponse struct {
    Latitude             float64 `json:"latitude"`
    Longitude            float64 `json:"longitude"`
    GenerationTimeMS     float64 `json:"generationtime_ms"`
    UTCOffsetSeconds     int     `json:"utc_offset_seconds"`
    Timezone             string  `json:"timezone"`
    TimezoneAbbreviation string  `json:"timezone_abbreviation"`
    Elevation            float64 `json:"elevation"`
    HourlyUnits          struct {
        Time          string `json:"time"`
        Temperature2m string `json:"temperature_2m"`
    } `json:"hourly_units"`
    Hourly struct {
        Time          []string  `json:"time"`
        Temperature2m []float64 `json:"temperature_2m"`
    } `json:"hourly"`
}

func getForecastAndNotify(targetDevice string, location Location) {
    url := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%s&longitude=%s&hourly=temperature_2m&forecast_days=3", location.Latitude, location.Longitude)

    response, err := http.Get(url)
    if err != nil {
        fmt.Println("Error contacting forecast API::", err)
        return
    }
    defer response.Body.Close()

    if response.StatusCode == http.StatusOK {
        body, err := io.ReadAll(response.Body)
        if err != nil {
            fmt.Println("Error reading the forecast API response body:", err)
            return
        }

        var forecast ForecastResponse

        err = json.Unmarshal(body, &forecast)
        if err != nil {
            fmt.Println("Error unmarshalling JSON:", err)
            return
        }

        /*
            Originally time was used as part of the push notification, but since
            notification are limited in length this has been removed.
        */
        for i := range forecast.Hourly.Time {
            temp := forecast.Hourly.Temperature2m[i]

            /*
                Notify all devices with a registered location forecasted to experience a temperature
                below 3°C in the next forecast cycle.
            */
            if temp < 3.0 {
                fmt.Printf("Sending frost notification to %s: \n", targetDevice)
                sendPushNotification(targetDevice, location.Name)
                break
            }
        }

        fmt.Printf("Finished analyzing forecast for (%s): \n", location.Name)

    } else {
        fmt.Printf("Failed to retrieve data. Status code: %d\n", response.StatusCode)
    }
}

func CheckAllLocationsForFrost() {
    for token, allLocations := range TokenLocationMap {
        for _, location := range allLocations {
            getForecastAndNotify(token, location)
        }
    }
}                                                      
                        

Sending notifications with APNS

  • I'm using the apns2 package to send the notification requests to Apple's servers.
  • The sendPushNotifications function will create a JSON Web Token containing my APNS Authorization Key, Signing Key, and Developer Account ID.
  • As shown in the image above, push notifications are limited in size and any text that exceeds this limit won't be displayed in the initial notification.
  • For this reason I've sent an alert with succinct content.
package gonotify

import (
	"fmt"
	"log"

	"github.com/sideshow/apns2"
	payload "github.com/sideshow/apns2/payload"
	token "github.com/sideshow/apns2/token"
)

func sendPushNotification(targetToken string, location string) {

	// load signing key from file
	authKey, err := token.AuthKeyFromFile("apnkey.p8")
	if err != nil {
		log.Println("Error sending push notification:", err)
	}

	// Generate JWT used for APNs
	requestToken := &token.Token{
		AuthKey: authKey,
		KeyID:   signingKey,
		TeamID:  teamID,
	}

	// Construct alert information from alert struct
	alertSubtitle := fmt.Sprintf("Frost Alert for %s", location)
	payload := payload.NewPayload().AlertSubtitle(alertSubtitle)

	notification := &apns2.Notification{
		DeviceToken: targetToken,
		Topic:       bundleId,
		Payload:     payload,
	}

	client := apns2.NewTokenClient(requestToken).Production()
	result, err := client.Push(notification)
	if err != nil {
		log.Println("Error Sending Push Notification:", err)
	}
	log.Println("Sent notification with response:", result)
}

                        

Running the server

  • The main function is declared inside the main package and is the entry point to this program.
  • It configures the AWS DynamoDB SDK and initializes a client to interact with the table.
  • I am not sure why, but some attempts to read the table contents into memory fail. For this reason I've added logic to retry a maximum of three times five seconds apart.
  • Handler functions are mapped to server endpoints that will allow user devices to register and add/remove locations.
  • I've used a Goroutine to spawn a thread devoted to checking all registered locations for the possibility of frost every twelve hours.
package main

import (
	"context"
	"log"
	"net/http"
	"time"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/harr1424/Go-Notify/gonotify"
)

var svc *dynamodb.Client

func main() {

	// Load AWS SDK config
	cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(gonotify.Region))
	if err != nil {
		log.Fatal("Error loading SDK config: ", err)
	}

	// Create DynamoDB client and provide it to gonotify package methods
	svc = dynamodb.NewFromConfig(cfg)
	gonotify.InitializeDynamoDBClient(svc)

	// Attempt to read existing data from DynamoDB
	// Limit to three attempts 5 seconds apart
	maxRetries := 3
	retryDelay := 5 * time.Second
	for attempt := 1; attempt <= maxRetries; attempt++ {
		err := gonotify.ReadRemoteTableContents()
		if err == nil {
			break // Success
		}

		log.Printf("Attempt %d: Failed to read remote table contents: %v", attempt, err)

		if attempt < maxRetries {
			log.Printf("Retrying in %v...", retryDelay)
			time.Sleep(retryDelay)
		}

		log.Fatal("Unable to load remote table contents: ", err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/register", gonotify.RegisterToken)
	mux.HandleFunc("/add_location", gonotify.HandleLocationAdd)
	mux.HandleFunc("/remove_location", gonotify.HandleLocationRemove)

	// Every 12 hours check all saved locations for frost
	go func() {
		for {
			gonotify.CheckAllLocationsForFrost()
			time.Sleep(12 * time.Hour)
		}
	}()

	log.Fatal(http.ListenAndServe("0.0.0.0:5050", mux))
	//log.Fatal(http.ListenAndServeTLS(":5050", "localhost.crt", "localhost.key", nil)) // support TLS when available
}

                        

This project taught me a lot about Go. Specifically it was my first webserver written in Go and allowed me to explore working with JSON data, DynamoDB, handler functions, and working with third-party packages. Thanks for reading!



View the project source code on GitHub.

Top Of Page