diff --git a/App/EventsMustacheFilters.swift b/App/Meetup/EventsMustacheFilters.swift similarity index 88% rename from App/EventsMustacheFilters.swift rename to App/Meetup/EventsMustacheFilters.swift index cac5e8a..a840b3b 100644 --- a/App/EventsMustacheFilters.swift +++ b/App/Meetup/EventsMustacheFilters.swift @@ -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), @@ -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 @@ -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 diff --git a/App/Meetup/MeetupAPI.swift b/App/Meetup/MeetupAPI.swift new file mode 100644 index 0000000..f45571f --- /dev/null +++ b/App/Meetup/MeetupAPI.swift @@ -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.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 + } +} diff --git a/App/Meetup/MeetupEvent+Mustache.swift b/App/Meetup/MeetupEvent+Mustache.swift new file mode 100644 index 0000000..ef65e83 --- /dev/null +++ b/App/Meetup/MeetupEvent+Mustache.swift @@ -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:)) + } +} diff --git a/App/Meetup/MeetupEvent+Unpack.swift b/App/Meetup/MeetupEvent+Unpack.swift new file mode 100644 index 0000000..f27cf7b --- /dev/null +++ b/App/Meetup/MeetupEvent+Unpack.swift @@ -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) + } + } +} diff --git a/App/Meetup/MeetupEvent.swift b/App/Meetup/MeetupEvent.swift new file mode 100644 index 0000000..e270e15 --- /dev/null +++ b/App/Meetup/MeetupEvent.swift @@ -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" + } +} + diff --git a/App/MeetupEvent.swift b/App/MeetupEvent.swift deleted file mode 100644 index 51ed318..0000000 --- a/App/MeetupEvent.swift +++ /dev/null @@ -1,82 +0,0 @@ -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) - } -} diff --git a/App/main.swift b/App/main.swift index 669ad0e..5aa6848 100644 --- a/App/main.swift +++ b/App/main.swift @@ -3,7 +3,6 @@ import VaporMustache import Engine import SocksCore import Mustache - /** Adding a provider allows it to boot and initialize itself as a dependency. @@ -17,7 +16,7 @@ let mustache = VaporMustache.Provider(withIncludes: [ /** Xcode defaults to a working directory in - a temporary build folder. + a temporary build folder. In order for Vapor to access Resources and Configuration files, the working directory @@ -64,7 +63,7 @@ let _ = drop.config["app", "key"].string ?? "" view to any request to the root directory of the website. Views referenced with `app.view` are by default assumed - to live in /Resources/Views/ + to live in /Resources/Views/ You can override the working directory by passing --workDir to the application upon execution. @@ -77,35 +76,36 @@ 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 { + let meetup = MeetupAPI(config: drop.config) + + var events: [MeetupEvent] = [] + do { - drop.log.warning("No config info for events query") - return try drop.view(template, context: [:]) + events = try meetup.getUpcomingEvents() } - - 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 { + catch MeetupAPI.Error.missingConfigInfo { + + drop.log.warning("Could not retrieve config info for MeetupAPI.") + } + catch MeetupAPI.Error.noResponse { - drop.log.warning("Event list response from Meetup was " + - "empty or nonexistent") - return try drop.view(template, context: [:]) + drop.log.warning("Request to Meetup failed or produced " + + "no response.") + } + catch MeetupAPI.Error.responseInvalidJSON { + + drop.log.warning("Meetup response could not be parsed to JSON.") + } + catch MeetupAPI.Error.responseNotAnArray { + + drop.log.warning("Meetup JSON did not represent expected array.") } - // 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 + //!!!: 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)] + var context: [String : Any] = ["events" : Mustache.Box(boxable: events)] + EventsMustacheFilters.asContext.forEach { (key, filter) in context[key] = filter } @@ -118,7 +118,7 @@ drop.get("/events") { _ in any JSON data type (String, Int, Dict, etc) in JSON() and returning it. - Types can be made convertible to JSON by + Types can be made convertible to JSON by conforming to JsonRepresentable. The User model included in this example demonstrates this. @@ -172,7 +172,7 @@ drop.get("data", Int.self) { request, int in */ /** - Here's an example of using type-safe routing to ensure + Here's an example of using type-safe routing to ensure only requests to "posts/" will be handled. String is the most general and will match any request @@ -254,13 +254,13 @@ drop.get("localization", String.self) { request, lang in */ /** - Middleware is a great place to filter - and modifying incoming requests and outgoing responses. + Middleware is a great place to filter + and modifying incoming requests and outgoing responses. Check out the middleware in App/Middleware. You can also add middleware to a single route by - calling the routes inside of `app.middleware(MiddlewareType) { + calling the routes inside of `app.middleware(MiddlewareType) { app.get() { ... } }` diff --git a/Config/meetup.json b/Config/meetup.json index 6d822f8..005bf29 100644 --- a/Config/meetup.json +++ b/Config/meetup.json @@ -1,6 +1,5 @@ { - "hostname" : "api.meetup.com", - "events-queries" : { - "all" : "PDX-iOS-CocoaHeads/events" - } -} \ No newline at end of file + "host" : "api.meetup.com", + "group-name" : "PDX-iOS-CocoaHeads", + "events-path" : "events" +} diff --git a/Tests/App/EventsFilterTests.swift b/Tests/App/Meetup/EventsFilterTests.swift similarity index 100% rename from Tests/App/EventsFilterTests.swift rename to Tests/App/Meetup/EventsFilterTests.swift diff --git a/Tests/App/Meetup/MeetupEvent+MustacheTests.swift b/Tests/App/Meetup/MeetupEvent+MustacheTests.swift new file mode 100644 index 0000000..241c4b7 --- /dev/null +++ b/Tests/App/Meetup/MeetupEvent+MustacheTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import App +import Mustache + +/** Ensure `mustacheBox(forKey:)` returns expected values. */ +class EventMustacheBoxTests: XCTestCase { + + let event = MeetupEvent(name: "Discussion", + time: 1234, + utcOffset: 5678, + rsvpCount: 9, + link: "http://example.com") + + func testMustacheBoxName() { + + let key = "name" + let expected = "Discussion" + + let boxValue = event.mustacheBox(forKey: key).value + + XCTAssertTrue((boxValue as? String) == expected) + } + + func testMustacheBoxTime() { + + let key = "time" + let expected = 1234 + + let boxValue = event.mustacheBox(forKey: key).value + + XCTAssertTrue((boxValue as? Int) == expected) + } + + func testMustacheBoxUtcOffset() { + + let key = "utcOffset" + let expected = 5678 + + let boxValue = event.mustacheBox(forKey: key).value + + XCTAssertTrue((boxValue as? Int) == expected) + } + + func testMustacheBoxRsvpCount() { + + let key = "rsvpCount" + let expected = 9 + + let boxValue = event.mustacheBox(forKey: key).value + + XCTAssertTrue((boxValue as? Int) == expected) + } + + func testMustacheBoxLink() { + + let key = "link" + let expected = "http://example.com" + + let boxValue = event.mustacheBox(forKey: key).value + + XCTAssertTrue((boxValue as? String) == expected) + } +} diff --git a/Tests/App/Meetup/MeetupEventTests.swift b/Tests/App/Meetup/MeetupEventTests.swift new file mode 100644 index 0000000..bfa4e51 --- /dev/null +++ b/Tests/App/Meetup/MeetupEventTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import App +import Vapor + +// Equality for `MeetupEvent` so that arrays of them can be tested +func ==(lhs: MeetupEvent, rhs: MeetupEvent) -> Bool { + return (lhs.name == rhs.name && + lhs.time == rhs.time && + lhs.utcOffset == rhs.utcOffset && + lhs.rsvpCount == rhs.rsvpCount && + lhs.link == rhs.link) +} + +extension MeetupEvent: Equatable {} + +/** Test unpacking of JSON into arrays of `MeetupEvent`s */ +class UnpackEventTests: XCTestCase { + + let sampleObject: [String : JSONRepresentable] = + ["name" : "Discussion", + "time" : 1234, + "utc_offset" : 5678, + "yes_rsvp_count" : 9, + "link" : "http://example.com"] + let event = MeetupEvent(name: "Discussion", + time: 1234, + utcOffset: 5678, + rsvpCount: 9, + link: "http://example.com") + + // Non-array JSON -> no results + func testNoUnpackOfNonarray() { + + var object = self.sampleObject + object.removeAll() + let json = JSON(object) + + XCTAssertThrowsError(try MeetupEvent.unpack(fromJSON: json)) + } + + // Empty input -> empty output + func testEmptyArrayUnpacksEmpty() { + + let json = JSON([]) + + let unpacked = try! MeetupEvent.unpack(fromJSON: json) + + XCTAssertTrue(unpacked.isEmpty) + } + + // Single malformed input -> empty output + func testMissingDataUnpacksEmpty() { + + var object = self.sampleObject + object.removeValue(forKey: "name") + let json = JSON([JSON(object)]) + + let unpacked = try! MeetupEvent.unpack(fromJSON: json) + + XCTAssertTrue(unpacked.isEmpty) + } + + // Two good entries, one bad -> skip the bad + func testSomeGoodData() { + + var badObject = self.sampleObject + badObject.removeValue(forKey: "name") + let json = JSON([JSON(self.sampleObject), + JSON(badObject), + JSON(self.sampleObject)]) + let expected = [self.event, self.event] + + let unpacked = try! MeetupEvent.unpack(fromJSON: json) + + XCTAssertEqual(unpacked, expected) + } + + // Good data successfully unpacks + func testGoodData() { + + let json = JSON([JSON(self.sampleObject), JSON(self.sampleObject)]) + let expected = [self.event, self.event] + + let unpacked = try! MeetupEvent.unpack(fromJSON: json) + + XCTAssertEqual(unpacked, expected) + } +} + +extension MeetupEvent { + + init(name: String, + time: Int, + utcOffset: Int, + rsvpCount: Int, + link: String) { + + self.name = name + self.time = time + self.utcOffset = utcOffset + self.rsvpCount = rsvpCount + self.link = link + } +} diff --git a/Tests/App/MeetupEventTests.swift b/Tests/App/MeetupEventTests.swift deleted file mode 100644 index 569602e..0000000 --- a/Tests/App/MeetupEventTests.swift +++ /dev/null @@ -1,144 +0,0 @@ -import XCTest -@testable import App -import Vapor -import Mustache - -// Equality for `MeetupEvent` so that arrays of them can be tested -func ==(lhs: MeetupEvent, rhs: MeetupEvent) -> Bool { - return (lhs.name == rhs.name && - lhs.time == rhs.time && - lhs.utcOffset == rhs.utcOffset && - lhs.rsvpCount == rhs.rsvpCount && - lhs.link == rhs.link) -} - -extension MeetupEvent: Equatable {} - -/** Test unpacking of JSON into arrays of `MeetupEvent`s */ -class UnpackEventTests: XCTestCase { - - let sampleObject: [String : JSONRepresentable] = - ["name" : "Discussion", - "time" : 1234, - "utc_offset" : 5678, - "yes_rsvp_count" : 9, - "link" : "http://example.com"] - let event = MeetupEvent(name: "Discussion", time: 1234, utcOffset: 5678, - rsvpCount: 9, link: "http://example.com") - - // Non-array JSON -> no results - func testNoUnpackOfNonarray() { - - var object = self.sampleObject - object.removeAll() - let json = JSON(object) - - XCTAssertThrowsError(try unpackMeetupEvents(fromJSON: json)) - } - - // Empty input -> empty output - func testEmptyArrayUnpacksEmpty() { - - let json = JSON([]) - - let unpacked = try! unpackMeetupEvents(fromJSON: json) - - XCTAssertTrue(unpacked.isEmpty) - } - - // Single malformed input -> empty output - func testMissingDataUnpacksEmpty() { - - var object = self.sampleObject - object.removeValue(forKey: "name") - let json = JSON([JSON(object)]) - - let unpacked = try! unpackMeetupEvents(fromJSON: json) - - XCTAssertTrue(unpacked.isEmpty) - } - - // Two good entries, one bad -> skip the bad - func testSomeGoodData() { - - var badObject = self.sampleObject - badObject.removeValue(forKey: "name") - let json = JSON([JSON(self.sampleObject), - JSON(badObject), - JSON(self.sampleObject)]) - let expected = [self.event, self.event] - - let unpacked = try! unpackMeetupEvents(fromJSON: json) - - XCTAssertEqual(unpacked, expected) - } - - // Good data successfully unpacks - func testGoodData() { - - let json = JSON([JSON(self.sampleObject), JSON(self.sampleObject)]) - let expected = [self.event, self.event] - - let unpacked = try! unpackMeetupEvents(fromJSON: json) - - XCTAssertEqual(unpacked, expected) - } -} - -/** Ensure `mustacheBox(forKey:)` returns expected values. */ -class EventMustacheBoxTests: XCTestCase { - - let event = MeetupEvent(name: "Discussion", time: 1234, utcOffset: 5678, - rsvpCount: 9, link: "http://example.com") - - func testMustacheBoxName() { - - let key = "name" - let expected = "Discussion" - - let boxValue = event.mustacheBox(forKey: key).value - - XCTAssertTrue((boxValue as? String) == expected) - } - - func testMustacheBoxTime() { - - let key = "time" - let expected = 1234 - - let boxValue = event.mustacheBox(forKey: key).value - - XCTAssertTrue((boxValue as? Int) == expected) - } - - func testMustacheBoxUtcOffset() { - - let key = "utcOffset" - let expected = 5678 - - let boxValue = event.mustacheBox(forKey: key).value - - XCTAssertTrue((boxValue as? Int) == expected) - } - - func testMustacheBoxRsvpCount() { - - let key = "rsvpCount" - let expected = 9 - - let boxValue = event.mustacheBox(forKey: key).value - - XCTAssertTrue((boxValue as? Int) == expected) - } - - func testMustacheBoxLink() { - - let key = "link" - let expected = "http://example.com" - - let boxValue = event.mustacheBox(forKey: key).value - - XCTAssertTrue((boxValue as? String) == expected) - } -} -