Yesterday I was doing some research into serverless meetups when I encountered something that bugged me about the Meetup.com web site. Specifically, this:

Login to search because...

I couldn't search for meetups until I logged in. Now, don't get me wrong, I've got a Meetup.com login, and I could have logged in in about 2 seconds, but this really bugged me. (As an aside, you can just go to https://www.meetup.com/find/events/ to skip being forced to log in.)

Even after logging in, I didn't like that I had to explicitly tell the form to not care about distance in order to find groups across the country:

Search

In this case, their defaults I think make sense. As an evangelist, I'm looking for meetups I can speak at (hey, by the way, I'd love to speak at yours!), so my use case is not the norm. But I was also a bit bugged by the fact that I couldn't limit my searches to America.

Therefore - I decided to do what any good developer would do - spend 10x as much time writing my own solution versus just dealing with what was given to me!

I knew Meetup had an API and I blogged on it a few years ago (Using the Meetup API in Client-Side Applications), so I figured this would be a great example of how I could use serverless, and OpenWhisk in particular, to build my own API wrapper around their data to build my own tool.

For my wrapper, I decided to build an interface to their Find Groups end point. (By the way, since I complained a bit about their UI I want to point out one thing they do very nicely - not forcing me to login to read API docs!)

Here is the action I created to wrap their API.

const rp = require('request-promise');

function main(args) {

    return new Promise((resolve, reject) => {
        
        let url = 'https://api.meetup.com/find/groups?key='+args.key;

        if(args.category) url += '&category='+args.category;
        if(args.country) url += '&country='+args.country;
        if(args.fallback_suggestions) url += '&fallback_suggestions='+args.fallback_suggestions;
        if(args.fields) url += '&fields='+args.fields;
        //skipping filter
        if(args.lat) url += '&lat='+args.lat;
        if(args.lon) url += '&lon='+args.lon;
        if(args.location) url += '&location='+args.location;
        if(args.radius) url += '&radius='+args.radius;
        //skipping self_groups
        if(args.text) url += '&text='+encodeURIComponent(args.text);
        if(args.topic_id) url += '&topic_id='+args.topic_id;
        if(args.upcoming_events) url += '&upcoming_events='+args.upcoming_events;
        if(args.zip) url += '&zip='+args.zip;

        if(args.only) url += '&only='+args.only;
        if(args.omit) url += '&omit='+args.omit;

        /*
        Note to self: I modified the code to return the full
        response so I could potentially do paging. I decided 
        against that for now, but I'm keeping resolveWithFullResponse
        in for the time being.
        */
        let options = {
            url:url, 
            json:true,
            resolveWithFullResponse: true
        };

        rp(options).then((resp) => {
            //console.log(resp.headers);
            /*
            When using radius=global and a country, the country filter doesn't
            quite work. SO let's fix that.
            */
            let items = resp.body;
            console.log('country='+args.country+' radius='+args.radius);
            if(args.country && args.country !== '' && args.radius === 'global') {
                console.log('Doing post filter on country');
                items = items.filter((item) => {
                    return (item.country === args.country);
                });
            }
            resolve({result:items});
        }).catch((err) => {
            reject({error:err});
        });

    });

}

For the most part, this is simply building a URL based on arguments. I'm not doing any validation since the API will do that for me. I'm also not doing any pagination since in my testing, I got over 100 results. I couldn't find docs on how many max results they would return and I did do a bit of "prep work" for adding support in the future, but for now, it will return at least 170 or so results in my testing.

Note that the action expects an argument for the key. I set that as a default action parameter so I don't have to include it in my own calls.

The only real interesting part is the manipulation I do for "country". While the Meetup API has a country argument, it seems to ignore it when you set the radius argument to global. So basically, I can't say "Don't care about distance from my home but keep it to America." Therefore I do my own filtering on the results after fetching them.

This is a great example (imo) of where serverless wrappers can be so useful. I took an existing API and built my own to address the shortcomings (or at least my perceived shortcomings) of it.

And that was it - literally. I "web" enabled it and my API was done. I then built the front end. I'm not going to bore you with my HTML and CSS. You can run the demo yourself here: https://cfjedimaster.github.io/Serverless-Examples/meetup/client/

In case you don't want to, here's an example of the output:

Yeah, not necessarily the prettiest demo in the world, but it did give me a chance to finally try Flexbox. I have an idea for a nicer version I'm going to try to get out the door this week, but we'll see. The JavaScript code behind this is relatively simple. It's 99% DOM manipulation to be honest.

const api = 'https://openwhisk.ng.bluemix.net/api/v1/web/ray@camdenfamily.com_dev/meetup/search.json?radius=global&category=34&omit=description,organizer,category,urlname,score&country=US';

let $keyword, $submitBtn, $resultItems;

$(document).ready(function() {

    $('#searchForm').on('submit', doSearch);
    $keyword = $('#search');
    $submitBtn = $('#submitBtn');
    $resultItems = $('#resultItems');

});

function doSearch(e) {
    e.preventDefault();
    $resultItems.html('');

    console.log('Ok, search against Meetup');
    let keyword = $keyword.val();
    console.log(keyword);

    //todo: leave if no search

    $submitBtn.attr('disabled','disabled');
    let url = api + '&text='+encodeURIComponent(keyword);
    $resultItems.html('<i>Searching...</i>');

    $.get(url).then((resp) => {
        $submitBtn.removeAttr('disabled');
        console.log(resp);
        if(resp.result.length === 0) {
            $resultItems.html('<p>Sorry, but there were no results.</p>');
            return;
        }
        let s = `<p>I found ${resp.result.length} match(es).</p>`;
        resp.result.forEach((item) => {
            let itemHtml = `
<h2>${item.name}</h2>
<p>
<a href="${item.link}#" target="_new">${item.link}</a><br/>
Members: ${item.members}<br/>
Address: ${item.city}, ${item.state}
</p>
            `;
            s += itemHtml;
            console.dir(item);
        });
        $resultItems.html(s);
    });
}

You'll notice my URL constant on top sets up a bunch of defaults and when the actual search is performed, I'm just adding a keyword.

So what do you think? If you want to see the code for yourself, you can find it here: https://github.com/cfjedimaster/Serverless-Examples/tree/master/meetup

The "action" folder contains my action code (one file) and "client" contains the client-side application that uses the API on OpenWhisk.