This repository has been archived by the owner. It is now read-only.
Permalink
Cannot retrieve contributors at this time
453 lines (373 sloc)
17.3 KB
| /* This Source Code Form is subject to the terms of the Mozilla Public | |
| * License, v. 2.0. If a copy of the MPL was not distributed with this | |
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | |
| import Foundation | |
| import Shared | |
| import GCDWebServers | |
| import SwiftyJSON | |
| @testable import Sync | |
| import XCTest | |
| private let log = Logger.syncLogger | |
| private func optTimestamp(x: AnyObject?) -> Timestamp? { | |
| guard let str = x as? String else { | |
| return nil | |
| } | |
| return decimalSecondsStringToTimestamp(str) | |
| } | |
| private func optStringArray(x: AnyObject?) -> [String]? { | |
| guard let str = x as? String else { | |
| return nil | |
| } | |
| return str.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } | |
| } | |
| private struct SyncRequestSpec { | |
| let collection: String | |
| let id: String? | |
| let ids: [String]? | |
| let limit: Int? | |
| let offset: String? | |
| let sort: SortOption? | |
| let newer: Timestamp? | |
| let full: Bool | |
| static func fromRequest(request: GCDWebServerRequest) -> SyncRequestSpec? { | |
| // Input is "/1.5/user/storage/collection", possibly with "/id" at the end. | |
| // That means we get five or six path components here, the first being empty. | |
| let parts = request.path!.components(separatedBy: "/").filter { !$0.isEmpty } | |
| let id: String? | |
| let query = request.query as! [String: AnyObject] | |
| let ids = optStringArray(x: query["ids"]) | |
| let newer = optTimestamp(x: query["newer"]) | |
| let full: Bool = query["full"] != nil | |
| let limit: Int? | |
| if let lim = query["limit"] as? String { | |
| limit = Int(lim) | |
| } else { | |
| limit = nil | |
| } | |
| let offset = query["offset"] as? String | |
| let sort: SortOption? | |
| switch query["sort"] as? String ?? "" { | |
| case "oldest": | |
| sort = SortOption.OldestFirst | |
| case "newest": | |
| sort = SortOption.NewestFirst | |
| case "index": | |
| sort = SortOption.Index | |
| default: | |
| sort = nil | |
| } | |
| if parts.count < 4 { | |
| return nil | |
| } | |
| if parts[2] != "storage" { | |
| return nil | |
| } | |
| // Use dropFirst, you say! It's buggy. | |
| switch parts.count { | |
| case 4: | |
| id = nil | |
| case 5: | |
| id = parts[4] | |
| default: | |
| // Uh oh. | |
| return nil | |
| } | |
| return SyncRequestSpec(collection: parts[3], id: id, ids: ids, limit: limit, offset: offset, sort: sort, newer: newer, full: full) | |
| } | |
| } | |
| struct SyncDeleteRequestSpec { | |
| let collection: String? | |
| let id: GUID? | |
| let ids: [GUID]? | |
| let wholeCollection: Bool | |
| static func fromRequest(request: GCDWebServerRequest) -> SyncDeleteRequestSpec? { | |
| // Input is "/1.5/user{/storage{/collection{/id}}}". | |
| // That means we get four, five, or six path components here, the first being empty. | |
| return SyncDeleteRequestSpec.fromPath(path: request.path!, withQuery: request.query as! [NSString : AnyObject]) | |
| } | |
| static func fromPath(path: String, withQuery query: [NSString: AnyObject]) -> SyncDeleteRequestSpec? { | |
| let parts = path.components(separatedBy: "/").filter { !$0.isEmpty } | |
| let queryIDs: [GUID]? = (query["ids"] as? String)?.components(separatedBy: ",") | |
| guard [2, 4, 5].contains(parts.count) else { | |
| return nil | |
| } | |
| if parts.count == 2 { | |
| return SyncDeleteRequestSpec(collection: nil, id: nil, ids: queryIDs, wholeCollection: true) | |
| } | |
| if parts[2] != "storage" { | |
| return nil | |
| } | |
| if parts.count == 4 { | |
| let hasIDs = queryIDs != nil | |
| return SyncDeleteRequestSpec(collection: parts[3], id: nil, ids: queryIDs, wholeCollection: !hasIDs) | |
| } | |
| return SyncDeleteRequestSpec(collection: parts[3], id: parts[4], ids: queryIDs, wholeCollection: false) | |
| } | |
| } | |
| private struct SyncPutRequestSpec { | |
| let collection: String | |
| let id: String | |
| static func fromRequest(request: GCDWebServerRequest) -> SyncPutRequestSpec? { | |
| // Input is "/1.5/user/storage/collection/id}}}". | |
| // That means we get six path components here, the first being empty. | |
| let parts = request.path!.components(separatedBy: "/").filter { !$0.isEmpty } | |
| guard parts.count == 5 else { | |
| return nil | |
| } | |
| if parts[2] != "storage" { | |
| return nil | |
| } | |
| return SyncPutRequestSpec(collection: parts[3], id: parts[4]) | |
| } | |
| } | |
| class MockSyncServer { | |
| let server = GCDWebServer() | |
| let username: String | |
| var offsets: Int = 0 | |
| var continuations: [String: [EnvelopeJSON]] = [:] | |
| var collections: [String: (modified: Timestamp, records: [String: EnvelopeJSON])] = [:] | |
| var baseURL: String! | |
| init(username: String) { | |
| self.username = username | |
| } | |
| class func makeValidEnvelope(guid: GUID, modified: Timestamp) -> EnvelopeJSON { | |
| let clientBody: [String: Any] = [ | |
| "id": guid, | |
| "name": "Foobar", | |
| "commands": [], | |
| "type": "mobile", | |
| ] | |
| let clientBodyString = JSON(clientBody).stringValue()! | |
| let clientRecord: [String : Any] = [ | |
| "id": guid, | |
| "collection": "clients", | |
| "payload": clientBodyString, | |
| "modified": Double(modified) / 1000, | |
| ] | |
| return EnvelopeJSON(JSON(clientRecord).stringValue()!) | |
| } | |
| class func withHeaders(response: GCDWebServerResponse, lastModified: Timestamp? = nil, records: Int? = nil, timestamp: Timestamp? = nil) -> GCDWebServerResponse { | |
| let timestamp = timestamp ?? Date.now() | |
| let xWeaveTimestamp = millisecondsToDecimalSeconds(timestamp) | |
| response.setValue("\(xWeaveTimestamp)", forAdditionalHeader: "X-Weave-Timestamp") | |
| if let lastModified = lastModified { | |
| let xLastModified = millisecondsToDecimalSeconds(lastModified) | |
| response.setValue("\(xLastModified)", forAdditionalHeader: "X-Last-Modified") | |
| } | |
| if let records = records { | |
| response.setValue("\(records)", forAdditionalHeader: "X-Weave-Records") | |
| } | |
| return response | |
| } | |
| func storeRecords(records: [EnvelopeJSON], inCollection collection: String, now: Timestamp? = nil) { | |
| let now = now ?? Date.now() | |
| let coll = self.collections[collection] | |
| var out = coll?.records ?? [:] | |
| records.forEach { | |
| out[$0.id] = $0.withModified(now) | |
| } | |
| let newModified = max(now, coll?.modified ?? 0) | |
| self.collections[collection] = (modified: newModified, records: out) | |
| } | |
| private func splitArray<T>(items: [T], at: Int) -> ([T], [T]) { | |
| return (Array(items.dropLast(items.count - at)), Array(items.dropFirst(at))) | |
| } | |
| private func recordsMatchingSpec(spec: SyncRequestSpec) -> (records: [EnvelopeJSON], offsetID: String?)? { | |
| // If we have a provided offset, handle that directly. | |
| if let offset = spec.offset { | |
| log.debug("Got provided offset \(offset).") | |
| guard let remainder = self.continuations[offset] else { | |
| log.error("Unknown offset.") | |
| return nil | |
| } | |
| // Remove the old one. | |
| self.continuations.removeValue(forKey: offset) | |
| // Handle the smaller-than-limit or no-provided-limit cases. | |
| guard let limit = spec.limit, limit < remainder.count else { | |
| log.debug("Returning all remaining items.") | |
| return (remainder, nil) | |
| } | |
| // Record the next continuation and return the first slice of records. | |
| let next = "\(self.offsets)" | |
| self.offsets += 1 | |
| let (returned, remaining) = splitArray(items: remainder, at: limit) | |
| self.continuations[next] = remaining | |
| log.debug("Returning \(limit) items; next continuation is \(next).") | |
| return (returned, next) | |
| } | |
| guard let records = self.collections[spec.collection]?.records.values else { | |
| // No matching records. | |
| return ([], nil) | |
| } | |
| var items = Array(records) | |
| log.debug("Got \(items.count) candidate records.") | |
| if spec.newer ?? 0 > 0 { | |
| items = items.filter { $0.modified > spec.newer! } | |
| } | |
| if let ids = spec.ids { | |
| let ids = Set(ids) | |
| items = items.filter { ids.contains($0.id) } | |
| } | |
| if let sort = spec.sort { | |
| switch sort { | |
| case SortOption.NewestFirst: | |
| items = items.sorted { $0.modified > $1.modified } | |
| log.debug("Sorted items newest first: \(items.map { $0.modified })") | |
| case SortOption.OldestFirst: | |
| items = items.sorted { $0.modified < $1.modified } | |
| log.debug("Sorted items oldest first: \(items.map { $0.modified })") | |
| case SortOption.Index: | |
| log.warning("Index sorting not yet supported.") | |
| } | |
| } | |
| if let limit = spec.limit, items.count > limit { | |
| let next = "\(self.offsets)" | |
| self.offsets += 1 | |
| let (returned, remaining) = splitArray(items: items, at: limit) | |
| self.continuations[next] = remaining | |
| return (returned, next) | |
| } | |
| return (items, nil) | |
| } | |
| private func recordResponse(record: EnvelopeJSON) -> GCDWebServerResponse { | |
| let body = record.asJSON().stringValue()! | |
| let bodyData = body.utf8EncodedData | |
| let response = GCDWebServerDataResponse(data: bodyData, contentType: "application/json") | |
| return MockSyncServer.withHeaders(response: response!, lastModified: record.modified) | |
| } | |
| private func modifiedResponse(timestamp: Timestamp) -> GCDWebServerResponse { | |
| let body = JSON(["modified": timestamp]).stringValue() | |
| let bodyData = body?.utf8EncodedData | |
| let response = GCDWebServerDataResponse(data: bodyData, contentType: "application/json")! | |
| return MockSyncServer.withHeaders(response: response) | |
| } | |
| func modifiedTimeForCollection(collection: String) -> Timestamp? { | |
| return self.collections[collection]?.modified | |
| } | |
| func removeAllItemsFromCollection(collection: String, atTime: Timestamp) { | |
| if self.collections[collection] != nil { | |
| self.collections[collection] = (atTime, [:]) | |
| } | |
| } | |
| func start() { | |
| let basePath = "/1.5/\(self.username)" | |
| let storagePath = "\(basePath)/storage/" | |
| let infoCollectionsPath = "\(basePath)/info/collections" | |
| server?.addHandler(forMethod: "GET", path: infoCollectionsPath, request: GCDWebServerRequest.self) { (request) -> GCDWebServerResponse! in | |
| var ic = [String: Any]() | |
| var lastModified: Timestamp = 0 | |
| for collection in self.collections.keys { | |
| if let timestamp = self.modifiedTimeForCollection(collection: collection) { | |
| ic[collection] = Double(timestamp) / 1000 | |
| lastModified = max(lastModified, timestamp) | |
| } | |
| } | |
| let body = JSON(ic).stringValue() | |
| let bodyData = body?.utf8EncodedData | |
| let response = GCDWebServerDataResponse(data: bodyData, contentType: "application/json")! | |
| return MockSyncServer.withHeaders(response: response, lastModified: lastModified, records: ic.count) | |
| } | |
| let matchPut: GCDWebServerMatchBlock = { method, url, headers, path, query -> GCDWebServerRequest! in | |
| guard method == "PUT", | |
| path?.hasPrefix(basePath) ?? false else { | |
| return nil | |
| } | |
| return GCDWebServerDataRequest(method: method, url: url, headers: headers, path: path, query: query) | |
| } | |
| server?.addHandler(match: matchPut) { (request) -> GCDWebServerResponse! in | |
| guard let request = request as? GCDWebServerDataRequest else { | |
| return MockSyncServer.withHeaders(response: GCDWebServerDataResponse(statusCode: 400)) | |
| } | |
| guard let spec = SyncPutRequestSpec.fromRequest(request: request) else { | |
| return MockSyncServer.withHeaders(response: GCDWebServerDataResponse(statusCode: 400)) | |
| } | |
| var body = JSON(request.jsonObject) | |
| body["modified"] = JSON(stringLiteral: millisecondsToDecimalSeconds(Date.now())) | |
| let record = EnvelopeJSON(body) | |
| self.storeRecords(records: [record], inCollection: spec.collection) | |
| let timestamp = self.modifiedTimeForCollection(collection: spec.collection)! | |
| let response = GCDWebServerDataResponse(data: millisecondsToDecimalSeconds(timestamp).utf8EncodedData, contentType: "application/json") | |
| return MockSyncServer.withHeaders(response: response!) | |
| } | |
| let matchDelete: GCDWebServerMatchBlock = { method, url, headers, path, query -> GCDWebServerRequest! in | |
| guard method == "DELETE" && (path?.hasPrefix(basePath))! else { | |
| return nil | |
| } | |
| return GCDWebServerRequest(method: method, url: url, headers: headers, path: path, query: query) | |
| } | |
| server?.addHandler(match: matchDelete) { (request) -> GCDWebServerResponse! in | |
| guard let spec = SyncDeleteRequestSpec.fromRequest(request: request!) else { | |
| return GCDWebServerDataResponse(statusCode: 400) | |
| } | |
| if let collection = spec.collection, let id = spec.id { | |
| guard var items = self.collections[collection]?.records else { | |
| // Unable to find the requested collection. | |
| return MockSyncServer.withHeaders(response: GCDWebServerDataResponse(statusCode: 404)) | |
| } | |
| guard let item = items[id] else { | |
| // Unable to find the requested id. | |
| return MockSyncServer.withHeaders(response: GCDWebServerDataResponse(statusCode: 404)) | |
| } | |
| items.removeValue(forKey: id) | |
| return self.modifiedResponse(timestamp: item.modified) | |
| } | |
| if let collection = spec.collection { | |
| if spec.wholeCollection { | |
| self.collections.removeValue(forKey: collection) | |
| } else { | |
| if let ids = spec.ids, | |
| var map = self.collections[collection]?.records { | |
| for id in ids { | |
| map.removeValue(forKey: id) | |
| } | |
| self.collections[collection] = (Date.now(), records: map) | |
| } | |
| } | |
| return self.modifiedResponse(timestamp: Date.now()) | |
| } | |
| self.collections = [:] | |
| return MockSyncServer.withHeaders(response: GCDWebServerDataResponse(data: "{}".utf8EncodedData, contentType: "application/json")) | |
| } | |
| let match: GCDWebServerMatchBlock = { method, url, headers, path, query -> GCDWebServerRequest! in | |
| guard method == "GET", path?.hasPrefix(storagePath) ?? false else { | |
| return nil | |
| } | |
| return GCDWebServerRequest(method: method, url: url, headers: headers, path: path, query: query) | |
| } | |
| server?.addHandler(match: match) { (request) -> GCDWebServerResponse! in | |
| // 1. Decide what the URL is asking for. It might be a collection fetch or | |
| // an individual record, and it might have query parameters. | |
| guard let spec = SyncRequestSpec.fromRequest(request: request!) else { | |
| return MockSyncServer.withHeaders(response: GCDWebServerDataResponse(statusCode: 400)) | |
| } | |
| // 2. Grab the matching set of records. Prune based on TTL, exclude with X-I-U-S, etc. | |
| if let id = spec.id { | |
| guard let collection = self.collections[spec.collection], let record = collection.records[id] else { | |
| // Unable to find the requested collection/id. | |
| return MockSyncServer.withHeaders(response: GCDWebServerDataResponse(statusCode: 404)) | |
| } | |
| return self.recordResponse(record: record) | |
| } | |
| guard let (items, offset) = self.recordsMatchingSpec(spec: spec) else { | |
| // Unable to find the provided offset. | |
| return MockSyncServer.withHeaders(response: GCDWebServerDataResponse(statusCode: 400)) | |
| } | |
| // TODO: TTL | |
| // TODO: X-I-U-S | |
| let body = JSON(items.map { $0.asJSON() }).stringValue() | |
| let bodyData = body?.utf8EncodedData | |
| let response = GCDWebServerDataResponse(data: bodyData, contentType: "application/json") | |
| // 3. Compute the correct set of headers: timestamps, X-Weave-Records, etc. | |
| if let offset = offset { | |
| response?.setValue(offset, forAdditionalHeader: "X-Weave-Next-Offset") | |
| } | |
| let timestamp = self.modifiedTimeForCollection(collection: spec.collection)! | |
| log.debug("Returning GET response with X-Last-Modified for \(items.count) records: \(timestamp).") | |
| return MockSyncServer.withHeaders(response: response!, lastModified: timestamp, records: items.count) | |
| } | |
| if server?.start(withPort: 0, bonjourName: nil) == false { | |
| XCTFail("Can't start the GCDWebServer.") | |
| } | |
| baseURL = "http://localhost:\(server!.port)\(basePath)" | |
| } | |
| } |