Introduction
In this article, we'll build an ASP.Net MVC 5 web application which will search your Yammer feed for a particular hashtag, and and then use Yammer's graph search to fetch the information about the poster's home town. We'll make a ASP.Net WebAPI endpoint to serve our information, and use JQuery and Bing Maps to display that information to our end users.
Background
Internally, my company uses Yammer as its social network. When I went to a company-sponsored tech conference last summer, they suggested that we use a particular hashtag for all of our posts. Since we had people literally flying in from around the world to attend the conference, I thought it would be cool to build an app which showed the posts which included the suggested hashtag as a highlighted where everyone was from.
This article is rather long, so I've separated it into two parts:
- Prerequisites
- Code
Before we dive into the code, we need to get the following prerequisites out of the way
- Hosting
- Database
- Maps API Key
- Yammer API Key
The first thing we're going to need to do is set up some hosting that supplies HTTPS - Microsoft Azure websites are currently offering 10 sites and SSL on their *.azurewebsites.net domain for free, so I'm going with that. You don't have to use Azure, you just need your host to support HTTPS. Everything in this article past this point will not work if you don't have SSL/HTTPS up and running.
If you use Azure, go to https://manage.windowsazure.com, log in with your microsoft account, and navigate to the add website wizard. You can use the Quick Create option here, and they make it pretty easy. The important thing is to reserve a URL that we can reference later.
Upon authenticating users, we'll want a record that they visited - having engagement metrics helps ensure that as we add features and further develop this app in the future, we're going in the right direction. Since I've already got hosting set up in Azure websites, I'm going to utilize SQL Azure for my database. Doing so makes it really easy to integrate with Entity Framework Code First (which we'll be using later). Since we'll only be using the database to record who used the app and nothing else, this step is optional. Doing this in Azure is pretty easy. Just use the quick create for this step.
NOTE: When using SQL Azure as your database host, you'll need to make sure that the firewall rules are set up so that you can connect to the database from your current location. After the wizard completes its initialization, take a minute to click the "Manage" button at the bottom to launch the silverlight manager at the bottom of the page - this will open a prompt which will get the firewall rules set up for you.
The next thing we're going to need to do is get set up with a Bing Maps key. If you take a look throught the documentation for the Bing Maps AJAX control, you'll see that in order to use the site maps widget, you need an API key.
Start off by going to https://www.bingmapsportal.com and signing in with your Microsoft account. Once there, click on the link to create a key, and then select "Basic" for the key type and "Public Website" for the application type. If you choose "Trial" for the key type, then the key that you generate will only be good for 90 days.
NOTE: In box for the application URL, we're using the HTTPS version of the URL we reserved on our hosting
After creating the key, click on the "Create or view keys" link, then scroll down to the bottom.
You can see in my example, I started by creating a trial key, and then later created a basic key which has no expiration date.
Write down/save the key that Bing Maps generates for you. We'll need this information later.
Head over to the client application management area, and click on "Register New App" Take a look at Yammer's API introduction, it's some pretty good stuff.
Fill out the required information, we can change all but the application name later. For now, supply the reserved HTTPS url (https://hashmaps.azurewebsites.net/) for the website, and https://hashmaps.azurewebsites.net/Home/Display for the redirect URI
- The Website value is where users will be redirected to in the event they look up your application's company profile in yammer
- The Redirect URI is the endpoint yammer will redirect your users to in the event they successfully authenticate with Yammer.
Once you've filled out the information, and completed the registration, head over to the "My Apps" area, and write down/save the client ID and client secret that yammer generated for you. We'll need this information for later.
The code has the following sections:
- Setting up the solution
- Authentication
- OAuth module
- Our Yammer API
- JavaScript constants
- Login Page
- Maps Page
- Cycling the posts
Start off by creating a new empty web project in Visual Studio. From there, add the following NuGet packages:
- Bootstrap
- EntityFramework
- Microsoft ASP.NET MVC
- Microsoft ASP.Net Web API 2.1
- Microsoft ASP.Net Web Optimization Framework
Then, add the following projects
- HashMaps.Data (this is where we will add our database objects)
- Referencing EntityFramework
- HashMaps.Model (this is where our DTO objects will live)
- Modules (this is where we will host our OAuth logic)
- Referencing EntityFramework and Json.Net
Finally,
- In your HashMaps project, add HashMaps.Data, HashMaps.Model, and Modules as project references
- HashMaps.Data project references HashMaps.Model
- Modules project references HashMaps.Data and HashMaps.Model.
When you're finished, your project structure should look as follows:
In your HashMaps.Model project, let's start by declaring our core IPrincipal
and IIdentity
objects. These objects will serve as our "application" level identity, and we'll internally use them to define "who" a user is. It is these objects that we'll be attaching Yammer authentication to.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Principal;
using System.Text;
using System.Threading.Tasks;
namespace HashMaps.Model
{
public class UserPrincipal : IPrincipal
{
public UserPrincipal(UserIdentity identity)
{
this.Identity = identity;
}
public UserIdentity Identity { get; set; }
IIdentity IPrincipal.Identity { get { return Identity; } }
public bool IsInRole(string role)
{
return true;
}
}
public class UserIdentity : IIdentity
{
public String AuthToken { get; set; }
public int ID { get; set; }
public string AuthenticationType
{
get { return "OAuth"; }
}
public bool IsAuthenticated
{
get { return true; }
}
public string Name { get; set; }
}
}
Notice that we're unconditionally returning true for the IsInRole
method - that's because for purposes of this application, we're not introducing the concept of tiered (normal/admin) accounts. Everybody has access to everything.
Additionally, we're unconditionally returning true on the IsAuthenticated
property in the UserIdentity
class, because (again) for purposes of this application, we won't create an instance of a UserIdentity
if we fail to authenticate the user.
Notice here that we're also adding an AuthToken
property to the UserIdentity
. Since we'll be making subsequent calls to Yammer after authentication (and their API requires this value), we'll keep this value in memory so we don't have to incur an unnecessary performance hit to retrieve this value a subsequent time.
The idea here is that we're going to create a custom HTTP module which constructs our IPrinciple
and IIdentity
objects, and then attach that user to Context.User. When the HTTP request pipeline begins executing authentication and authorization code, it'll use our constructed objects as the basis for determining whether or not to continue. If you want a deeper explanation on how this works, 4Guys from Rolla has an excellent article which explains the theory in detail.
Let's start by looking at the first part of the context_AuthenticateRequest
method:
var app = sender as HttpApplication;
if (app.Context.User != null)
{ return; }
if (app.Request.Cookies.Keys.Cast<String>().Contains(System.Web.Security.FormsAuthentication.FormsCookieName))
{
try
{
var authCookie = app.Request.Cookies[System.Web.Security.FormsAuthentication.FormsCookieName];
var ticket = System.Web.Security.FormsAuthentication.Decrypt(authCookie.Value);
if(ticket.Expired == false)
{
var userID = Convert.ToInt32(ticket.Name);
var authtoken = ticket.UserData;
app.Context.User = new UserPrincipal(new UserIdentity() { AuthToken = authtoken, ID = userID });
return;
}
}
catch (Exception ex)
{ }
}
Here, we're just looking to make sure that we've already authenticated the user. That is, if the context aready has a user instance accociated with it, then skip the rest, and if the request comes in with a valid forms authentication ticket in its cookie collection, convert it to a user, associate it with the context, and return.
The second part is where we do the cool stuff:
if (String.IsNullOrWhiteSpace(app.Request.Params.Get("code")) == false)
{
using (var cli = new WebClient())
{
var authorizeCode = app.Request.Params.Get("code");
var yammerClientID = ConfigurationManager.AppSettings["YammerClientID"];
var yammerClientSecret = ConfigurationManager.AppSettings["YammerClientSecret"];
var authenticateUrl = String.Format("https://www.yammer.com/oauth2/access_token.json?client_id={0}&client_secret={1}&code={2}", yammerClientID, yammerClientSecret, authorizeCode);
var authenticationResponse = cli.DownloadString(authenticateUrl);
var authenticationObject = JsonConvert.DeserializeObject<AuthenticationResult>(authenticationResponse);
var authenticationToken = authenticationObject.access_token.token.ToString();
using (var db = new HashMapContext())
{
var user = db.Users.Where(u => u.ID == authenticationObject.user.id).FirstOrDefault();
if(user == null)
{ user = db.Users.Create();
user.ID = authenticationObject.user.id;
user.Location = authenticationObject.user.location;
user.MugshotUrl = authenticationObject.user.mugshot_url;
user.Name = authenticationObject.user.full_name;
user.AuthorizationCode = authorizeCode;
user.AuthenticationToken = authenticationToken;
db.Users.Add(user);
}
else
{ user.AuthenticationToken = authenticationToken;
}
db.SaveChanges();
}
var expirationDate = DateTime.UtcNow.AddHours(1);
var ticket = new System.Web.Security.FormsAuthenticationTicket(1, authenticationObject.user.id.ToString(), DateTime.UtcNow, expirationDate, true, authenticationToken);
var cookieString = System.Web.Security.FormsAuthentication.Encrypt(ticket);
var authCookie = new HttpCookie(System.Web.Security.FormsAuthentication.FormsCookieName, cookieString);
authCookie.Expires = expirationDate;
authCookie.Path = System.Web.Security.FormsAuthentication.FormsCookiePath;
app.Response.Cookies.Set(authCookie);
app.Context.User = new UserPrincipal(new UserIdentity() { ID = authenticationObject.user.id, AuthToken = authenticationToken });
}
return;
}
When Yammer successfully authenticates your user, and the user authorizes your app, yammer will redirect back to your application with a code
parameter in the url. Using the user authorization code, your Yammer client ID, and your Yammer client secret, you can then make API calls to Yammer to get information about your newly authenticated user.
After we extract a bit of information about the user and save it off, we convert that information into a UserPrincipal
and UserIdentity
, and then assign it to app.Context.User
.
Make sure that this project is referenced in your HashMaps.Web project, and then head over to the root level web.config. Under the System.WebServer
node, ensure the following is present:
<modules>
<add name="OAuthModule" type="Modules.OAuthModule, Modules" />
</modules>
Lets head over now to the web project and add a Web API endpoint. In the Get
method (which is protected by the [Authorize]
attribute), we'll start by extracting the auth token from the context's current user, and making a api call to search Yammer for "AvaTS14".
var user = this.User as UserPrincipal;
String searchResults = null;
var authToken = user.Identity.AuthToken;
using (var cli = new WebClient())
{
cli.Headers.Add("Authorization", "Bearer " + authToken);
cli.QueryString.Add("search", "AvaTS14");
try
{
searchResults = cli.DownloadString("https://www.yammer.com/api/v1/search.json");
}catch(Exception ex)
{
return new List<HashMaps.Model.Dto.DtoMessage>();
}}
var feed = JsonConvert.DeserializeObject<RootObject>(searchResults);
Notice how we were able to cast this.User
to a UserPrincipal
? That's only because we explicitly put an [Authorize]
attribute on the method (rejecting anonymous requests), and injected our custom authentication module into the HTTP pipeline.
Now, the raw result that comes back from Yammer includes a lot of things that we don't need for purposes of this application. We'll then turn this into a collection of DTOs (data transfer objects):
var dtos = new List<HashMaps.Model.Dto.DtoMessage>();
foreach (var msg in feed.messages.messages)
{
try
{
var dto = new HashMaps.Model.Dto.DtoMessage();
dto.ID = msg.id;
dto.PlainBody = msg.body.plain;
dto.WebUrl = msg.web_url;
dto.Composer = new HashMaps.Model.Dto.DtoPerson() { ID = msg.sender_id };
dtos.Add(dto);
}
catch (Exception ex)
{ } }
Now that we have the message we'll display, we need to find out where the composer is from.
var distinctSenderIDs = feed.messages.messages.Select(m => m.sender_id).Distinct();
var distinctSenders = new List<Model.Dto.DtoPerson>();
using (var db = new HashMapContext())
{
foreach (var senderID in distinctSenderIDs)
{
var dbSender = db.Users.Where(u => u.ID == senderID).FirstOrDefault();
if (dbSender != null)
{
var sender = new Model.Dto.DtoPerson();
sender.FullName = dbSender.Name;
sender.ID = dbSender.ID;
sender.Location = dbSender.Location;
distinctSenders.Add(sender);
continue;
}
using (var cli = new WebClient())
{
cli.Headers.Add("Authorization", "Bearer " + authToken);
try
{
var userJson = cli.DownloadString("https://www.yammer.com/api/v1/users/" + senderID.ToString() + ".json");
var parsedUser = JsonConvert.DeserializeObject<Person>(userJson);
var sender = new Model.Dto.DtoPerson();
sender.FullName = parsedUser.full_name;
sender.ID = parsedUser.id;
sender.Location = parsedUser.location;
distinctSenders.Add(sender);
}
catch (Exception ex)
{ continue; } }
}
}
First, we're going to look in our own database for the person before reaching out to Yammer for the person. The desired field here is location
. If we were going to scale this for larger use, we might modify this so that we see when the last time we pulled location, as people tend to move from city to city - This application was only designed to be used for a few days, so the idea behind hitting the DB was we'll take a performance hit on our DB before we bother Yammer asking for the location of a person that we pulled earlier that day.
Now that we have all of the data we're going to return to our caller, let's merge it into a usable DTO
foreach (var dto in dtos)
{
var composer = distinctSenders.Where(s => s.ID == dto.Composer.ID).FirstOrDefault();
if (composer != null)
{
dto.Composer = composer;
}
}
return dtos.Where(dto => String.IsNullOrWhiteSpace(dto.Composer.Location) == false);
One last thing we want to do for our API consumers is clean up the feed so that we omit posts where the user didn't specify a location. The whole point of this is to render the posts on a map, and if there's no location data, then the post is essentially worthless to us.
I'm not exactly sure if it's considered "good" practice or not, but one thing I like to do is create a constants object on the client side which keeps track of all of the configuration item's we'll need from page to page.
In the Layout.cshtml page, I add the following:
@Scripts.Render("~/bundles/Frameworks")
<script type="text/javascript">
var constants = {
yammerClientID: "@System.Configuration.ConfigurationManager.AppSettings["YammerClientID"]",
bingMapsKey: "@System.Configuration.ConfigurationManager.AppSettings["BingMapsKey"]",
host: "@String.Format("{0}: };
</script>
@RenderSection("scripts", required: false)
This pulls the Yammer client ID and the Bing Maps key from our web.config, and makes them available as javascript variables. I also push the host forward, as the Yammer redirect needs this value to match with the value we set up in the API key profile.
On the login page, you can use a button design that yammer provides, or you can make your own. The key here is the following code:
(function () {
$("#logIn").click(function () {
var redirect = encodeURIComponent(constants.host + "/Home/Display");
window.location = "https://www.yammer.com/dialog/oauth?client_id=" + constants.yammerClientID + "&redirect_uri=" + redirect;
});
});
This tells your app to redirect to Yammer for authentication, and upon successful authentication, come back to the Home/Display controller.
Finally, on the page which actually renders the map, let's start off by setting up the bing map to associate with a div:
var map;
var searchManager;
var options = {
credentials: constants.bingMapsKey,
center: new Microsoft.Maps.Location(35.333333, 25.133333),
zoom: 2
};
$("#mapDiv").text("");
var map = new Microsoft.Maps.Map(document.getElementById("mapDiv"), options);
Technically, it's not necessary to center the map - Through trial and error I found a place between South America and Africa which defaults the map so that North and South America fits on the left, and Asia, Europe, and Africa fit on the riight, and Antartica is visible and centered on the bottom.
Next, we need to define a call back when the map is ready to search and perform geocoding operations:
Microsoft.Maps.loadModule('Microsoft.Maps.Search', { callback: searchModuleLoaded });
function searchModuleLoaded() {
searchManager = new Microsoft.Maps.Search.SearchManager(map);
}
The basic idea here is that we'll declare an array of posts, cycle through them one at a time, and then periodically refresh the array with new values from the API that we created.
First, let's write the code to do a refresh on the array that we're cycling through:
var yams = [];
var currentYamIndex = -1;
var currentPin = null;
var infobox = null;
refreshYams();
window.setInterval(function () {
refreshYams();
}, 60 * 2 * 1000);
function refreshYams()
{
$("#loading-placeholder").show();
$.ajax({
url: "/Api/YammerUpdates",
success: function (data, textStatus, jqXHR) {
$("#loading-placeholder").hide();
if (data.length > 0)
{ yams = data; } },
error: function (jqXHR, textStatus, errorThrown) {
alert("Error");
}
});
}
It's not very sexy, but every 2 minutes, we're just showing the loading place holder, issuing a HTTP get to the api to pull the new data, and then hiding the placeholder once we've retrieved the data.
Now let's cycle through them:
window.setInterval(function () {
cycleYams();
}, 5 * 1000);
function cycleYams() {
if (searchManager == undefined || searchManager == null)
{ return; }
if (yams.length == 0)
{ return; }
if (currentYamIndex + 1 > yams.length - 1)
{ currentYamIndex = 0; }
else
{ currentYamIndex++; }
PlotData(yams[currentYamIndex]);
}
Again, nothing special, just moving the current iterator forward once every 5 seconds, and then looping back to the beginning once we hit the end.
The real magic happens when we plot the post:
function PlotData(item)
{
var geocodeRequest = { where: item.Composer.Location, count: 1, callback: geocodeCallback, errorCallback: errCallback, userData: item };
searchManager.geocode(geocodeRequest);
}
function geocodeCallback(geocodeResult, userData) {
if (currentPin != null)
{
map.entities.remove(currentPin); }
if (geocodeResult.results.length > 0)
{
var location = geocodeResult.results[0].location;
currentPin = new Microsoft.Maps.Infobox(location, {
visible: true,
title: userData.Composer.FullName,
description: userData.PlainBody.substr(0,140)
});
map.entities.push(currentPin);
}
}
function errCallback(geocodeRequest) {
}
The first thing we do here is remove the existing pin - we *could* have two posts showing at the same time, but it makes for a bad looking UI.
When we drop a pin on the map, it HAS to be by latitude and longitude. In our Yammer profile, we don't get that - we get "San Antonio, Texas". So we issue a geocode request, and when it successfully comes back, it gives us the latitude and longitude which we can then pass onto an infobox that gets dropped on the map.
Points of Interest
I had a lot of fun building this application. My next steps are to migrate this to the OWIN application structure, allow users to search by a custom hashtag, and search their Facebook, Twitter, and LinkedIn feeds, as well as Yammer
History
2014-11-19 : Fixed some spelling errors
2014-11-18 : Original post