Project 5: Weather Update with Weather Underground and SendGrid

The Bay Area is experiencing a fair amount of good weather (which is also causing a really severe drought). It’s become so routine that I don’t keep track of the weather. The other day was very windy and overcast, the next with a little rain. Wouldn’t it be nice to get a notification about the weather today?

For this fifth project in my 15 projects in 30 days, I’m going to write a Node.js app that will let me subscribe to a weather notification which will pull the weather for the current day from Weather Underground and email it to me using SendGrid. Both APIs are pretty simple to setup.

Weather Underground

Weather Underground has a couple of useful features we’ll use. First, of course, is an API to pull the weather forecast for a location. But where do you get a valid location to pass to the API? They also have an AutoComplete search endpoint that let’s you search and return results of locations that match the user input.

Sign up for the Weather Underground API and copy down the Key ID for later.

wunderground

You can make up to 500 API calls per day for free, plenty to get started.

SendGrid

SendGrid is one of those secret ninja APIs that you wouldn’t think you really need. Of course there are many ways to send email from a webserver. But the logistics of making sure the emails actually get to your recipient can become an unnecessary distraction.

SendGrid provides a simple API to send emails and offers additional features like analytics to see how many are delivered and bounce. For this project, I’m going to use it strictly to send emails, but you can definitely extend the app to deal with bounced emails.

Sign up for access to the API at sendgrid.com. Keep track of the username and password you sign up with. You’ll need that later. If you forget, you can find the username under the Developers tab.

sendgrid

 

MongoDB

We’ll use MongoDB to store the subscriptions (representing an email address and location where the weather information is desired).

To install MongoDB, run this command:

apt-get install mongodb

To check that it is running, run this command:

service mongodb status

Node.js

To install Node.js:

sudo apt-get update
sudo apt-get install nodejs
sudo apt-get install npm

Some version info:

# nodejs -v
v0.10.25

# npm -v
1.3.10

Set Up

There are six files for this project. server.js and sendweather.js will be the two server side Node.js apps. They use config.json for some shared settings. On the front end is our typical AngularJS app, index.html and weatherupdate.js, located in the public folder. And package.json will be used to install the necessary Node modules.

// Filename: server.js

var express = require('express');
var app = express();
var bodyParser = require('body-parser');
var mongoose = require('mongoose');
var url = require('url');
var config = require('./config');

app.use(bodyParser.json());
app.listen(config.PORT);

mongoose.connect(config.MONGODB_ADDRESS);

var Subscription = mongoose.model(config.MONGODB_MODEL_NAME, {
  email: String,
  city: String,
  location: String
});

app.get('/api/search', function(req, res) {
	var url_parts = url.parse(req.url, true);
	var query = url_parts.query;

	var request = require("request");
	request.get("http://autocomplete.wunderground.com/aq?query="+query.query,function(error, response, body){
		if(error) {
			res.send([]);
			return;
		}

		var results = JSON.parse(response.body);
		var locations = [];

		for(var i=0; i<results.RESULTS.length; i++) {
			locations.push({name:results.RESULTS[i].name, l: results.RESULTS[i].l});
		}

		res.send(locations);
	});
});

app.post('/api/subscribe', function(req, res) {
	Subscription.create({
			email: req.body.email, 
			city: req.body.city, 
			location: req.body.location
		}, function(err, weather) {
			res.send({success:true});
		}
	);
});

app.post('/api/unsubscribe', function(req, res) {
	Subscription.remove({
			email: req.body.email, 
			location: req.body.location
		}, function(err) {
			if(err) {
				res.send({error: 'Could not find subscription'});
				return;
			}

			res.send(JSON.stringify({success:true}));
		}
	);
})

app.use(express.static(__dirname + '/public'));

console.log('App listening on port '+config.PORT);
// sendweather.js
var config = require('./config');

var mongoose = require('mongoose');
mongoose.connect(config.MONGODB_ADDRESS);

var request = require('request');
var sendgrid = require('sendgrid')(config.SENDGRID_USERNAME, config.SENDGRID_PASSWORD);

var Subscription = mongoose.model(config.MONGODB_MODEL_NAME, {
  email: String,
  city: String,
  location: String
});

function getWeather(sub, callback) {
	request.get('http://api.wunderground.com/api/'+config.WUNDERGROUND_API_KEY+'/forecast'+sub.location+'.json',function(error,response,body){
	   if(error) {
	        return;
	   }

	   var weather = JSON.parse(response.body);
	   callback(sub, weather);
	});
}

function sendEmail(email, subject, content)
{
	try {
	    sendgrid.send({
	        to: email,
	        from: config.SENDGRID_FROM,
	        subject: subject,
	        text: content
	    }, function(err, json) {
	        if(err) 
	        	return console.error(err);
	    });
	} catch(e) {
	    console.log(e);
	}
}

Subscription.find({}, function(err, subscriptions) {
	for(var i=0; i<subscriptions.length; i++) {
		getWeather({
			location: subscriptions[i].location, 
			city: subscriptions[i].city, 
			email: subscriptions[i].email
		}, function(sub, weather) {
			var content = weather.forecast.txt_forecast.forecastday[0].title+'\n'+weather.forecast.txt_forecast.forecastday[0].fcttext+'\n\n' +
						  weather.forecast.txt_forecast.forecastday[1].title+'\n'+weather.forecast.txt_forecast.forecastday[1].fcttext+'\n\n';
			sendEmail(sub.email, 'Weather for '+sub.city, content);
		});
	}

});

process.exit();
// Filename: config.json
{
	"SENDGRID_USERNAME": "",
	"SENDGRID_PASSWORD": "",
	"SENDGRID_FROM": "",
	"MONGODB_ADDRESS": "mongodb://127.0.0.1:27017/test",
	"MONGODB_MODEL_NAME": "Weather",
	"WUNDERGROUND_API_KEY": "",
	"PORT": 8080	
}
// Filename: package.json
{
  "name": "weather-update",
  "description": "Weather Update application for Node.js",
  "version": "0.0.1",
  "private": true,
  "dependencies": {
    "express": "*",
    "mongoose": "*",
    "url": "*",
    "body-parser": "*",
    "sendgrid": "*"
  }
}

<!-- Filename: public/index.html -->
<!DOCTYPE html>
<html ng-app="weatherUpdateApp">
  <head>
    <meta charset="utf-8">
    <title>Weather Update</title>
	<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.16/angular.min.js"></script>
  	<script src="weatherupdate.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet">

    <style type="text/css">
      body {
        padding-top: 20px;
        padding-bottom: 40px;
      }

      .container-narrow {
        margin: 0 auto;
        max-width: 700px;
      }

      .container-narrow > hr {
        margin: 30px 0;
      }

      .marketing {
        margin: 60px 0;
      }

      .marketing p + h4 {
        margin-top: 28px;
      }

      .result {
      	padding-top: 5px;
      }
    </style>
  </head>

  <body ng-controller="WeatherUpdateCtrl">

    <div class="container-narrow">

      <div class="masthead">
        <h3 class="muted">Weather Update</h3>
      </div>

      <hr>

      <div class="row-fluid marketing">
      	Get weather updates every morning so you're never unprepared!
        <form name="stepOne">
			<h3>Step 1: Enter your email address</h3>
	    	<input type="email" name="input" ng-model="email" required /> 
	      	<span role="alert">
		      <span class="error" ng-show="stepOne.input.$error.email">Not valid email!</span>
		    </span>
		</form>
		<form name="stepTwo" ng-hide="stepOne.$invalid">
			<h3>Step 2: Search for a city</h3> 
			<input type="text" ng-model="city" required />
			
			<input type="button" class="btn btn-primary" ng-click="searchWeather()" value="Search" ng-disabled="stepTwo.$invalid" />

			<h3 ng-show="results.length > 0">Step 3: Subscribe!</h3>

			<div ng-repeat="result in results" class="result">
				<input type="button" class="btn btn-primary btn-xs" ng-click="subscribe(result)" value="Subscribe" ng-hide="result.subscribed" /><input type="button" class="btn btn-primary btn-xs" ng-click="unsubscribe(result)" value="Unsubscribe" ng-show="result.subscribed" /> {{result.name}} 
			</div>
		</div>
		
		<hr>
	    
	    <div>
	    	Powered by <a href="http://wunderground.com" target="_blank">Weather Underground</a> and <a href="http://sendgrid.com" target="_blank">SendGrid</a>.
	    </div>

      </div>

    </div> <!-- /container -->

  </body>
</html>
// Filename: public/weatherupdate.js

angular.module('weatherUpdateApp', [])
.controller('WeatherUpdateCtrl', function($scope, $http) {
	$scope.email = '';
	$scope.city = '';
	$scope.results = [];

	$scope.searchWeather = function() {
		$http.get('/api/search?query='+$scope.city).success(function(results) {
			$scope.results = results;
		});
	}

	$scope.subscribe = function(location) {
		$http.post('/api/subscribe', {email: $scope.email, city: location.name, location: location.l}).success(function(results) {
			console.log(results);
		});

		location.subscribed = true;
	}

	$scope.unsubscribe = function(location) {
		$http.post('/api/unsubscribe', {email: $scope.email, location: location.l}).success(function(results) {
			console.log(results);
		});

		location.subscribed = false;
	}	
});

To install the required packages, which include mongoose, body-parser, sendgrid, and express, run this command:

npm install

There are a couple of things to change in config.json. The values for SENDGRID_USERNAME and SENDGRID_PASSWORD should be your SendGrid username and password. The value for SENDGRID_FROM should be your email address where the email notifications come from. Lastly, the value WUNDERGROUND_API_KEY should be the API Key for Weather Underground.

And start up the app by running this command:

nodejs server.js

If everything is good, you’ll get the following message:

App listening on port 8080

Weather Update

Weather Update is a pretty simple app on the front-end. It uses AngularJS and Bootstrap.

weatherupdate

Start by entering an email address. The email address field is validated by AngularJS. If an invalid email address is entered, “Not valid email!” is displayed. Pretty neat built in functionality!

weatherupdate2

When a valid email address is entered, step 2 is automatically shown.

weatherupdate3

Step 2 lets the user search for a location. This search box disables the Search button if no location is entered. Again, AngularJS functionality built in.

weatherupdate4

Step 3 fetches locations from Weather Underground and returns a list. When the user finds the right location, they can click on the Subscribe button next to the location. Their email address, city name, and the location identifier is added to MongoDB. The subscribe button changes to Unsubscribe. If clicked, the email address and location is removed from the MongoDB.

weatherupdate5

On the backend, we need to run the other Node.js app to process these subscriptions. Ideally it would process each user’s subscriptions when it is morning for them. For now the app will process all subscriptions and exit. You can extend the functionality to send them whenever you desire. Refer to Project 1 where I used a timer to check at a set interval.

That’s it for this project. There are a number of improvements that could be made:

  • Add user accounts that have users login to manage their subscriptions. Use a service like Parse to store their email address. Refer to Project 3 where I used Parse.
  • An email address can sign up for the same location more than once. Check that there isn’t already a subscription for the email address and location.
  • The only way to unsubscribe from a notification is to click on the Unsubscribe button on the same page when you subscribe to the notification. Add another page to manage the subscriptions. To remove a subscription, make a call to /api/unsubscribe with the email address and location identifier. You’ll probably need to add another endpoint to return the list of subscriptions.
  • If a user subscribes to multiple locations, they receive one email per location. Combine multiple locations into one email address.
  • Add a link in the email to unsubscribe from the notification. It should link to another page in the AngularJS app that should process the unsubscribe request.
  • There is a limit of 500 API calls to Weather Underground. Optimize multiple requests for the same location by caching the result for a location. If the location is cached, you don’t have to make the same request again and use up another call.

Source Code
You can find the repo on GitHub.

Post-mortem

This project added a couple of new things. First, I moved a number of configuration options to config.json. And installing Node.js modules is simplified using the package.json file.

As for AngularJS, form validation was pretty neat. In order to move to Step 2, the email address field must validate. And in Step 2, the search button is disabled if the validation of the location text is empty.

15 Projects in 30 Days Challenge

This blog post is part of my 15 projects in 30 days challenge. I’m hacking together 15 projects with different APIs, services, and technologies that I’ve had little to no exposure to. If my code isn’t completely efficient or accurate, please understand it isn’t meant to be complete and bulletproof. When something is left out, I try to mention it. Reach out to me and kindly teach me if I go towards the dark side. ?

This challenge serves a couple of purposes. First, I’ve always enjoyed hacking new things together and using APIs. And I haven’t had the chance (more like a reason) to dive in head first with things like AngularJS, NodeJS, and MongoDB. This project demonstrated AngularJS, Node.js, MongoDB, Weather Underground, SendGrid, and Bootstrap.

This entry was posted in 15 Projects in 30 days. Bookmark the permalink.