/* 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:NSCharacterSet.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(object: clientBody).stringValue()! let clientRecord: [String : Any] = [ "id": guid, "collection": "clients", "payload": clientBodyString, "modified": Double(modified) / 1000, ] return EnvelopeJSON(JSON(object: 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(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(object: ["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(object: 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?.startsWith(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(object: 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?.startsWith(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?.startsWith(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(object: 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)" } }