Skip to content

Commit

Permalink
Finishes pdx-cocoaheads#4 "Pull data from Meetup API"
Browse files Browse the repository at this point in the history
The "/events" route handler now uses a MeetupAPI object, which makes the
connection to api.meetup.com. The MeetupAPI.getUpcomingEvents() method pulls
down future events.

The events are provided by Meetup.com as a JSON array. The JSON objects are
unpacked into a parallel array of MeetupEvent structs, which is passed back
out to the route handler to be used as the context for the view.

A private Configuration struct supplies MeetupAPI with the appropriate URL
information. It gets the URL pieces from the Config/meetup.json file,
accessing that file via the active Droplet's Config object: the Droplet
aleady knows where the config files are, rather than us having to find them
again. The Droplet's Config is passed in to MeetupAPI on its creation, which
hands it off to its Configuration.
  • Loading branch information
Josh Caswell committed Aug 24, 2016
1 parent c1f5a45 commit 78cfff9
Show file tree
Hide file tree
Showing 12 changed files with 457 additions and 269 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ 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.
* ready to be added to the Mustache rendering context.
*/
static var asContext: [String : MustacheBox] {
return ["formattedDate" : Box(filter: formattedDateFilter),
Expand All @@ -18,11 +18,11 @@ struct EventsMustacheFilters {
// 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.
/**
* Format the event's millisecond timestamp into a nice description,
* using the event's timezone information.
*/
static var formattedDateFilter: FilterFunction = Filter {
static var formattedDateFilter: FilterFunction = Filter {

(time: Int?, info: RenderingInfo) in

Expand All @@ -47,7 +47,7 @@ struct EventsMustacheFilters {
/** 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 {
static var pluralizedRSVPFilter: FilterFunction = Filter {

(count: Int?, info: RenderingInfo) in

Expand Down
145 changes: 145 additions & 0 deletions App/Meetup/MeetupAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import class Vapor.Config
import class Engine.HTTPClient
import class Engine.TCPClientStream

/** Access to the Meetup.com API */
struct MeetupAPI {

enum Error : ErrorProtocol {

/** Necessary config file info was not found */
case missingConfigInfo
/** Could not connect or response from API was absent */
case noResponse
/** Response from API could not be parsed into JSON */
case responseInvalidJSON
/** Response was not the expected array of events */
case responseNotAnArray
}

// `Vapor.Droplet.client` uses the class object and calls static
// methods, so we will do the same.
private let client = HTTPClient<TCPClientStream>.self

private let configuration: Configuration

init(config: Config) {
self.configuration = Configuration(config: config)
}

/**
* Pulls down future events for the group from Meetup.com's API and
* converts them to `MeetupEvent`s for eventual display.
*
* -Throws: `MeetupAPI.Error.missingConfigInfo` if the necessary URL info
* is somehow missing from the config file.
* - Throws: `MeetupAPI.Error.noResponse` if the connection fails or
* produces an empty response.
* - Throws: `MeetupAPI.Error.responseInvalidJSON` if the response cannot
* be parsed into JSON
* - Throws: `MeetupAPI.Error.responseNotAnArray` if the response parses
* into something other than an array.
*
* - Returns: An array of `MeetupEvent` representing the event data, in
* the same order Meetup.com provided them.
*/
func getUpcomingEvents() throws -> [MeetupEvent] {

guard let requestURL = self.configuration.eventsEndpoint else {
throw Error.missingConfigInfo
}

guard let response = try? self.client.get(requestURL) else {
throw Error.noResponse
}

guard let parsed = response.json else {
throw Error.responseInvalidJSON
}

guard let events = try? MeetupEvent.unpack(fromJSON: parsed) else {
throw Error.responseNotAnArray
}

return events
}
}

/**
* Vend values, via the given `Vapor.Config`, from the meetup.json file.
*/
private struct Configuration {

/**
* The underlying `Vapor.Config` object.
*
* This is recieved from the active `Droplet`, rather than created from
* scratch, because the `Droplet` knows the correct working directory.
*/
private let config: Config

init(config: Config) {
self.config = config
}

/** Keys for the meetup.json file, including the filename itself. */
private struct Keys {

static let file = "meetup"
static let host = "host"
static let groupName = "group-name"
static let eventsPath = "events-path"
}

// Scheme is not really configurable, as -- according to Vapor -- using
// HTTPS on Linux requires an additional component
var scheme: String {
return "http"
}

/** API hostname */
var host: String? {
return self.config[Keys.file, Keys.host]?.string
}

/** PDX CocoaHeads group's URL segment; what Meetup calls `:urlname` */
var groupName: String? {
return self.config[Keys.file, Keys.groupName]?.string
}

/** API path for fetching all events of a given group */
var eventsPath: String? {
return self.config[Keys.file, Keys.eventsPath]?.string
}

/**
* Base URL for any PDX CocoaHeads group endpoint; includes scheme,
* host, and group name. `nil` if any of that info is unavailable.
*/
var groupURL: String? {

guard
let host = self.host,
let groupName = self.groupName
else {
return nil
}

return self.scheme.finished(with: "://") +
host.finished(with: "/") +
groupName
}

/** API endpoint for all CocoaHeads-PDX events */
var eventsEndpoint: String? {

guard
let groupURL = self.groupURL,
let eventsPath = self.eventsPath
else {
return nil
}

return groupURL.finished(with: "/") + eventsPath
}
}
22 changes: 22 additions & 0 deletions App/Meetup/MeetupEvent+Mustache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Mustache

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:))
}
}
33 changes: 33 additions & 0 deletions App/Meetup/MeetupEvent+Unpack.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import enum Vapor.JSON

extension MeetupEvent {

/** Errors during unpacking of Meetup API's JSON data. */
enum UnpackError : ErrorProtocol {
case notAnArray
}

/**
* 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: `MeetupEvent.UnpackError.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
*/
static func unpack(fromJSON json: JSON) throws -> [MeetupEvent] {

guard let events: [JSON] = json.array else {
throw UnpackError.notAnArray
}

return events.flatMap {
// Just skip an individual item if it's not usable
MeetupEvent(fromJSON: $0)
}
}
}
48 changes: 48 additions & 0 deletions App/Meetup/MeetupEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import enum Vapor.JSON

/**
* 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

init?(fromJSON json: JSON) {

guard
let event = json.object,
let name = event[Keys.name.rawValue].string,
let time = event[Keys.time.rawValue].int,
let utcOffset = event[Keys.utcOffset.rawValue].int,
let rsvpCount = event[Keys.rsvpCount.rawValue].int,
let link = event[Keys.link.rawValue].string
else {
return nil
}

self.name = name
self.time = time
self.utcOffset = utcOffset
self.rsvpCount = rsvpCount
self.link = link
}

/** Keys in the Meetup API's JSON "event" object */
private enum Keys : String {

case name, time, link
case utcOffset = "utc_offset"
case rsvpCount = "yes_rsvp_count"
}
}

82 changes: 0 additions & 82 deletions App/MeetupEvent.swift

This file was deleted.

Loading

0 comments on commit 78cfff9

Please sign in to comment.