Skip to content

Commit

Permalink
Initial pass at pulling events list from Meetup and displaying, addre…
Browse files Browse the repository at this point in the history
…ssing pdx-cocoaheads#4.

Droplet.client makes the request to the Meetup API, which returns a list in
JSON. The JSON is unpacked into instances of `MeetupEvent`. `MeetupEvent`
currently stores just the fields known to be needed for the template, and
implements `MustacheBoxable` so that it can be included directly in the
rendering context.

Created Mustache template and stylesheet for the page, with two filters to
make numerical data look good.

Added link to new page to homepage nav.

Added basic testing for new functionality: Mustache filters and `MeetupEvent`.

Needs further structural refinement: currently too much is done inside the
route handler.
  • Loading branch information
Josh Caswell committed Aug 4, 2016
1 parent f7b645b commit c1f5a45
Show file tree
Hide file tree
Showing 10 changed files with 547 additions and 2 deletions.
60 changes: 60 additions & 0 deletions App/EventsMustacheFilters.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation
import Mustache

/** Namespace/vendor for FilterFunctions needed for the Events page. */
struct EventsMustacheFilters {

/**
* All filters used in the upcoming-events.mustache template, boxed and
* ready to be added to the Mustache rendering context.
*/
static var asContext: [String : MustacheBox] {
return ["formattedDate" : Box(filter: formattedDateFilter),
"pluralizedRSVP" : Box(filter: pluralizedRSVPFilter)]
}

/** Fallback for missing timezone info. */
// Note: this may be overly defensive; currently a `MeetupEvent` cannot
// be created without a timezone.
private static let PDXStandardUTCOffset = -25200000

/**
* Format the event's millisecond timestamp into a nice description, using
* the event's timezone information.
*/
static var formattedDateFilter: FilterFunction = Filter {

(time: Int?, info: RenderingInfo) in

guard let milliseconds = time else {
return Rendering("Unknown date")
}

// Context's MustacheBox wraps a MeetupEvent
let offsetBox = info.context.mustacheBox(forKey: "utcOffset")
let utcOffset = (offsetBox.value as? Int) ?? PDXStandardUTCOffset

let date = Date(timeIntervalSince1970: Double(milliseconds/1000))
let formatter = DateFormatter()
// UTC offset is also milliseconds
formatter.timeZone = TimeZone(forSecondsFromGMT: utcOffset/1000)
formatter.dateStyle = .full
formatter.timeStyle = .short

return Rendering(formatter.string(from: date))
}

/** Change the inner text of the RSVP description based on the count. */
// This snazzy idea cribbed straight out of the Mustache docs.
// See Mustache/Rendering/CoreFunctions.swift#L310
static var pluralizedRSVPFilter: FilterFunction = Filter {

(count: Int?, info: RenderingInfo) in

var peoples: String = "people are"
if count == 1 {
peoples = "person is"
}
return Rendering(peoples)
}
}
82 changes: 82 additions & 0 deletions App/MeetupEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Mustache
import Vapor

/**
* Holder for values returned from Meetup API that we want to display on
* "Upcoming Events" page.
*/
struct MeetupEvent {
/** Event title */
let name: String
/** Event date in milliseconds since epoch */
let time: Int
/** Event location timezone's milliseconds from UTC */
let utcOffset: Int
/** Number of people who have said they're attending */
let rsvpCount: Int
/** URL (as string) of event's page on Meetup.com */
let link: String
}

extension MeetupEvent: MustacheBoxable {

/** Allow lookup of fields by name in Mustache rendering context */
func mustacheBox(forKey key: String) -> MustacheBox {

switch key {
case "name": return Box(boxable: self.name)
case "time": return Box(boxable: self.time)
case "utcOffset": return Box(boxable: self.utcOffset)
case "rsvpCount": return Box(boxable: self.rsvpCount)
case "link": return Box(boxable: self.link)
default: return Box()
}
}

var mustacheBox: MustacheBox {
return MustacheBox(value: self,
keyedSubscript: self.mustacheBox(forKey:))
}
}

/** Errors during unpacking of Meetup API's returned JSON data. */
enum MeetupEventUnpackError : ErrorProtocol {
case NotAnArray(JSON)
}

/**
* Transform an array of JSON-coded events returned from Meetup into
* `MeetupEvent` instances, skipping any that are malformed.
*
* - Parameter fromJSON: Vapor.JSON wrapping an array of objects
*
* - Throws: `MeetupEventUnpackError.NotAnArray` if the `fromJSON` parameter
* cannot be unwrapped into a Swift array
*
* - Returns: An array of `MeetupEvent`s in the order the event descriptions
* were presented in the JSON
*/
func unpackMeetupEvents(fromJSON json: JSON) throws -> [MeetupEvent] {

guard let events: [JSON] = json.array else {
throw MeetupEventUnpackError.NotAnArray(json)
}

return events.flatMap { eventJSON in

// Lenient unpacking: just skip an individual item if it's not usable
guard let event = eventJSON.object,
let name = event["name"].string,
let time = event["time"].int,
let utcOffset = event["utc_offset"].int,
let rsvpCount = event["yes_rsvp_count"].int,
let link = event["link"].string
else {

return nil
}

return MeetupEvent(name: name, time: time, utcOffset: utcOffset,
rsvpCount: rsvpCount, link: link)
}
}
44 changes: 43 additions & 1 deletion App/main.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Vapor
import VaporMustache

import Engine
import SocksCore
import Mustache

/**
Adding a provider allows it to boot
Expand Down Expand Up @@ -71,6 +73,46 @@ drop.get("/") { request in
return try drop.view("index.html")
}

drop.get("/events") { _ in

let template = "upcoming-events.mustache"

// Show default "no events" page if config data is missing for some reason
guard let eventsHost = drop.config["meetup", "hostname"].string,
let queryPath = drop.config["meetup", "events-queries", "all"].string
else {

drop.log.warning("No config info for events query")
return try drop.view(template, context: [:])
}

let requestURL = "https://\(eventsHost)/\(queryPath)"
let response = try? drop.client.get(requestURL)

// Special action for failed response?

// Test unpacking of response, show default "no events" on failure
guard let parsed = response?.json,
let unpacked = try? unpackMeetupEvents(fromJSON: parsed)
else {

drop.log.warning("Event list response from Meetup was " +
"empty or nonexistent")
return try drop.view(template, context: [:])
}

// Set up context with successfully unpacked data
//!!!: Typing `context`'s values as `MustacheBox`, or allowing that to be
//!!!: inferred leads to a fatal error due to a failed unsafeBitCast
//!!!: in _dictionaryBridgeToObjectiveC *after returning* the built view.
var context: [String : Any] = ["events" : Mustache.Box(boxable: unpacked)]
EventsMustacheFilters.asContext.forEach { (key, filter) in
context[key] = filter
}

return try drop.view(template, context: context)
}

/**
Return JSON requests easy by wrapping
any JSON data type (String, Int, Dict, etc)
Expand Down
6 changes: 6 additions & 0 deletions Config/meetup.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"hostname" : "api.meetup.com",
"events-queries" : {
"all" : "PDX-iOS-CocoaHeads/events"
}
}
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ let package = Package(
"Database",
"Localization",
"Public",
"Resources",
"Resources"
]
)

7 changes: 7 additions & 0 deletions Public/css/upcoming-events.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.rsvp-desc {
font-size: smaller;
}

.meetup-link {
font-size: smaller;
}
1 change: 1 addition & 0 deletions Resources/Views/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ <h1><a href="./">PDX Cocoaheads</a></h1>
<nav>
<ul>
<li><a href="#">Home</a></li>
<li><a href="/events">Events</a></li>
<li><a href="https://github.com/pdx-cocoaheads/policies/blob/master/citizen_code_of_conduct.md">Code of Conduct</a></li>
</ul>
</nav>
Expand Down
43 changes: 43 additions & 0 deletions Resources/Views/upcoming-events.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="{{ site.description }}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDX CocoaHeads Events</title>
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/upcoming-events.css">
</head>
<body>
<div class="container">
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="#">Events</a></li>
<li><a href="https://github.com/pdx-cocoaheads/policies/blob/master/citizen_code_of_conduct.md">Code of Conduct</a></li>
</ul>
</nav>
<h2>Upcoming Events</h2>
<hr/>
<div class="event-list">
{{#events}}
<p>
<strong class="event-title">{{name}}</strong><br/>
<em>Date:</em> {{formattedDate(time)}}<br/>
<span class="rsvp-desc" style="float: left">
{{rsvpCount}} {{ pluralizedRSVP(rsvpCount) }} coming
</span>
<span class="meetup-link" style="float: right">
<a href="{{link}}">RSVP on Meetup.com</a>
</span>
<hr/>
</p>
{{/events}}
{{^events}}
We don't seem to have any events scheduled right now.
{{/events}}
</div>
</div>
</body>
</html>
Loading

0 comments on commit c1f5a45

Please sign in to comment.