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.
TokenRequest
, Location
, and
LocationAddRequest
to unmarshall JSON data sent by user devices.
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.
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.
HandleLocationAdd
and HandleLocationRemove
are endpoint
functions that are used to associate locations
with a device token in the map described above.
HandleLocationUpdate
which contains logic
for
adding or removing
locations for a given key in the table.
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)
}
UpdateTokenLocationMap
creates an
attributeValueList
to represent a slice of Location
types.
Location
type contains nested fields such as
latitude,
longitude,
name of the location, and units of temperature to use if an alert is sent.
attributeValueList
is the value in the table's key-value relationship
scheme.
The
device token is the key.
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
}
ForecastResponse
struct that will be used to unmarshall the
JSON
returned by a call to the Open Meteo API.
getForecastAndNotify
function 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.
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.
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)
}
}
}
sendPushNotifications
function will create a
JSON Web Token containing my
APNS Authorization Key, Signing Key, and Developer Account ID.
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)
}
main
function is declared inside the main
package and is
the
entry
point to this program.
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!