diff --git a/Package.resolved b/Package.resolved index faf258b..4244270 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,42 @@ "version" : "1.24.0" } }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31", + "version" : "1.20.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", + "version" : "4.15.2" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "8c9a227476555c55837e569be71944e02a056b72", + "version" : "4.9.1" + } + }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", @@ -45,6 +81,15 @@ "version" : "2.6.0" } }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "a64a0abc2530f767af15dd88dda7f64d5f1ff9de", + "version" : "1.2.0" + } + }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", @@ -63,6 +108,15 @@ "version" : "1.6.2" } }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "5e63558d12e0267782019f5dadfcae83a7d06e09", + "version" : "2.5.1" + } + }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", @@ -117,6 +171,15 @@ "version" : "1.0.2" } }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "0c62c5b4601d6c125050b5c3a97f20cce881d32b", + "version" : "1.1.0" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", @@ -125,6 +188,24 @@ "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", "version" : "1.4.0" } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "8589cb562feab069f2563bdcdeb8f9608a07a2c7", + "version" : "4.112.0" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "4232d34efa49f633ba61afde365d3896fc7f8740", + "version" : "2.15.0" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index d7141df..eea7c9a 100644 --- a/Package.swift +++ b/Package.swift @@ -30,10 +30,23 @@ let package = Package( "HaystackClientNIO" ] ), + .library( + name: "HaystackServer", + targets: [ + "HaystackServer" + ] + ), + .library( + name: "HaystackServerVapor", + targets: [ + "HaystackServerVapor" + ] + ), ], dependencies: [ .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.9.0") + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.9.0"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), ], targets: [ .target( @@ -62,7 +75,21 @@ let package = Package( .product(name: "AsyncHTTPClient", package: "async-http-client"), ] ), - + .target( + name: "HaystackServer", + dependencies: [ + "Haystack", + ] + ), + .target( + name: "HaystackServerVapor", + dependencies: [ + "Haystack", + "HaystackServer", + .product(name: "Vapor", package: "vapor") + ] + ), + // Tests .testTarget( name: "HaystackTests", @@ -80,6 +107,14 @@ let package = Package( name: "HaystackClientDarwinIntegrationTests", dependencies: ["HaystackClientDarwin"] ), + .testTarget( + name: "HaystackServerTests", + dependencies: ["HaystackServer"] + ), + .testTarget( + name: "HaystackServerVaporTests", + dependencies: ["HaystackServerVapor", .product(name: "XCTVapor", package: "vapor")] + ), ] ) #else @@ -97,10 +132,23 @@ let package = Package( "HaystackClientNIO" ] ), + .library( + name: "HaystackServer", + targets: [ + "HaystackServer" + ] + ), + .library( + name: "HaystackServerVapor", + targets: [ + "HaystackServerVapor" + ] + ), ], dependencies: [ .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.9.0") + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.9.0"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), ], targets: [ .target( @@ -122,7 +170,21 @@ let package = Package( .product(name: "AsyncHTTPClient", package: "async-http-client"), ] ), - + .target( + name: "HaystackServer", + dependencies: [ + "Haystack" + ] + ), + .target( + name: "HaystackServerVapor", + dependencies: [ + "Haystack", + "HaystackServer", + .product(name: "Vapor", package: "vapor") + ] + ), + // Tests .testTarget( name: "HaystackTests", @@ -136,6 +198,14 @@ let package = Package( name: "HaystackClientNIOIntegrationTests", dependencies: ["HaystackClientNIO"] ), + .testTarget( + name: "HaystackServerTests", + dependencies: ["HaystackServer"] + ), + .testTarget( + name: "HaystackServerVaporTests", + dependencies: ["HaystackServerVapor", .product(name: "XCTVapor", package: "vapor")] + ), ] ) -#endif \ No newline at end of file +#endif diff --git a/README.md b/README.md index 9c20764..bc336c0 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ See below for available libraries and descriptions. ### Haystack -This contains the +This contains the [Haystack type-system primitives](https://project-haystack.org/doc/docHaystack/Kinds) and utilities to interact with them. @@ -101,20 +101,55 @@ Once you create a client, you can use it to make requests: ```swift func yesterdaysValues() async throws -> Grid { let client = ... - + // Open and authenticate. This must be called before requests can be made try await client.open() - + // Request the historical values for @28e7fb7d-e20316e0 let grid = try await client.hisRead(id: Ref("28e7fb7d-e20316e0"), range: .yesterday) - + // Close the client session and log out try await client.close() - + return grid } ``` +### HaystackServerVapor + +A server for the [Haystack HTTP API](https://project-haystack.org/doc/docHaystack/HttpApi) that uses +[Vapor](https://github.com/vapor/vapor). It's separated from the `HaystackServer` package so that clients may use +alternative server technologies if they choose, including Hummingbird or NIO directly. + +It exposes a `HaystackRouteCollection` that can be registered on a Vapor +application: + +```swift +let app = Application() +try app.register(collection: HaystackRouteCollection(delegate: ...)) +``` + +The delegate is a protocol that can be implemented however the user sees fit, although the standard Haystack +implementation is defined in `HaystackServer`. + +### HaystackServer + +This defines the standard functionality and data processing of Haystack API servers, based on generic backing data +stores. In most cases, Haystack servers should use the `HaystackServer` class and customize storage behavior by +implementing the `RecordStore`, `HistoryStore`, and `WatchStore` protocols. + +```swift +struct InfluxHistoryStore: HistoryStore { + // Define storage behavior here + ... +} + +let server = HaystackServer( + historyStore: InfluxHistoryStore(), + ... +) +``` + ## License This package is licensed under the Academic Free License 3.0 for maximum compatibility with diff --git a/Sources/Haystack/API/API.swift b/Sources/Haystack/API/API.swift new file mode 100644 index 0000000..34ff734 --- /dev/null +++ b/Sources/Haystack/API/API.swift @@ -0,0 +1,188 @@ +public protocol API { + + /// Closes the current authentication session. + /// + /// https://project-haystack.org/doc/docHaystack/Ops#close + func close() async throws -> Void + + /// Queries basic information about the server + /// + /// https://project-haystack.org/doc/docHaystack/Ops#about + func about() async throws -> Grid + + /// Queries def dicts from the current namespace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#defs + /// + /// - Parameters: + /// - filter: A string filter + /// - limit: The maximum number of defs to return in response + /// - Returns: A grid with the dict representation of each def + func defs(filter: String?, limit: Number?) async throws -> Grid + + /// Queries lib defs from current namspace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#libs + /// + /// - Parameters: + /// - filter: A string filter + /// - limit: The maximum number of defs to return in response + /// - Returns: A grid with the dict representation of each def + func libs(filter: String?, limit: Number?) async throws -> Grid + + /// Queries op defs from current namspace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#ops + /// + /// - Parameters: + /// - filter: A string filter + /// - limit: The maximum number of defs to return in response + /// - Returns: A grid with the dict representation of each def + func ops(filter: String?, limit: Number?) async throws -> Grid + + /// Queries filetype defs from current namspace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#filetypes + /// + /// - Parameters: + /// - filter: A string filter + /// - limit: The maximum number of defs to return in response + /// - Returns: A grid with the dict representation of each def + func filetypes(filter: String?, limit: Number?) async throws -> Grid + + /// Read a set of entity records by their unique identifier + /// + /// https://project-haystack.org/doc/docHaystack/Ops#read + /// + /// - Parameter ids: Ref identifiers + /// - Returns: A grid with a row for each entity read + func read(ids: [Ref]) async throws -> Grid + + /// Read a set of entity records using a filter + /// + /// https://project-haystack.org/doc/docHaystack/Ops#read + /// + /// - Parameters: + /// - filter: A string filter + /// - limit: The maximum number of entities to return in response + /// - Returns: A grid with a row for each entity read + func read(filter: String, limit: Number?) async throws -> Grid + + /// Navigate a project for learning and discovery + /// + /// https://project-haystack.org/doc/docHaystack/Ops#nav + /// + /// - Parameter navId: The ID of the entity to navigate from. If null, the navigation root is used. + /// - Returns: A grid of navigation children for the navId specified by the request + func nav(navId: Ref?) async throws -> Grid + + /// Reads time-series data from historized point + /// + /// https://project-haystack.org/doc/docHaystack/Ops#hisRead + /// + /// - Parameters: + /// - id: Identifier of historized point + /// - range: A date-time range + /// - Returns: A grid whose rows represent timetamp/value pairs with a DateTime ts column and a val column for each scalar value + func hisRead(id: Ref, range: HisReadRange) async throws -> Grid + + /// Posts new time-series data to a historized point + /// + /// https://project-haystack.org/doc/docHaystack/Ops#hisWrite + /// + /// - Parameters: + /// - id: The identifier of the point to write to + /// - items: New timestamp/value samples to write + /// - Returns: An empty grid + func hisWrite(id: Ref, items: [HisItem]) async throws -> Grid + + /// Write to a given level of a writable point's priority array + /// + /// https://project-haystack.org/doc/docHaystack/Ops#pointWrite + /// + /// - Parameters: + /// - id: Identifier of writable point + /// - level: Number from 1-17 for level to write + /// - val: Value to write or null to auto the level + /// - who: Username/application name performing the write, otherwise authenticated user display name is used + /// - duration: Number with duration unit if setting level 8 + /// - Returns: An empty grid + func pointWrite(id: Ref, level: Number, val: any Val, who: String?, duration: Number?) async throws -> Grid + + /// Read the current status of a writable point's priority array + /// + /// https://project-haystack.org/doc/docHaystack/Ops#pointWrite + /// + /// - Parameter id: Identifier of writable point + /// - Returns: A grid with current priority array state + func pointWriteStatus(id: Ref) async throws -> Grid + + /// Used to create new watches. + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchSub + /// + /// - Parameters: + /// - watchDis: Debug/display string + /// - lease: Number with duration unit for desired lease period + /// - ids: The identifiers of the entities to subscribe to + /// - Returns: A grid where rows correspond to the current entity state of the requested identifiers. Grid metadata contains + /// `watchId` and `lease`. + func watchSubCreate(watchDis: String, lease: Number?, ids: [Ref]) async throws -> Grid + + /// Used to add entities to an existing watch. + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchSub + /// + /// - Parameters: + /// - watchId: Debug/display string + /// - lease: Number with duration unit for desired lease period + /// - ids: The identifiers of the entities to subscribe to + /// - Returns: A grid where rows correspond to the current entity state of the requested identifiers. Grid metadata contains + /// `watchId` and `lease`. + func watchSubAdd(watchId: String, lease: Number?, ids: [Ref]) async throws -> Grid + + /// Used remove entities from a watch + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchUnsub + /// + /// - Parameters: + /// - watchId: Watch identifier + /// - ids: Ref values for each entity to unsubscribe. If empty the entire watch is closed. + /// - Returns: An empty grid + func watchUnsubRemove(watchId: String, ids: [Ref]) async throws -> Grid + + /// Used to close a watch entirely + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchUnsub + /// + /// - Parameters: + /// - watchId: Watch identifier + /// - Returns: An empty grid + func watchUnsubDelete(watchId: String) async throws -> Grid + + /// Used to poll a watch for changes to the subscribed entity records + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchPoll + /// + /// - Parameters: + /// - watchId: Watch identifier + /// - refresh: Whether a full refresh should occur + /// - Returns: A grid where each row correspondes to a watched entity + func watchPoll(watchId: String, refresh: Bool) async throws -> Grid + + /// https://project-haystack.org/doc/docHaystack/Ops#invokeAction + /// - Parameters: + /// - id: Identifier of target rec + /// - action: The name of the action func + /// - args: The arguments to the action + /// - Returns: A grid of undefined shape + func invokeAction(id: Ref, action: String, args: [String: any Val]) async throws -> Grid + + /// Evaluate an Axon expression + /// + /// https://haxall.io/doc/lib-hx/op~eval + /// + /// - Parameter expression: A string Axon expression + /// - Returns: A grid of undefined shape + func eval(expression: String) async throws -> Grid +} diff --git a/Sources/HaystackClient/HisItem.swift b/Sources/Haystack/API/HisItem.swift similarity index 52% rename from Sources/HaystackClient/HisItem.swift rename to Sources/Haystack/API/HisItem.swift index c4ab4a3..ef73b9f 100644 --- a/Sources/HaystackClient/HisItem.swift +++ b/Sources/Haystack/API/HisItem.swift @@ -1,12 +1,14 @@ -import Haystack - /// A timestamp/value pair. public struct HisItem { - let ts: DateTime - let val: any Val + public let ts: DateTime + public let val: any Val public init(ts: DateTime, val: any Val) { self.ts = ts self.val = val } + + public func toDict() -> Dict { + return ["ts": ts, "val": val] + } } diff --git a/Sources/Haystack/API/HisReadRange.swift b/Sources/Haystack/API/HisReadRange.swift new file mode 100644 index 0000000..cb55cd1 --- /dev/null +++ b/Sources/Haystack/API/HisReadRange.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Query-able DateTime ranges, which support relative and absolute values. +public enum HisReadRange { + case today + case yesterday + case date(Haystack.Date) + case dateRange(from: Haystack.Date, to: Haystack.Date) + case dateTimeRange(from: DateTime, to: DateTime) + case after(DateTime) + + public func start() -> Foundation.Date? { + switch self { + case .today: + return Calendar.current.startOfDay(for: Foundation.Date()) + case .yesterday: + return Calendar.current.date(byAdding: .day, value: -1, to: Calendar.current.startOfDay(for: Foundation.Date()))! + case let .date(date): + return date.startOfDay(timezone: nil) + case let .dateRange(from, _): + return from.startOfDay(timezone: nil) + case let .dateTimeRange(from, _): + return from.date + case let .after(from): + return from.date + } + } + + public func end() -> Foundation.Date? { + switch self { + case .today: + return Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: Foundation.Date()))! + case .yesterday: + return Calendar.current.startOfDay(for: Foundation.Date()) + case let .date(date): + return date.endOfDay(timezone: nil) + case let .dateRange(_, to): + return to.endOfDay(timezone: nil) + case let .dateTimeRange(_, to): + return to.date + case .after(_): + return nil + } + } + + public static func fromZinc(_ str: String) throws -> HisReadRange { + if str == "today" { + return .today + } + if str == "yesterday" { + return .yesterday + } + if str.contains(",") { + let split = str.split(separator: ",") + let fromStr = String(split[0]) + let fromVal = try? ZincReader(fromStr).readVal() + let toStr = String(split[1]) + let toVal = try? ZincReader(toStr).readVal() + + switch fromVal { + case let fromDate as Haystack.Date: + switch toVal { + case let toDate as Haystack.Date: + return .dateRange(from: fromDate, to: toDate) + default: + throw HisReadRangeError.fromAndToDontMatch(fromStr, toStr) + } + case let fromDateTime as DateTime: + switch toVal { + case let toDateTime as DateTime: + return .dateTimeRange(from: fromDateTime, to: toDateTime) + default: + throw HisReadRangeError.fromAndToDontMatch(fromStr, toStr) + } + default: + throw HisReadRangeError.formatNotRecognized(str) + } + } + let val = try? ZincReader(str).readVal() + switch val { + case let date as Haystack.Date: + return .date(date) + case let dateTime as DateTime: + return .after(dateTime) + default: + throw HisReadRangeError.formatNotRecognized(str) + } + } + + public func toZinc() throws -> String { + switch self { + case .today: + return "today" + case .yesterday: + return "yesterday" + case let .date(date): + return date.toZinc() + case let .dateRange(from, to): + return "\(from.toZinc()),\(to.toZinc())" + case let .dateTimeRange(from, to): + return "\(from.toZinc()),\(to.toZinc())" + case let .after(dateTime): + return dateTime.toZinc() + } + } +} + +enum HisReadRangeError: Error { + case fromAndToDontMatch(String, String) + case formatNotRecognized(String) +} diff --git a/Sources/Haystack/Date.swift b/Sources/Haystack/Date.swift index 21bd862..af17f0e 100644 --- a/Sources/Haystack/Date.swift +++ b/Sources/Haystack/Date.swift @@ -47,6 +47,20 @@ public struct Date: Val { ) } + public func startOfDay(timezone: TimeZone?) -> Foundation.Date { + return DateComponents( + calendar: .current, + timeZone: timezone ?? .current, + year: year, + month: month, + day: day + ).date! + } + + public func endOfDay(timezone: TimeZone?) -> Foundation.Date { + return Calendar.current.date(byAdding: .day, value: 1, to: startOfDay(timezone: timezone))! + } + /// Converts to Zinc formatted string. /// See [Zinc Literals](https://project-haystack.org/doc/docHaystack/Zinc#literals) public func toZinc() -> String { diff --git a/Sources/Haystack/DateTime.swift b/Sources/Haystack/DateTime.swift index d84da15..8105f4c 100644 --- a/Sources/Haystack/DateTime.swift +++ b/Sources/Haystack/DateTime.swift @@ -262,3 +262,14 @@ extension DateTime { } } } + +// DateTime + Comparable +extension DateTime: Comparable { + public static func < (lhs: DateTime, rhs: DateTime) -> Bool { + return lhs.date < rhs.date + } + + public static func == (lhs: DateTime, rhs: DateTime) -> Bool { + return lhs.date == rhs.date + } +} diff --git a/Sources/Haystack/Dict.swift b/Sources/Haystack/Dict.swift index 7df0a41..836ad7c 100644 --- a/Sources/Haystack/Dict.swift +++ b/Sources/Haystack/Dict.swift @@ -14,7 +14,7 @@ public struct Dict: Val { return Dict([:]) } - public let elements: [String: any Val] + public private(set) var elements: [String: any Val] public init(_ elements: [String: any Val]) { self.elements = elements @@ -48,6 +48,10 @@ public struct Dict: Val { return try fieldVal.coerce(to: T.self) } + public func has(_ name: String) -> Bool { + return self.elements.keys.contains(name) + } + /// Converts to Zinc formatted string. /// See [Zinc Literals](https://project-haystack.org/doc/docHaystack/Zinc#literals) public func toZinc() -> String { @@ -185,6 +189,41 @@ extension Dict { } } +// Dict + Collection +extension Dict: Collection { + public var startIndex: Dictionary.Index { + elements.keys.startIndex + } + + public var endIndex: Dictionary.Index { + elements.keys.endIndex + } + + public subscript(position: Dictionary.Index) -> (key: String, value: any Val) { + return elements[position] + } + + public func index(after i: Dictionary.Index) -> Dictionary.Index { + return elements.index(after: i) + } +} + +// Convenience string accessor +extension Dict { + public subscript(key: String) -> (any Val)? { + get { + let val = elements[key] + guard !(val is Null) else { + return nil + } + return val + } + set { + elements[key] = newValue + } + } +} + extension Dict: ExpressibleByDictionaryLiteral { /// Creates an instance initialized with the given key-value pairs. public init(dictionaryLiteral elementLiterals: (String, any Val)...) { diff --git a/Sources/Haystack/Filter.swift b/Sources/Haystack/Filter.swift new file mode 100644 index 0000000..9a55a10 --- /dev/null +++ b/Sources/Haystack/Filter.swift @@ -0,0 +1,655 @@ + +/** + * Filter models a parsed tag query string. + * + * @see Project Haystack + */ +public protocol Filter: Hashable, CustomStringConvertible { + /* Return if given tags entity matches this query. */ + func include(dict: Dict, pather: Pather?) throws -> Bool +} + +public extension Filter { + /** Hash code is based on string encoding */ + func hash(into hasher: inout Hasher) { + hasher.combine(description) + } + + /** Equality is based on string encoding */ + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.description == rhs.description + } + + /// Existential comparison. This is required because Swift equality requires comparing concrete types. + /// Will only return true if filter types and input strings match. + func equals(_ that: any Filter) -> Bool { + return type(of: self) == type(of: that) && description == that.description + } + + /** + * Return a query which is the logical-and of this and that query. + */ + func and(_ that: any Filter) -> any Filter { + return And(a: self, b: that) + } + + /** + * Return a query which is the logical-or of this and that query. + */ + func or(_ that: any Filter) -> any Filter { + return Or(a: self, b: that) + } +} + +// Static method container for constructing concrete filters. +public enum FilterFactory { + /// Decode a string into a Filter + public static func make(_ s: String) throws -> any Filter { + return try FilterParser(in: s).parse() + } + + /** + * Match records which have the specified tag path defined. + */ + public static func has(_ path: String) throws -> any Filter { + return try Has(path: Path.make(path: path)) + } + + /** + * Match records which do not define the specified tag path. + */ + public static func missing(_ path: String) throws -> any Filter { + return try Missing(path: Path.make(path: path)) + } + + /** + * Match records which have a tag are equal to the specified value. + * If the path is not defined then it is unmatched. + */ + public static func eq(_ path: String, _ val: any Val) throws -> any Filter { + return try Eq(val: val, path: Path.make(path: path)) + } + + /** + * Match records which have a tag not equal to the specified value. + * If the path is not defined then it is unmatched. + */ + public static func ne(_ path: String, _ val: any Val) throws -> any Filter { + return try Ne(val: val, path: Path.make(path: path)) + } + + /** + * Match records which have tags less than the specified value. + * If the path is not defined then it is unmatched. + */ + public static func lt(_ path: String, _ val: any Val) throws -> any Filter { + return try Lt(val: val, path: Path.make(path: path)) + } + + /** + * Match records which have tags less than or equals to specified value. + * If the path is not defined then it is unmatched. + */ + public static func le(_ path: String, _ val: any Val) throws -> any Filter { + return try Le(val: val, path: Path.make(path: path)) + } + + /** + * Match records which have tags greater than specified value. + * If the path is not defined then it is unmatched. + */ + public static func gt(_ path: String, _ val: any Val) throws -> any Filter { + return try Gt(val: val, path: Path.make(path: path)) + } + + /** + * Match records which have tags greater than or equal to specified value. + * If the path is not defined then it is unmatched. + */ + public static func ge(_ path: String, _ val: any Val) throws -> any Filter { + return try Ge(val: val, path: Path.make(path: path)) + } +} + +/** Pather is a callback interface used to resolve query paths. + * + * Given a Ref string identifier, resolve to an entity's + * Dict respresentation or ref is not found return null. + */ +public typealias Pather = (String) -> Dict? + +protocol PathFilter: Filter { + var path: Path { get } + func doInclude(val: any Val) -> Bool +} + +/// This is used to from PathFilter `include` methods to wrap the `doInclude` definition. +/// +/// It's done this way because concrete types don't inherit conformance defined on the protocol, and classes don't support abstract methods. +func pathFilterInclude(pathFilter: any PathFilter, dict: Dict, pather: Pather?) throws -> Bool { + var val = try dict.get(pathFilter.path[0]) ?? null + if pathFilter.path.count != 1 { + if let pather = pather { + var nt: Dict? = dict + for i in 1 ..< pathFilter.path.count { + if let val = val as? Dict { + nt = val + } else if let val = val as? Ref { + nt = pather(val.val) + } else { + val = null + break + } + val = try nt?.get(pathFilter.path[i]) ?? null + } + } else { + val = null + } + } + return pathFilter.doInclude(val: val) +} + +class Has: PathFilter { + var path: Path + + init(path: Path) { + self.path = path + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try pathFilterInclude(pathFilter: self, dict: dict, pather: pather) + } + + func doInclude(val: any Val) -> Bool { + return !(val is Null) + } + + var description: String { + return path.description + } +} + +class Missing: PathFilter { + var path: Path + + init(path: Path) { + self.path = path + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try pathFilterInclude(pathFilter: self, dict: dict, pather: pather) + } + + func doInclude(val: any Val) -> Bool { + return val is Null + } + + var description: String { + return "not " + path.description + } +} + +protocol CmpFilter: PathFilter { + var val: any Val { get } + func cmpStr() -> String +} + +extension CmpFilter { + var description: String { + var s = "" + s.append(path.description) + s.append(cmpStr()) + s.append(val.toZinc()) + return s + } + + func sameType(val: any Val) -> Bool { + return !(val is Null) && type(of: val).valType == type(of: self.val).valType + } +} + +class Eq: CmpFilter { + var val: any Val + var path: Path + + init(val: any Val, path: Path) { + self.val = val + self.path = path + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try pathFilterInclude(pathFilter: self, dict: dict, pather: pather) + } + + func cmpStr() -> String { + return "==" + } + + func doInclude(val: any Val) -> Bool { + return !(val is Null) && val.equals(self.val) + } +} + +class Ne: CmpFilter { + var val: any Val + var path: Path + + init(val: any Val, path: Path) { + self.val = val + self.path = path + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try pathFilterInclude(pathFilter: self, dict: dict, pather: pather) + } + + func cmpStr() -> String { + return "!=" + } + + func doInclude(val: any Val) -> Bool { + return !(val is Null) && !val.equals(self.val) + } +} + +class Lt: CmpFilter { + var val: any Val + var path: Path + + init(val: any Val, path: Path) { + self.val = val + self.path = path + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try pathFilterInclude(pathFilter: self, dict: dict, pather: pather) + } + + func cmpStr() -> String { + return "<" + } + + func doInclude(val: any Val) -> Bool { + return sameType(val: val) && val.toZinc() < self.val.toZinc() + } +} + +class Le: CmpFilter { + var val: any Val + var path: Path + + init(val: any Val, path: Path) { + self.val = val + self.path = path + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try pathFilterInclude(pathFilter: self, dict: dict, pather: pather) + } + + func cmpStr() -> String { + return "<=" + } + + func doInclude(val: any Val) -> Bool { + return sameType(val: val) && val.toZinc() <= self.val.toZinc() + } +} + +class Gt: CmpFilter { + var val: any Val + var path: Path + + init(val: any Val, path: Path) { + self.val = val + self.path = path + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try pathFilterInclude(pathFilter: self, dict: dict, pather: pather) + } + + func cmpStr() -> String { + return ">" + } + + func doInclude(val: any Val) -> Bool { + return sameType(val: val) && val.toZinc() > self.val.toZinc() + } +} + +class Ge: CmpFilter { + var val: any Val + var path: Path + + init(val: any Val, path: Path) { + self.val = val + self.path = path + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try pathFilterInclude(pathFilter: self, dict: dict, pather: pather) + } + + func cmpStr() -> String { + return ">=" + } + + func doInclude(val: any Val) -> Bool { + return sameType(val: val) && val.toZinc() >= self.val.toZinc() + } +} + +protocol CompoundFilter: Filter { + var a: any Filter { get } + var b: any Filter { get } + + func keyword() -> String +} + +extension CompoundFilter { + var description: String { + var s = "" + if a is any CompoundFilter { + s.append("(") + s.append(a.description) + s.append(")") + } else { + s.append(a.description) + } + s.append(" ") + s.append(keyword()) + s.append(" ") + if b is any CompoundFilter { + s.append("(") + s.append(b.description) + s.append(")") + } else { + s.append(b.description) + } + return s + } +} + +class And: CompoundFilter { + let a: any Filter + let b: any Filter + + init(a: any Filter, b: any Filter) { + self.a = a + self.b = b + } + + func keyword() -> String { + return "and" + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try a.include(dict: dict, pather: pather) && b.include(dict: dict, pather: pather) + } +} + +class Or: CompoundFilter { + let a: any Filter + let b: any Filter + + init(a: any Filter, b: any Filter) { + self.a = a + self.b = b + } + + func keyword() -> String { + return "or" + } + + func include(dict: Dict, pather: Pather?) throws -> Bool { + return try a.include(dict: dict, pather: pather) || b.include(dict: dict, pather: pather) + } +} + +/// Path is a simple name or a complex path using the "->" separator +public struct Path: Hashable, Equatable { + let string: String + let names: [String] + + init(_ s: String) { + string = s + names = [s] + } + + init(s: String, n: [String]) { + string = s + names = n + } + + /** Construct a new Path from string or throw ParseException */ + public static func make(path: String) throws -> Path { + var dash = path.firstIndex(of: "-") + + // parse + var s = path.startIndex + var acc = [Substring]() + var first = true + while true { + guard let thisDash = dash else { + if first { + return Path(s: path, n: [path]) + } else { + let rest = path[s ..< path.endIndex] + if rest.count == 0 { + throw ParseError.path("Path: " + path) + } + acc.append(rest) + break + } + } + let n = path[s ..< thisDash] + if n.count == 0 { + throw ParseError.path("Path: " + path) + } + acc.append(n) + if path[path.index(after: thisDash)] != ">" { + throw ParseError.path("Path: " + path) + } + s = path.index(after: thisDash) + s = path.index(after: s) + dash = path[s ..< path.endIndex].firstIndex(of: "-") + first = false + } + return Path(s: path, n: acc.map { String($0) }) + } +} + +extension Path: CustomStringConvertible { + public var description: String { + return string + } +} + +extension Path: Collection { + public typealias Index = Int + + public var startIndex: Int { + return names.startIndex + } + + public var endIndex: Int { + return names.endIndex + } + + public subscript(position: Int) -> String { + return names[position] + } + + public func index(after i: Int) -> Int { + return i + 1 + } +} + +enum ParseError: Error { + case filter(String) + case path(String) +} + +public class FilterParser { + private let tokenizer: ZincTokenizer + private var cur: ZincToken? + private var curVal: any Val + private var peek: ZincToken? + private var peekVal: any Val + + public init(in: String) throws { + tokenizer = try ZincTokenizer(`in`) + curVal = null + peekVal = null + try consume() + try consume() + } + + public func parse() throws -> any Filter { + let f = try condOr() + try verify(expected: ZincToken.eof) + return f + } + + private func condOr() throws -> any Filter { + let lhs = try condAnd() + if !isKeyword("or") { + return lhs + } + try consume() + return try lhs.or(condOr()) + } + + private func condAnd() throws -> any Filter { + let lhs = try term() + if !isKeyword("and") { + return lhs + } + try consume() + return try lhs.and(condAnd()) + } + + private func term() throws -> any Filter { + if cur == ZincToken.lparen { + try consume() + let f = try condOr() + try consume(expected: ZincToken.rparen) + return f + } + + if isKeyword("not"), peek == ZincToken.id { + try consume() + return try Missing(path: path()) + } + + let p = try path() + if cur == ZincToken.eq { + try consume() + return try Eq(val: val(), path: p) + } + if cur == ZincToken.notEq { + try consume() + return try Ne(val: val(), path: p) + } + if cur == ZincToken.lt { + try consume() + return try Lt(val: val(), path: p) + } + if cur == ZincToken.ltEq { + try consume() + return try Le(val: val(), path: p) + } + if cur == ZincToken.gt { + try consume() + return try Gt(val: val(), path: p) + } + if cur == ZincToken.gtEq { + try consume() + return try Ge(val: val(), path: p) + } + + return Has(path: p) + } + + private func path() throws -> Path { + var id = try pathName() + if cur != ZincToken.arrow { + return Path(id) + } + + var segments = [String]() + segments.append(id) + var s = id + while cur == ZincToken.arrow { + try consume(expected: ZincToken.arrow) + id = try pathName() + segments.append(id) + s.append(ZincToken.arrow.rawValue) + s.append(id) + } + return Path(s: s, n: segments) + } + + private func pathName() throws -> String { + if cur != ZincToken.id { + throw ParseError.filter("Expecting tag name, not " + curToStr()) + } + let id = curVal as! String + try consume() + return id + } + + private func val() throws -> any Val { + if let cur = cur, cur.isLiteral { + let v = curVal + try consume() + return v + } + + if cur == ZincToken.id { + if "true".equals(curVal) { + try consume() + return true + } + if "false".equals(curVal) { + try consume() + return false + } + } + + throw ParseError.filter("Expecting value literal, not \(curToStr())") + } + + private func isKeyword(_ n: String) -> Bool { + return cur == ZincToken.id && n.equals(curVal) + } + + private func verify(expected: ZincToken) throws { + if cur != expected { + throw ParseError.filter("Expected \(expected) not \(curToStr())") + } + } + + private func curToStr() -> String { + if let cur = cur { + return "\(cur) \(curVal)" + } else { + return Haystack.null.toZinc() + } + } + + private func consume() throws { + try consume(expected: nil) + } + + private func consume(expected: ZincToken?) throws { + if let expected = expected { + try verify(expected: expected) + } + cur = peek + curVal = peekVal + peek = try tokenizer.next() + peekVal = tokenizer.val + } +} diff --git a/Sources/Haystack/Grid.swift b/Sources/Haystack/Grid.swift index e140794..0fd394a 100644 --- a/Sources/Haystack/Grid.swift +++ b/Sources/Haystack/Grid.swift @@ -11,9 +11,9 @@ import Foundation public struct Grid: Val { public static var valType: ValType { .Grid } - public let meta: Dict - public let cols: [Col] - public let rows: [Dict] + public private(set) var meta: Dict + public private(set) var cols: [Col] + public private(set) var rows: [Dict] init(meta: Dict, cols: [Col], rows: [Dict]) { self.meta = meta @@ -21,17 +21,35 @@ public struct Grid: Val { self.rows = rows } + /// Create a Grid with no column metadata from a list of Dicts. + /// + /// There is no guarantee on the column ordering. + /// - Parameters: + /// - meta: Grid metadata + /// - rows: The rows of the grid + public init(meta: Dict = [:], rowsAndColumns: [Dict]) { + self.meta = meta + var colNames = Set() + for row in rowsAndColumns { + for (key, _) in row { + colNames.insert(key) + } + } + self.cols = colNames.map { Col(name: $0) } + self.rows = rowsAndColumns + } + /// Converts to Zinc formatted string. /// See [Zinc Literals](https://project-haystack.org/doc/docHaystack/Zinc#literals) public func toZinc() -> String { // Ensure `ver` is listed first in meta - let ver = meta.elements["ver"] ?? "3.0" + let ver = meta["ver"] ?? "3.0" var zinc = "ver:\(ver.toZinc())" - var metaWithoutVer = meta.elements + var metaWithoutVer = meta metaWithoutVer["ver"] = nil if metaWithoutVer.count > 0 { - zinc += " \(Dict(metaWithoutVer).toZinc(withBraces: false))" + zinc += " \(metaWithoutVer.toZinc(withBraces: false))" } zinc += "\n" @@ -40,7 +58,7 @@ public struct Grid: Val { } else { let zincCols = cols.map { col in var colZinc = col.name - if let colMeta = col.meta, colMeta.elements.count > 0 { + if let colMeta = col.meta, colMeta.count > 0 { colZinc += " \(colMeta.toZinc(withBraces: false))" } return colZinc @@ -50,7 +68,7 @@ public struct Grid: Val { let zincRows = rows.map { row in let rowZincElements = cols.map { col in - let element = row.elements[col.name] ?? null + let element = row[col.name] ?? null return element.toZinc() } return rowZincElements.joined(separator: ", ") @@ -60,6 +78,21 @@ public struct Grid: Val { return zinc } + + /// Returns a grid that is the same as the existing one, but with its columns reordered according to the input names. + /// - Parameter newOrder: The names of the columns, in the desired order + /// - Returns: self for chaining + public mutating func reorderCols(to newOrder: [String]) throws -> Self { + var newCols: [Col] = [] + for name in newOrder { + guard let colIndex = cols.firstIndex(where: { $0.name == name }) else { + throw GridError.columnNotFound(name) + } + newCols.append(cols[colIndex]) + } + self.cols = newCols + return self + } } // Grid + Codable @@ -87,7 +120,9 @@ extension Grid { ) } - self.meta = try container.decode(Dict.self, forKey: .meta) + var meta = try container.decode(Dict.self, forKey: .meta) + meta["ver"] = nil // Remove version + self.meta = meta let cols = try container.decode([Col].self, forKey: .cols) if cols.map(\.name) == ["empty"] { self.cols = [] @@ -111,8 +146,10 @@ extension Grid { /// See [JSON format](https://project-haystack.org/doc/docHaystack/Json#grid) public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: Self.CodingKeys.self) + var versionedMeta = meta + versionedMeta["ver"] = "3.0" try container.encode(Self.kindValue, forKey: ._kind) - try container.encode(meta, forKey: .meta) + try container.encode(versionedMeta, forKey: .meta) if cols.isEmpty { try container.encode([Col(name: "empty")], forKey: .cols) try container.encode([Dict](), forKey: .rows) @@ -167,12 +204,48 @@ extension Grid { } } -public struct Col: Codable { - let name: String - let meta: Dict? +// Grid + Collection +extension Grid: Collection { + public var startIndex: Int { + rows.startIndex + } + + public var endIndex: Int { + rows.endIndex + } + + public subscript(position: Int) -> Dict { + return rows[position] + } + + public func index(after i: Int) -> Int { + return i + 1 + } +} + +extension Grid: ExpressibleByArrayLiteral { + /// Create a grid from the provided literals. The grid will have no grid or column-level metadata + public init(arrayLiteral: Dict...) { + self.init(meta: [:], rowsAndColumns: arrayLiteral) + } +} + +extension Grid: CustomStringConvertible { + public var description: String { + return self.toZinc() + } +} + +public struct Col: Codable, Sendable { + public let name: String + public let meta: Dict? public init(name: String, meta: Dict? = nil) { self.name = name self.meta = meta } } + +public enum GridError: Error { + case columnNotFound(String) +} diff --git a/Sources/Haystack/IO/ZincReader.swift b/Sources/Haystack/IO/ZincReader.swift index 3037e12..4675d87 100644 --- a/Sources/Haystack/IO/ZincReader.swift +++ b/Sources/Haystack/IO/ZincReader.swift @@ -205,11 +205,11 @@ public class ZincReader { while cur == .id { numCols += 1 let name = try consumeTagName() - var colMeta: [String: any Val]? = nil + var colMeta: Dict? = nil if cur == .id { - colMeta = try parseDict().elements + colMeta = try parseDict() } - try builder.addCol(name: name, meta: colMeta) + try builder.addCol(name: name, meta: colMeta?.elements) guard cur == .comma else { break diff --git a/Sources/Haystack/IO/ZincToken.swift b/Sources/Haystack/IO/ZincToken.swift index 82b1111..72bf9b4 100644 --- a/Sources/Haystack/IO/ZincToken.swift +++ b/Sources/Haystack/IO/ZincToken.swift @@ -45,7 +45,7 @@ enum ZincToken: String, Equatable, Hashable { var isLiteral: Bool { switch self { - case .id, .num, .str, .ref, .symbol, .uri, .date, .time, .datetime: + case .num, .str, .ref, .symbol, .uri, .date, .time, .datetime: return true default: return false diff --git a/Sources/Haystack/List.swift b/Sources/Haystack/List.swift index dc2549f..a0e0d12 100644 --- a/Sources/Haystack/List.swift +++ b/Sources/Haystack/List.swift @@ -7,7 +7,7 @@ import Foundation public struct List: Val { public static var valType: ValType { .List } - public let elements: [any Val] + public private(set) var elements: [any Val] public init(_ elements: [any Val]) { self.elements = elements @@ -19,6 +19,10 @@ public struct List: Val { let zincElements = elements.map { $0.toZinc() } return "[\(zincElements.joined(separator:", "))]" } + + public func toSwiftArray() -> [any Val] { + return elements + } } // List + Codable @@ -85,6 +89,25 @@ extension List { } } +// List + Collection +extension List: Collection { + public var startIndex: Int { + elements.startIndex + } + + public var endIndex: Int { + elements.endIndex + } + + public subscript(position: Int) -> any Val { + return elements[position] + } + + public func index(after i: Int) -> Int { + return i + 1 + } +} + extension List: ExpressibleByArrayLiteral { /// Creates an instance initialized with the given elements. public init(arrayLiteral: any Val...) { diff --git a/Sources/Haystack/Utils/GridBuilder.swift b/Sources/Haystack/Utils/GridBuilder.swift index ddf5df6..645a872 100644 --- a/Sources/Haystack/Utils/GridBuilder.swift +++ b/Sources/Haystack/Utils/GridBuilder.swift @@ -8,7 +8,7 @@ public class GridBuilder { var rows: [[String: any Val]] public init() { - meta = ["ver":"3.0"] // We don't back-support old grid versions + meta = [:] colNames = [] colMeta = [:] rows = [] @@ -65,10 +65,6 @@ public class GridBuilder { /// - meta: Column-level metadata for the new column /// - Returns: This instance for chaining public func addCol(name: String, meta: [String: any Val]? = nil) throws -> Self { - guard rows.count == 0 else { - throw GridBuilderError.cannotAddColAfterRows - } - guard !colNames.contains(name) else { throw GridBuilderError.colAlreadyDefined(name: name) } @@ -77,12 +73,6 @@ public class GridBuilder { colMeta[name] = meta } - rows = rows.map { row in - var newRow = row - newRow[name] = Null.val - return newRow - } - return self } @@ -161,6 +151,26 @@ public class GridBuilder { } return self } + + @discardableResult + /// Append a new row to the grid. Newly seen columns are added automatically with no metadata, although column ordering is not guaranteed. + /// - Parameter vals: The values of the row, in the same order as the columns. + /// - Returns: This instance for chaining + public func addRow(_ dict: Dict) throws -> Self { + try self.addRow(dict.elements) + return self + } + + @discardableResult + /// Append a new row to the grid. Newly seen columns are added automatically with no metadata, although column ordering is not guaranteed. + /// - Parameter vals: The values of the row, in the same order as the columns. + /// - Returns: This instance for chaining + public func addRows(_ dicts: [Dict]) throws -> Self { + for dict in dicts { + try self.addRow(dict) + } + return self + } } enum GridBuilderError: Error { diff --git a/Sources/Haystack/Val.swift b/Sources/Haystack/Val.swift index 9d84025..64723d6 100644 --- a/Sources/Haystack/Val.swift +++ b/Sources/Haystack/Val.swift @@ -2,7 +2,7 @@ import Foundation /// Val represents the core functionality of Haystack types, specifically /// hashability, equatability, JSON coding, and zinc coding. -public protocol Val: Codable, Hashable { +public protocol Val: Codable, Hashable, Sendable { static var valType: ValType { get } func toZinc() -> String } diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift index c1a03bb..1d62cef 100644 --- a/Sources/HaystackClient/Client.swift +++ b/Sources/HaystackClient/Client.swift @@ -14,7 +14,7 @@ import Foundation /// let about = await try client.about() /// await try client.close() /// ``` -public class Client { +public class Client: API { let baseUrl: String let username: String let password: String @@ -252,7 +252,7 @@ public class Client { /// - range: A date-time range /// - Returns: A grid whose rows represent timetamp/value pairs with a DateTime ts column and a val column for each scalar value public func hisRead(id: Ref, range: HisReadRange) async throws -> Grid { - return try await post(path: "hisRead", args: ["id": id, "range": range.toRequestString()]) + return try await post(path: "hisRead", args: ["id": id, "range": range.toZinc()]) } /// Posts new time-series data to a historized point @@ -387,7 +387,7 @@ public class Client { return try await post(path: "watchSub", grid: builder.toGrid()) } - /// Used to close a watch entirely or remove entities from a watch + /// Used to remove entities from a watch /// /// https://project-haystack.org/doc/docHaystack/Ops#watchUnsub /// @@ -395,7 +395,7 @@ public class Client { /// - watchId: Watch identifier /// - ids: Ref values for each entity to unsubscribe. If empty the entire watch is closed. /// - Returns: An empty grid - public func watchUnsub( + public func watchUnsubRemove( watchId: String, ids: [Ref] ) async throws -> Grid { @@ -414,6 +414,26 @@ public class Client { return try await post(path: "watchUnsub", grid: builder.toGrid()) } + /// Used to close a watch entirely + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchUnsub + /// + /// - Parameters: + /// - watchId: Watch identifier + /// - Returns: An empty grid + public func watchUnsubDelete( + watchId: String + ) async throws -> Grid { + var gridMeta: [String: any Val] = ["watchId": watchId] + gridMeta["close"] = marker + + let builder = GridBuilder() + builder.setMeta(gridMeta) + try builder.addCol(name: "id") + + return try await post(path: "watchUnsub", grid: builder.toGrid()) + } + /// Used to poll a watch for changes to the subscribed entity records /// /// https://project-haystack.org/doc/docHaystack/Ops#watchPoll @@ -437,6 +457,8 @@ public class Client { return try await post(path: "watchPoll", grid: builder.toGrid()) } + /// Used to invoke a user action on a target record + /// /// https://project-haystack.org/doc/docHaystack/Ops#invokeAction /// - Parameters: /// - id: Identifier of target rec diff --git a/Sources/HaystackClient/HisReadRange.swift b/Sources/HaystackClient/HisReadRange.swift deleted file mode 100644 index 8310552..0000000 --- a/Sources/HaystackClient/HisReadRange.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Haystack - -/// Query-able DateTime ranges, which support relative and absolute values. -public enum HisReadRange { - case today - case yesterday - case date(Haystack.Date) - case dateRange(from: Haystack.Date, to: Haystack.Date) - case dateTimeRange(from: DateTime, to: DateTime) - case after(DateTime) - - func toRequestString() -> String { - switch self { - case .today: return "today" - case .yesterday: return "yesterday" - case let .date(date): return "\(date.toZinc())" - case let .dateRange(fromDate, toDate): return "\(fromDate.toZinc()),\(toDate.toZinc())" - case let .dateTimeRange(fromDateTime, toDateTime): return "\(fromDateTime.toZinc()),\(toDateTime.toZinc())" - case let .after(dateTime): return "\(dateTime.toZinc())" - } - } -} diff --git a/Sources/HaystackServer/HaystackServer.swift b/Sources/HaystackServer/HaystackServer.swift new file mode 100644 index 0000000..24ed081 --- /dev/null +++ b/Sources/HaystackServer/HaystackServer.swift @@ -0,0 +1,205 @@ +import Foundation +import Haystack + +/// A HaystackServer is a server that implements the Haystack API. +/// It translates API calls into operations on the underlying data stores. +public class HaystackServer: API { + let recordStore: RecordStore + let historyStore: HistoryStore + let watchStore: WatchStore + + let onInvokeAction: (Haystack.Ref, String, [String : any Haystack.Val]) async throws -> Haystack.Grid + let onEval: (String) async throws -> Haystack.Grid + + public init( + recordStore: RecordStore, + historyStore: HistoryStore, + watchStore: WatchStore, + onInvokeAction: @escaping (Haystack.Ref, String, [String : any Haystack.Val]) async throws -> Haystack.Grid = { _, _, _ in + GridBuilder().toGrid() + }, + onEval: @escaping (String) async throws -> Haystack.Grid = { _ in + GridBuilder().toGrid() + } + ) { + self.recordStore = recordStore + self.historyStore = historyStore + self.watchStore = watchStore + self.onInvokeAction = onInvokeAction + self.onEval = onEval + } + + public func close() async throws { + return + } + + public func about() async throws -> Haystack.Grid { + let gb = Haystack.GridBuilder() + try gb.addRow([ + "haystackVersion": "4.0", + "tz": "New_York", + "serverName": "Test Server", + "serverTime": DateTime(date: Foundation.Date()), + "serverBootTime": DateTime(date: Foundation.Date() - ProcessInfo.processInfo.systemUptime), + "productName": "swift-haystack", + "productUri": Uri("https://github.com/NeedleInAJayStack/swift-haystack"), + "productVersion": "0.0.0", // TODO: Version + "vendorName": "NeedleInAJayStack", + "vendorUri": Uri("https://github.com/NeedleInAJayStack"), + ]) + return gb.toGrid() + } + + public func defs(filter: String?, limit: Haystack.Number?) async throws -> Haystack.Grid { + let gb = Haystack.GridBuilder() + var queryFilter = "def" + if let filter = filter { + queryFilter += " and (\(filter))" + } + let dicts = try await recordStore.read(filter: queryFilter, limit: limit) + try gb.addRows(dicts) + return gb.toGrid() + } + + public func libs(filter: String?, limit: Haystack.Number?) async throws -> Haystack.Grid { + let gb = Haystack.GridBuilder() + var queryFilter = "lib" + if let filter = filter { + queryFilter += " and (\(filter))" + } + let dicts = try await recordStore.read(filter: queryFilter, limit: limit) + try gb.addRows(dicts) + return gb.toGrid() + } + + public func ops(filter: String?, limit: Haystack.Number?) async throws -> Haystack.Grid { + let gb = Haystack.GridBuilder() + var queryFilter = "def and op" + if let filter = filter { + queryFilter += " and (\(filter))" + } + let dicts = try await recordStore.read(filter: queryFilter, limit: limit) + try gb.addRows(dicts) + return gb.toGrid() + } + + public func filetypes(filter: String?, limit: Haystack.Number?) async throws -> Haystack.Grid { + let gb = Haystack.GridBuilder() + var queryFilter = "def and filetype" + if let filter = filter { + queryFilter += " and (\(filter))" + } + let dicts = try await recordStore.read(filter: queryFilter, limit: limit) + try gb.addRows(dicts) + return gb.toGrid() + } + + public func read(ids: [Haystack.Ref]) async throws -> Haystack.Grid { + let gb = Haystack.GridBuilder() + let dicts = try await recordStore.read(ids: ids) + try gb.addRows(dicts) + return gb.toGrid() + } + + public func read(filter: String, limit: Haystack.Number?) async throws -> Haystack.Grid { + let gb = Haystack.GridBuilder() + let dicts = try await recordStore.read(filter: filter, limit: limit) + try gb.addRows(dicts) + return gb.toGrid() + } + + public func nav(navId: Haystack.Ref?) async throws -> Haystack.Grid { + // TODO: Implement + return GridBuilder().toGrid() + } + + public func hisRead(id: Haystack.Ref, range: Haystack.HisReadRange) async throws -> Haystack.Grid { + let gb = Haystack.GridBuilder() + try gb.addCol(name: "ts") + try gb.addCol(name: "val") + let dicts = try await historyStore.hisRead(id: id, range: range) + try gb.addRows(dicts) + return gb.toGrid() + } + + public func hisWrite(id: Haystack.Ref, items: [Haystack.HisItem]) async throws -> Haystack.Grid { + try await historyStore.hisWrite(id: id, items: items) + return GridBuilder().toGrid() + } + + public func pointWrite(id: Haystack.Ref, level: Haystack.Number, val: any Haystack.Val, who: String?, duration: Haystack.Number?) async throws -> Haystack.Grid { + // TODO: Implement + return GridBuilder().toGrid() + } + + public func pointWriteStatus(id: Haystack.Ref) async throws -> Haystack.Grid { + // TODO: Implement + return GridBuilder().toGrid() + } + + public func watchSubCreate(watchDis: String, lease: Haystack.Number?, ids: [Haystack.Ref]) async throws -> Haystack.Grid { + let watchId = try await watchStore.create(ids: ids, lease: lease) + let builder = GridBuilder().setMeta([ + "watchId": watchId, + "lease": lease ?? Haystack.Null.val + ]) + let watchRecs = try await recordStore.read(ids: ids) + try builder.addRows(watchRecs) + try await watchStore.updateLastReported(watchId: watchId) + return builder.toGrid() + } + + public func watchSubAdd(watchId: String, lease: Haystack.Number?, ids: [Haystack.Ref]) async throws -> Haystack.Grid { + try await watchStore.addIds(watchId: watchId, ids: ids) + let builder = GridBuilder().setMeta([ + "watchId": watchId, + "lease": lease ?? Haystack.Null.val + ]) + let watchRecs = try await recordStore.read(ids: ids) + try builder.addRows(watchRecs) + try await watchStore.updateLastReported(watchId: watchId) + return builder.toGrid() + } + + public func watchUnsubRemove(watchId: String, ids: [Haystack.Ref]) async throws -> Haystack.Grid { + try await watchStore.removeIds(watchId: watchId, ids: ids) + return GridBuilder().toGrid() + } + + public func watchUnsubDelete(watchId: String) async throws -> Haystack.Grid { + try await watchStore.delete(watchId: watchId) + return GridBuilder().toGrid() + } + + public func watchPoll(watchId: String, refresh: Bool) async throws -> Haystack.Grid { + let watch = try await watchStore.read(watchId: watchId) + let builder = GridBuilder().setMeta([ + "watchId": watchId + ]) + var watchRecs = [Dict]() + if refresh { + watchRecs = try await recordStore.read(ids: watch.ids) + } else { + watchRecs = try await recordStore.read(ids: watch.ids).filter { rec in + return try rec.trap("mod", as: DateTime.self).date > watch.lastReported ?? .distantPast + } + } + + try builder.addRows(watchRecs) + try await watchStore.updateLastReported(watchId: watchId) + return builder.toGrid() + } + + public func invokeAction(id: Haystack.Ref, action: String, args: [String : any Haystack.Val]) async throws -> Haystack.Grid { + return try await self.onInvokeAction(id, action, args) + } + + public func eval(expression: String) async throws -> Haystack.Grid { + return try await self.onEval(expression) + } +} + +public enum ServerError: Error { + case idNotFound(Haystack.Ref) + case watchNotFound(String) +} diff --git a/Sources/HaystackServer/Stores/HistoryStore.swift b/Sources/HaystackServer/Stores/HistoryStore.swift new file mode 100644 index 0000000..2f2fea4 --- /dev/null +++ b/Sources/HaystackServer/Stores/HistoryStore.swift @@ -0,0 +1,10 @@ +import Haystack + +/// Defines a storage system that allows reading and writing of Haystack history data. +public protocol HistoryStore { + /// Reads history data for a given ID and time range. + func hisRead(id: Haystack.Ref, range: Haystack.HisReadRange) async throws -> [Haystack.Dict] + + /// Writes history data for a given ID. + func hisWrite(id: Haystack.Ref, items: [Haystack.HisItem]) async throws +} diff --git a/Sources/HaystackServer/Stores/RecordStore.swift b/Sources/HaystackServer/Stores/RecordStore.swift new file mode 100644 index 0000000..2a13174 --- /dev/null +++ b/Sources/HaystackServer/Stores/RecordStore.swift @@ -0,0 +1,29 @@ +import Haystack + +/// Defines a storage system that allows reading and writing of Haystack records. +public protocol RecordStore { + /// Reads records from the store based on a list of IDs. + func read(ids: [Haystack.Ref]) async throws -> [Haystack.Dict] + + /// Reads records from the store based on a filter and an optional limit. + func read(filter: String, limit: Haystack.Number?) async throws -> [Haystack.Dict] + + /// Commits a list of record diffs to the store. + func commitAll(diffs: [RecordDiff]) async throws -> [RecordDiff] +} + +public struct RecordDiff { + public init( + id: Haystack.Ref, + old: Haystack.Dict?, + new: Haystack.Dict + ) { + self.id = id + self.old = old + self.new = new + } + + public let id: Haystack.Ref + public let old: Haystack.Dict? + public let new: Haystack.Dict +} diff --git a/Sources/HaystackServer/Stores/WatchStore.swift b/Sources/HaystackServer/Stores/WatchStore.swift new file mode 100644 index 0000000..11d14cd --- /dev/null +++ b/Sources/HaystackServer/Stores/WatchStore.swift @@ -0,0 +1,25 @@ +import Foundation +import Haystack + +/// Defines a storage system that allows stateful storage of system watches. +public protocol WatchStore { + func read(watchId: String) async throws -> WatchResponse + func create(ids: [Haystack.Ref], lease: Haystack.Number?) async throws -> String + func addIds(watchId: String, ids: [Haystack.Ref]) async throws + func removeIds(watchId: String, ids: [Haystack.Ref]) async throws + func updateLastReported(watchId: String) async throws + func delete(watchId: String) async throws +} + +public struct WatchResponse { + public let ids: [Haystack.Ref] + public let lease: Haystack.Number + public let lastReported: Foundation.Date? + + public init(ids: [Haystack.Ref], lease: Haystack.Number, lastReported: Foundation.Date?) { + self.ids = ids + self.lease = lease + self.lastReported = lastReported + } +} + diff --git a/Sources/HaystackServerVapor/HTTPMediaType+zinc.swift b/Sources/HaystackServerVapor/HTTPMediaType+zinc.swift new file mode 100644 index 0000000..6938557 --- /dev/null +++ b/Sources/HaystackServerVapor/HTTPMediaType+zinc.swift @@ -0,0 +1,6 @@ +import Vapor + +public extension HTTPMediaType { + /// The `application/zinc` media type: https://project-haystack.org/doc/docHaystack/Zinc + static let zinc = HTTPMediaType(type: "text", subType: "zinc", parameters: ["charset": "utf-8"]) +} diff --git a/Sources/HaystackServerVapor/HaystackRouteCollection.swift b/Sources/HaystackServerVapor/HaystackRouteCollection.swift new file mode 100644 index 0000000..4252c91 --- /dev/null +++ b/Sources/HaystackServerVapor/HaystackRouteCollection.swift @@ -0,0 +1,555 @@ +import Haystack +import Vapor + +/// A route collection that exposes Haystack API endpoints. +public struct HaystackRouteCollection: RouteCollection { + + /// This instance defines all Haystack API processing that is done server-side. + let delegate: any API + + public init(delegate: any API) { + self.delegate = delegate + } + + public func boot(routes: any Vapor.RoutesBuilder) throws { + + /// Closes the current authentication session. + /// + /// https://project-haystack.org/doc/docHaystack/Ops#close + routes.post("close") { request in + try await delegate.close() + return "" + } + + /// Queries basic information about the server + /// + /// https://project-haystack.org/doc/docHaystack/Ops#about + routes.get("about") { request in + return try await request.respond(with: delegate.about()) + } + + /// Queries basic information about the server + /// + /// https://project-haystack.org/doc/docHaystack/Ops#about + routes.post("about") { request in + return try await request.respond(with: delegate.about()) + } + + /// Queries def dicts from the current namespace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#defs + routes.get("defs") { request in + let dict = request.queryDict() + let filter: String? + let limit: Number? + do { + filter = try dict.get("filter", as: String.self) + limit = try dict.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.defs(filter: filter, limit: limit) + ) + } + + /// Queries def dicts from the current namespace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#defs + routes.post("defs") { request in + let grid = try request.decodeGrid() + let filter: String? + let limit: Number? + do { + filter = try grid.first?.get("filter", as: String.self) + limit = try grid.first?.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.defs(filter: filter, limit: limit) + ) + } + + /// Queries lib defs from the current namespace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#defs + routes.get("libs") { request in + let dict = request.queryDict() + let filter: String? + let limit: Number? + do { + filter = try dict.get("filter", as: String.self) + limit = try dict.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.libs(filter: filter, limit: limit) + ) + } + + /// Queries lib defs from current namspace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#libs + routes.post("libs") { request in + let grid = try request.decodeGrid() + let filter: String? + let limit: Number? + do { + filter = try grid.first?.get("filter", as: String.self) + limit = try grid.first?.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.libs(filter: filter, limit: limit) + ) + } + + /// Queries lib defs from the current namespace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#defs + routes.get("ops") { request in + let dict = request.queryDict() + let filter: String? + let limit: Number? + do { + filter = try dict.get("filter", as: String.self) + limit = try dict.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.libs(filter: filter, limit: limit) + ) + } + + /// Queries op defs from current namspace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#ops + routes.post("ops") { request in + let grid = try request.decodeGrid() + let filter: String? + let limit: Number? + do { + filter = try grid.first?.get("filter", as: String.self) + limit = try grid.first?.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.ops(filter: filter, limit: limit) + ) + } + + /// Queries lib defs from the current namespace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#defs + routes.get("filetypes") { request in + let dict = request.queryDict() + let filter: String? + let limit: Number? + do { + filter = try dict.get("filter", as: String.self) + limit = try dict.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.filetypes(filter: filter, limit: limit) + ) + } + + /// Queries filetype defs from current namspace + /// + /// https://project-haystack.org/doc/docHaystack/Ops#filetypes + routes.post("filetypes") { request in + let grid = try request.decodeGrid() + let filter: String? + let limit: Number? + do { + filter = try grid.first?.get("filter", as: String.self) + limit = try grid.first?.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.filetypes(filter: filter, limit: limit) + ) + } + + /// Read a set of entity records by their unique identifier + /// + /// https://project-haystack.org/doc/docHaystack/Ops#read + routes.get("read") { request in + let dict = request.queryDict() + let ids: [Ref]? + let filter: String? + let limit: Number? + do { + let id = try dict.get("id", as: List.self) + ids = try id?.map { element in + guard let id = element as? Ref else { + throw Abort(.badRequest, reason: "`id` elements must be Ref") + } + return id + } + filter = try dict.get("filter", as: String.self) + limit = try dict.get("limit", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + if let ids = ids { + return try await request.respond(with: delegate.read(ids: ids)) + } else if let filter = filter { + return try await request.respond( + with: delegate.read(filter: filter, limit: limit) + ) + } else { + throw Abort(.badRequest, reason: "Read request must have either 'id' or 'filter'") + } + } + + /// Read a set of entity records by their unique identifier + /// + /// https://project-haystack.org/doc/docHaystack/Ops#read + routes.post("read") { request in + let grid = try request.decodeGrid() + var ids: [Ref]? = nil + var filter: String? = nil + var limit: Number? = nil + do { + if grid.cols.contains(where: { $0.name == "id" }) { + ids = try grid.map { try $0.trap("id", as: Ref.self) } + } else { + guard let firstRow = grid.first else { + throw Abort(.badRequest, reason: "Request grid must not be empty") + } + filter = try firstRow.trap("filter", as: String.self) + limit = try firstRow.get("limit", as: Number.self) + } + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + if let ids = ids { + return try await request.respond(with: delegate.read(ids: ids)) + } else if let filter = filter { + return try await request.respond( + with: delegate.read(filter: filter, limit: limit) + ) + } else { + throw Abort(.badRequest, reason: "Read request must have either 'id' or 'filter'") + } + } + + /// Navigate a project for learning and discovery + /// + /// https://project-haystack.org/doc/docHaystack/Ops#nav + routes.get("nav") { request in + let dict = request.queryDict() + let navId: Ref? + do { + navId = try dict.get("navId", as: Ref.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.nav(navId: navId) + ) + } + + /// Navigate a project for learning and discovery + /// + /// https://project-haystack.org/doc/docHaystack/Ops#nav + routes.post("nav") { request in + let grid = try request.decodeGrid() + let navId: Ref? + do { + navId = try grid.first?.get("navId", as: Ref.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.nav(navId: navId) + ) + } + + /// Reads time-series data from historized point + /// + /// https://project-haystack.org/doc/docHaystack/Ops#hisRead + routes.get("hisRead") { request in + let dict = request.queryDict() + let id: Ref + let range: HisReadRange + do { + id = try dict.trap("id", as: Ref.self) + range = try HisReadRange.fromZinc(dict.trap("range", as: String.self)) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.hisRead( + id: id, + range: range + ) + ) + } + + /// Reads time-series data from historized point + /// + /// https://project-haystack.org/doc/docHaystack/Ops#hisRead + routes.post("hisRead") { request in + let grid = try request.decodeGrid() + guard let row = grid.first else { + throw Abort(.badRequest, reason: "Request grid must not be empty") + } + let id: Ref + let range: HisReadRange + do { + id = try row.trap("id", as: Ref.self) + range = try HisReadRange.fromZinc(row.trap("range", as: String.self)) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.hisRead( + id: id, + range: range + ) + ) + } + + /// Reads time-series data from historized point + /// + /// https://project-haystack.org/doc/docHaystack/Ops#hisRead + routes.post("hisWrite") { request in + let grid = try request.decodeGrid() + let id: Ref + let items: [HisItem] + do { + id = try grid.meta.trap("id", as: Ref.self) + items = try grid.map { row in + let ts = try row.trap("ts", as: DateTime.self) + let val = try row.trap("val") + return HisItem(ts: ts, val: val) + } + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.hisWrite(id: id, items: items) + ) + } + + /// Write to a given level of a writable point's priority array + /// + /// https://project-haystack.org/doc/docHaystack/Ops#pointWrite + routes.post("pointWrite") { request in + let grid = try request.decodeGrid() + guard let args = grid.first else { + throw Abort(.badRequest, reason: "Request grid must not be empty") + } + + // Check for pointWrite status by checking for a level + let id: Ref + let level: Number? + do { + id = try args.trap("id", as: Ref.self) + level = try args.get("level", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + guard let level = level else { + return try await request.respond( + with: delegate.pointWriteStatus(id: id) + ) + } + + // Otherwise, do a pointWrite + let val: any Val + let who: String? + let duration: Number? + do { + val = try args.trap("val") + who = try args.get("who", as: String.self) + duration = try args.get("duration", as: Number.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + return try await request.respond( + with: delegate.pointWrite( + id: id, + level: level, + val: val, + who: who, + duration: duration + ) + ) + } + + /// Used to create new watches. + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchSub + routes.post("watchSub") { request in + let grid = try request.decodeGrid() + + let ids: [Ref] + let lease: Number? + let watchDis: String? + let watchId: String? + do { + ids = try grid.map { row in + try row.trap("id", as: Ref.self) + } + lease = try grid.meta.get("lease", as: Number.self) + watchDis = try grid.meta.get("watchDis", as: String.self) + watchId = try grid.meta.get("watchId", as: String.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + if let watchDis = watchDis { + return try await request.respond( + with: delegate.watchSubCreate( + watchDis: watchDis, + lease: lease, + ids: ids + ) + ) + } + + if let watchId = watchId { + return try await request.respond( + with: delegate.watchSubAdd( + watchId: watchId, + lease: lease, + ids: ids + ) + ) + } + + throw Abort(.badRequest, reason: "Meta must include either `watchDis` or `watchId`") + } + + /// Used to close a watch entirely or remove entities from a watch + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchUnsub + routes.post("watchUnsub") { request in + let grid = try request.decodeGrid() + + let watchId: String + do { + watchId = try grid.meta.trap("watchId", as: String.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + if grid.meta.has("close") { + return try await request.respond( + with: delegate.watchUnsubDelete( + watchId: watchId + ) + ) + } else { + let ids: [Ref] + do { + ids = try grid.map { row in + try row.trap("id", as: Ref.self) + } + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.watchUnsubRemove( + watchId: watchId, + ids: ids + ) + ) + } + } + + /// Used to poll a watch for changes to the subscribed entity records + /// + /// https://project-haystack.org/doc/docHaystack/Ops#watchPoll + routes.post("watchPoll") { request in + let grid = try request.decodeGrid() + let watchId: String + let refresh: Bool + do { + watchId = try grid.meta.trap("watchId", as: String.self) + refresh = try grid.meta.get("refresh", as: Bool.self) ?? false + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond( + with: delegate.watchPoll( + watchId: watchId, + refresh: refresh + ) + ) + } + + /// Used to invoke a user action on a target record + /// + /// https://project-haystack.org/doc/docHaystack/Ops#invokeAction + routes.post("invokeAction") { request in + let grid = try request.decodeGrid() + let id: Ref + let action: String + do { + id = try grid.meta.trap("id", as: Ref.self) + action = try grid.meta.trap("action", as: String.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + var args = [String: any Val]() + if let row = grid.first { + args = row.elements + } + + return try await request.respond( + with: delegate.invokeAction( + id: id, + action: action, + args: args + ) + ) + } + + /// Evaluate an Axon expression + /// + /// https://haxall.io/doc/lib-hx/op~eval + routes.post("eval") { request in + let grid = try request.decodeGrid() + guard let args = grid.first else { + throw Abort(.badRequest, reason: "Request grid must not be empty") + } + let expr: String + do { + expr = try args.trap("expr", as: String.self) + } catch { + throw Abort(.badRequest, reason: error.localizedDescription) + } + + return try await request.respond(with: delegate.eval(expression: expr)) + } + } +} diff --git a/Sources/HaystackServerVapor/Request+Grid.swift b/Sources/HaystackServerVapor/Request+Grid.swift new file mode 100644 index 0000000..d736e6e --- /dev/null +++ b/Sources/HaystackServerVapor/Request+Grid.swift @@ -0,0 +1,46 @@ +import Haystack +import Vapor + +extension Grid: Content {} + +extension Request { + /// Returns the grid parsed from the request body according to the `content-type` header + func decodeGrid() throws -> Grid { + let grid: Grid + switch self.headers.contentType { + case .zinc: + guard let body = self.body.string else { + throw Abort(.badRequest, reason: "No request body provided") + } + return try ZincReader(body).readGrid() + default: + grid = try self.content.decode(Grid.self) + } + return grid + } + + /// Responds with the grid, encoded according to the `accept` header. See https://project-haystack.org/doc/docHaystack/HttpApi#contentNegotiation + func respond(with grid: Grid) async throws -> Response { + let accept = self.headers.accept + if accept.isEmpty || accept.mediaTypes.contains(.zinc) { + let response = Response(body: .init(stringLiteral: grid.toZinc())) + response.headers.contentType = .zinc + return response + } else { + return try await grid.encodeResponse(for: self) + } + } + + /// Extracts query parameters from the request URL. See https://project-haystack.org/doc/docHaystack/HttpApi#requests + func queryDict() -> Dict { + let queryItems = URLComponents(string: self.url.description)?.queryItems ?? [] + var dictMap: [String: any Val] = [:] + for queryItem in queryItems { + if let value = queryItem.value { + // If we cannot parse the value as zinc, we should use the string value as per the spec + dictMap[queryItem.name] = (try? (ZincReader(value).readVal())) ?? value + } + } + return Dict(dictMap) + } +} diff --git a/Tests/HaystackClientDarwinIntegrationTests/HaystackClientDarwinIntegrationTests.swift b/Tests/HaystackClientDarwinIntegrationTests/HaystackClientDarwinIntegrationTests.swift index c0cfce3..3b1e238 100644 --- a/Tests/HaystackClientDarwinIntegrationTests/HaystackClientDarwinIntegrationTests.swift +++ b/Tests/HaystackClientDarwinIntegrationTests/HaystackClientDarwinIntegrationTests.swift @@ -87,6 +87,6 @@ final class HaystackClientDarwinIntegrationTests: XCTestCase { } func testWatchUnsub() async throws { - print(try await client.watchUnsub(watchId: "id", ids: [Ref("28e7fb47-d67ab19a")])) + print(try await client.watchUnsubRemove(watchId: "id", ids: [Ref("28e7fb47-d67ab19a")])) } } diff --git a/Tests/HaystackClientNIOIntegrationTests/HaystackClientNIOIntegrationTests.swift b/Tests/HaystackClientNIOIntegrationTests/HaystackClientNIOIntegrationTests.swift index fc8b9ae..04c40c6 100644 --- a/Tests/HaystackClientNIOIntegrationTests/HaystackClientNIOIntegrationTests.swift +++ b/Tests/HaystackClientNIOIntegrationTests/HaystackClientNIOIntegrationTests.swift @@ -95,6 +95,6 @@ final class HaystackClientNIOIntegrationTests: XCTestCase { } func testWatchUnsub() async throws { - print(try await client.watchUnsub(watchId: "id", ids: [Ref("28e7fb47-d67ab19a")])) + print(try await client.watchUnsubRemove(watchId: "id", ids: [Ref("28e7fb47-d67ab19a")])) } } diff --git a/Tests/HaystackServerTests/HaystackServerTests.swift b/Tests/HaystackServerTests/HaystackServerTests.swift new file mode 100644 index 0000000..17705fb --- /dev/null +++ b/Tests/HaystackServerTests/HaystackServerTests.swift @@ -0,0 +1,675 @@ +import Foundation +import Haystack +import HaystackServer +import XCTest + +final class HaystackServerTests: XCTestCase { + func testAbout() async throws { + let server = HaystackServer( + recordStore: InMemoryRecordStore(), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + let response = try await server.about() + let about = try XCTUnwrap(response.first) + XCTAssertNotNil(about["haystackVersion"] as? String) + XCTAssertNotNil(about["tz"] as? String) + XCTAssertNotNil(about["serverTime"] as? DateTime) + XCTAssertNotNil(about["serverBootTime"] as? DateTime) + XCTAssertEqual(about["productName"] as? String, "swift-haystack") + XCTAssertEqual(about["productUri"] as? Uri, Uri("https://github.com/NeedleInAJayStack/swift-haystack")) + XCTAssertNotNil(about["productVersion"] as? String) + XCTAssertEqual(about["vendorName"] as? String, "NeedleInAJayStack") + XCTAssertEqual(about["vendorUri"] as? Uri, Uri("https://github.com/NeedleInAJayStack")) + } + + func testDefs() async throws { + let server = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("a"): ["id": Ref("a"), "def": Marker.val], + Ref("b"): ["id": Ref("b"), "def": Marker.val, "foo": "bar"], + Ref("c"): ["id": Ref("c")], + ]), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + + // Test no filter + var grid = try await server.defs(filter: nil, limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["a", "b"] + ) + + // Test filter + grid = try await server.defs(filter: "foo", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }, + ["b"] + ) + + // Test bad filter + grid = try await server.defs(filter: "none", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }, + [] + ) + + // Test limit + grid = try await server.defs(filter: nil, limit: Number(0)) + XCTAssertEqual(grid.count, 0) + } + + func testLibs() async throws { + let server = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("a"): ["id": Ref("a"), "lib": Marker.val], + Ref("b"): ["id": Ref("b"), "lib": Marker.val, "foo": "bar"], + Ref("c"): ["id": Ref("c")], + ]), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + + // Test no filter + var grid = try await server.libs(filter: nil, limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["a", "b"] + ) + + // Test filter + grid = try await server.libs(filter: "foo", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }, + ["b"] + ) + + // Test bad filter + grid = try await server.libs(filter: "none", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }, + [] + ) + + // Test limit + grid = try await server.libs(filter: nil, limit: Number(0)) + XCTAssertEqual(grid.count, 0) + } + + func testOps() async throws { + let server = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("a"): ["id": Ref("a"), "def": Marker.val, "op": Marker.val], + Ref("b"): ["id": Ref("b"), "def": Marker.val, "op": Marker.val, "foo": "bar"], + Ref("c"): ["id": Ref("c")], + ]), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + + // Test no filter + var grid = try await server.ops(filter: nil, limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["a", "b"] + ) + + // Test filter + grid = try await server.ops(filter: "foo", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }, + ["b"] + ) + + // Test bad filter + grid = try await server.ops(filter: "none", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }, + [] + ) + + // Test limit + grid = try await server.ops(filter: nil, limit: Number(0)) + XCTAssertEqual(grid.count, 0) + } + + func testFiletypes() async throws { + let server = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("a"): ["id": Ref("a"), "def": Marker.val, "filetype": Marker.val], + Ref("b"): ["id": Ref("b"), "def": Marker.val, "filetype": Marker.val, "foo": "bar"], + Ref("c"): ["id": Ref("c")], + ]), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + + // Test no filter + var grid = try await server.filetypes(filter: nil, limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["a", "b"] + ) + + // Test filter + grid = try await server.filetypes(filter: "foo", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }, + ["b"] + ) + + // Test bad filter + grid = try await server.filetypes(filter: "none", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }, + [] + ) + + // Test limit + grid = try await server.filetypes(filter: nil, limit: Number(0)) + XCTAssertEqual(grid.count, 0) + } + + func testReadIds() async throws { + let server = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("a"): ["id": Ref("a"), "def": Marker.val, "filetype": Marker.val], + Ref("b"): ["id": Ref("b"), "def": Marker.val, "filetype": Marker.val, "foo": "bar"], + Ref("c"): ["id": Ref("c")], + ]), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + + let grid = try await server.read(ids: [Ref("a"), Ref("b")]) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["a", "b"] + ) + } + + func testReadFilter() async throws { + let server = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("ahu"): ["id": Ref("ahu"), "equip": Marker.val, "ahu": Marker.val], + Ref("supply-air-temp"): ["id": Ref("supply-air-temp"), "equipRef": Ref("ahu"), "point": Marker.val, "supply": Marker.val, "air": Marker.val, "temp": Marker.val, "sensor": Marker.val], + Ref("vav"): ["id": Ref("vav"), "equip": Marker.val, "vav": Marker.val], + Ref("discharge-air-temp"): ["id": Ref("supply-air-temp"), "equipRef": Ref("vav"), "point": Marker.val, "discharge": Marker.val, "air": Marker.val, "temp": Marker.val, "sensor": Marker.val], + ]), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + + // Test normal + var grid = try await server.read(filter: "equip", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["ahu", "vav"] + ) + + // Test limit + grid = try await server.read(filter: "equip", limit: Number(1)) + XCTAssertEqual(grid.count, 1) + + // Test and + grid = try await server.read(filter: "equip and ahu", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["ahu"] + ) + + // Test or + grid = try await server.read(filter: "ahu or vav", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["ahu", "vav"] + ) + + // Test path + grid = try await server.read(filter: "point and equipRef->ahu", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["supply-air-temp"] + ) + + // Test ref equality + grid = try await server.read(filter: "equipRef == @ahu", limit: nil) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["supply-air-temp"] + ) + } + + func testNav() async throws { + let server = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("site"): ["id": Ref("site"), "site": Marker.val], + Ref("equip"): ["id": Ref("equip"), "equip": Marker.val, "siteRef": Ref("site")], + Ref("point"): ["id": Ref("point"), "point": Marker.val, "equipRef": Ref("equip"), "siteRef": Ref("site")], + ]), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + + var grid = try await server.nav(navId: Ref("site")) + // TODO: Implement nav +// XCTAssertEqual( +// grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), +// ["equip"] +// ) + + grid = try await server.nav(navId: Ref("equip")) + // TODO: Implement nav +// XCTAssertEqual( +// grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), +// ["point"] +// ) + } + + func testHisRead() async throws { + // Test absolute time ranges + + let absoluteServer = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("point"): ["id": Ref("point")], + ]), + historyStore: InMemoryHistoryStore([ + Ref("point"): [ + HisItem(ts: DateTime("2025-05-09T12:00:00-07:00"), val: Number(1)), + HisItem(ts: DateTime("2025-05-10T00:00:00-07:00"), val: Number(2)), + HisItem(ts: DateTime("2025-05-10T12:00:00-07:00"), val: Number(3)), + HisItem(ts: DateTime("2025-05-11T00:00:00-07:00"), val: Number(4)), + ] + ]), + watchStore: InMemoryWatchStore() + ) + + // After + var grid = try await absoluteServer.hisRead(id: Ref("point"), range: .after(DateTime("2025-05-09T17:00:00-07:00"))) + try XCTAssertEqual( + grid.rows, + [ + ["ts": DateTime("2025-05-10T00:00:00-07:00"), "val": Number(2)], + ["ts": DateTime("2025-05-10T12:00:00-07:00"), "val": Number(3)], + ["ts": DateTime("2025-05-11T00:00:00-07:00"), "val": Number(4)], + ] + ) + + // Date + grid = try await absoluteServer.hisRead(id: Ref("point"), range: .date(Date("2025-05-10"))) + try XCTAssertEqual( + grid.rows, + [ + ["ts": DateTime("2025-05-10T00:00:00-07:00"), "val": Number(2)], + ["ts": DateTime("2025-05-10T12:00:00-07:00"), "val": Number(3)], + ] + ) + + // Date Range + grid = try await absoluteServer.hisRead(id: Ref("point"), range: .dateRange(from: Date("2025-05-10"), to: Date("2025-05-11"))) + try XCTAssertEqual( + grid.rows, + [ + ["ts": DateTime("2025-05-10T00:00:00-07:00"), "val": Number(2)], + ["ts": DateTime("2025-05-10T12:00:00-07:00"), "val": Number(3)], + ["ts": DateTime("2025-05-11T00:00:00-07:00"), "val": Number(4)], + ] + ) + + // DateTime Range + grid = try await absoluteServer.hisRead(id: Ref("point"), range: .dateTimeRange(from: DateTime("2025-05-10T00:00:00-07:00"), to: DateTime("2025-05-11T00:00:00-07:00"))) + try XCTAssertEqual( + grid.rows, + [ + ["ts": DateTime("2025-05-10T00:00:00-07:00"), "val": Number(2)], + ["ts": DateTime("2025-05-10T12:00:00-07:00"), "val": Number(3)], + ] + ) + + // Test relative time ranges + + let now = Date.now + let yesterday = now.addingTimeInterval(-60*60*24) + let dayBeforeYesterday = now.addingTimeInterval(-60*60*24*2) + let tomorrow = now.addingTimeInterval(60*60*24) + + let relativeServer = try HaystackServer( + recordStore: InMemoryRecordStore([ + Ref("point"): ["id": Ref("point")], + ]), + historyStore: InMemoryHistoryStore([ + Ref("point"): [ + HisItem(ts: DateTime(date: dayBeforeYesterday), val: Number(1)), + HisItem(ts: DateTime(date: yesterday), val: Number(2)), + HisItem(ts: DateTime(date: now), val: Number(3)), + HisItem(ts: DateTime(date: tomorrow), val: Number(4)), + ] + ]), + watchStore: InMemoryWatchStore() + ) + + grid = try await relativeServer.hisRead(id: Ref("point"), range: .today) + XCTAssertEqual( + grid.rows, + [ + ["ts": DateTime(date: now), "val": Number(3)], + ] + ) + + grid = try await relativeServer.hisRead(id: Ref("point"), range: .yesterday) + XCTAssertEqual( + grid.rows, + [ + ["ts": DateTime(date: yesterday), "val": Number(2)], + ] + ) + } + + func testPointWrite() async throws { + // TODO: Implement + } + + func testPointWriteStatus() async throws { + // TODO: Implement + } + + // Test all watch methods together to avoid state complexities + func testWatch() async throws { + let idA = try Ref("a") + let idB = try Ref("b") + let idC = try Ref("c") + let recA: Dict = ["id": idA, "def": Marker.val] + let recB: Dict = ["id": idB, "def": Marker.val, "foo": "bar"] + let recC: Dict = ["id": idC] + let recordStore = InMemoryRecordStore([ + idA: recA, + idB: recB, + idC: recC, + ]) + let server = HaystackServer( + recordStore: recordStore, + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore() + ) + + // Create the watch and validate A and B are returned + var grid = try await server.watchSubCreate(watchDis: "ab", lease: nil, ids: [idA, idB]) + let watchId = try XCTUnwrap(grid.meta["watchId"] as? String) + XCTAssertEqual(grid.meta, ["watchId": watchId, "lease": null]) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["a", "b"] + ) + + // Change B to increment "mod" + var newB = recB + newB["new"] = Marker.val + _ = try await recordStore.commitAll(diffs: [ + .init(id: idB, old: recB, new: newB) + ]) + + // Check that subsequent poll picks up B + grid = try await server.watchPoll(watchId: watchId, refresh: false) + XCTAssertEqual(grid.meta, ["watchId": watchId]) + XCTAssertEqual(grid.count, 1) + XCTAssertEqual(grid.first?["id"] as? Ref, idB) + XCTAssertNotNil(grid.first?["new"]) + + // Add C to the watch and validate C is returned + grid = try await server.watchSubAdd(watchId: watchId, lease: nil, ids: [idC]) + XCTAssertEqual(grid.meta, ["watchId": watchId, "lease": null]) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["c"] + ) + + // Change C to increment "mod" + var newC = recC + newC["new"] = Marker.val + _ = try await recordStore.commitAll(diffs: [ + .init(id: idC, old: recC, new: newC) + ]) + + // Validate poll picks up C + grid = try await server.watchPoll(watchId: watchId, refresh: false) + XCTAssertEqual(grid.meta, ["watchId": watchId]) + XCTAssertEqual(grid.count, 1) + XCTAssertEqual(grid.first?["id"] as? Ref, idC) + XCTAssertNotNil(grid.first?["new"]) + + // Remove A from watch + grid = try await server.watchUnsubRemove(watchId: watchId, ids: [Ref("a")]) + XCTAssertTrue(grid.isEmpty) + + // Change A to increment "mod" + var newA = recA + newA["new"] = Marker.val + _ = try await recordStore.commitAll(diffs: [ + .init(id: idA, old: recA, new: newA) + ]) + + // Validate poll does not pick up A + grid = try await server.watchPoll(watchId: watchId, refresh: false) + XCTAssertEqual(grid.meta, ["watchId": watchId]) + XCTAssertEqual(grid.count, 0) + + // Test that poll with refresh gives B and C + grid = try await server.watchPoll(watchId: watchId, refresh: true) + XCTAssertEqual(grid.meta, ["watchId": watchId]) + XCTAssertEqual( + grid.compactMap { ($0["id"] as? Ref)?.val }.sorted(), + ["b", "c"] + ) + } + + func testInvokeAction() async throws { + let server = HaystackServer( + recordStore: InMemoryRecordStore(), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore(), + onInvokeAction: { id, action, args in + let gb = GridBuilder() + try gb.addRow(["id": id, "action": action, "args": Dict(args)]) + return gb.toGrid() + } + ) + let grid = try await server.invokeAction(id: Ref("1"), action: "a", args: ["foo": "bar"]) + try XCTAssertEqual(grid[0]["id"] as? Ref, Ref("1")) + XCTAssertEqual(grid[0]["action"] as? String, "a") + XCTAssertEqual(grid[0]["args"] as? Dict, Dict(["foo": "bar"])) + } + + func testEval() async throws { + let server = HaystackServer( + recordStore: InMemoryRecordStore(), + historyStore: InMemoryHistoryStore(), + watchStore: InMemoryWatchStore(), + onEval: { expression in + let gb = GridBuilder() + try gb.addRow(["foo": "bar"]) + return gb.toGrid() + } + ) + let grid = try await server.eval(expression: "anything") + XCTAssertEqual( + grid, + [ + ["foo": "bar"], + ] + ) + } +} + +/// This is a super inefficient record store based on an in-memory list of Diffs +/// +/// Any change automatically updates the `mod` DateTime tag on the record +class InMemoryRecordStore: RecordStore { + var recs: [Haystack.Ref: Haystack.Dict] = [:] + + init() {} + + init(_ dicts: [Haystack.Ref: Haystack.Dict]) { + self.recs = [:] + for (k, v) in dicts { + var dictWithMod = v + dictWithMod["mod"] = DateTime(date: .now) + self.recs[k] = dictWithMod + } + } + + func read(ids: [Haystack.Ref]) async throws -> [Haystack.Dict] { + return try ids.map { id in + guard let rec = recs[id] else { + throw ServerError.idNotFound(id) + } + return rec + } + } + + func read(filter: String, limit: Haystack.Number?) async throws -> [Haystack.Dict] { + let filter = try FilterFactory.make(filter) + var dicts = [Dict]() + for rec in recs.values { + if let limit = limit, dicts.count >= Int(limit.val) { + break + } + if try filter.include( + dict: rec, + pather: { ref in + return try? self.recs[Ref(ref)] + } + ) { + dicts.append(rec) + } + } + return dicts + } + + func commitAll(diffs: [RecordDiff]) async throws -> [RecordDiff] { + var updatedDiffs: [RecordDiff] = [] + for diff in diffs { + var diffWithMod = diff.new + diffWithMod["mod"] = DateTime(date: .now) + if let oldRec = recs[diff.id] { + recs[diff.id] = diffWithMod + updatedDiffs.append(RecordDiff(id: diff.id, old: oldRec, new: diffWithMod)) + } else { + recs[diff.id] = diffWithMod + updatedDiffs.append(RecordDiff(id: diff.id, old: nil, new: diffWithMod)) + } + } + return updatedDiffs + } +} + +/// This is a super inefficient history store based on an in-memory list of histories +class InMemoryHistoryStore: HistoryStore { + /// Maps Refs to histories. These histories are not assumed to be ordered in time (for simplicity). + var histories: [Ref: [HisItem]] + + init() { + self.histories = [:] + } + + init(_ histories: [Ref: [HisItem]] = [:]) { + self.histories = histories + } + + func hisRead(id: Ref, range: HisReadRange) async throws -> [Dict] { + let start = range.start() + let end = range.end() + let history = histories[id] ?? [] + return history.filter { item in + var inRange = true + if let start = start, item.ts.date < start { + inRange = false + } + if let end = end, end <= item.ts.date { + inRange = false + } + return inRange + }.sorted { + $0.ts < $1.ts + }.map { + $0.toDict() + } + } + + func hisWrite(id: Ref, items: [HisItem]) async throws { + var history = histories[id] ?? [] + history.append(contentsOf: items) + histories[id] = history + } +} + +/// This is a super inefficient history store based on an in-memory list of histories +class InMemoryWatchStore: WatchStore { + /// Maps watch IDs to a list of Refs. This is used to track which records are being watched. + var watches: [String: Watch] = [:] + + init() { + self.watches = [:] + } + + init(_ watches: [String: Watch] = [:]) { + self.watches = watches + } + + func read(watchId: String) async throws -> WatchResponse { + guard let watch = watches[watchId] else { + throw ServerError.watchNotFound(watchId) + } + return WatchResponse(ids: watch.ids, lease: watch.lease, lastReported: watch.lastReported) + } + + func create(ids: [Haystack.Ref], lease: Haystack.Number?) async throws -> String { + let watchId = UUID().uuidString + watches[watchId] = Watch(id: watchId, ids: ids, lease: lease) + return watchId + } + + func addIds(watchId: String, ids: [Haystack.Ref]) async throws { + guard var watch = watches[watchId] else { + throw ServerError.watchNotFound(watchId) + } + watch.ids.append(contentsOf: ids) + watches[watchId] = watch + } + + func removeIds(watchId: String, ids: [Haystack.Ref]) async throws { + guard var watch = watches[watchId] else { + throw ServerError.watchNotFound(watchId) + } + watch.ids.removeAll { id in + ids.contains(id) + } + watches[watchId] = watch + } + + func updateLastReported(watchId: String) async throws { + guard var watch = watches[watchId] else { + throw ServerError.watchNotFound(watchId) + } + watch.lastReported = .now + watches[watchId] = watch + } + + func delete(watchId: String) async throws { + watches[watchId] = nil + } + + public struct Watch: Hashable { + let id: String + var ids: [Haystack.Ref] + let lease: Haystack.Number + var lastReported: Foundation.Date? + + init(id: String, ids: [Haystack.Ref], lease: Haystack.Number?) { + self.id = id + self.ids = ids + self.lease = lease ?? Number(1, unit: "hr") + self.lastReported = nil + } + } +} diff --git a/Tests/HaystackServerVaporTests/HaystackServerVaporTests.swift b/Tests/HaystackServerVaporTests/HaystackServerVaporTests.swift new file mode 100644 index 0000000..8e77dc8 --- /dev/null +++ b/Tests/HaystackServerVaporTests/HaystackServerVaporTests.swift @@ -0,0 +1,245 @@ +import Haystack +import HaystackServerVapor +import XCTest +import XCTVapor + +final class HaystackServerVaporTests: XCTestCase { + func testGet() throws { + let app = Application(.testing) + try app.register(collection: HaystackRouteCollection(delegate: HaystackAPIMock())) + defer { app.shutdown() } + + let responseGrid = try GridBuilder() + .addCols(names: ["id", "foo"]) + .addRow([Haystack.Ref("a"), Marker.val]) + .addRow([Haystack.Ref("b"), Marker.val]) + .toGrid() + + // Test zinc encoding + try app.test( + .GET, + "/read?id=[@a,@b]", + headers: [ + HTTPHeaders.Name.accept.description: HTTPMediaType.zinc.description + ] + ) { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqual(res.headers.contentType, .zinc) + XCTAssertEqual( + res.body.string, + responseGrid.toZinc() + ) + } + + // Test JSON encoding + try app.test( + .GET, + "/read?id=[@a,@b]", + headers: [ + HTTPHeaders.Name.accept.description: HTTPMediaType.json.description + ] + ) { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqual(res.headers.contentType, .json) + try XCTAssertEqual( + res.content.decode(Grid.self), + responseGrid + ) + } + } + + func testGetBadQuery() throws { + let app = Application(.testing) + try app.register(collection: HaystackRouteCollection(delegate: HaystackAPIMock())) + defer { app.shutdown() } + + try app.test( + .GET, + "/read?id=[a,b]" // Invalid because expecting Ref, not String + ) { res in + XCTAssertEqual(res.status, .badRequest) + } + } + + func testPost() throws { + let app = Application(.testing) + try app.register(collection: HaystackRouteCollection(delegate: HaystackAPIMock())) + defer { app.shutdown() } + + let requestGrid = try GridBuilder() + .addCol(name: "id") + .addRow([Haystack.Ref("a")]) + .addRow([Haystack.Ref("b")]) + .toGrid() + + let responseGrid = try GridBuilder() + .addCols(names: ["id", "foo"]) + .addRow([Haystack.Ref("a"), Marker.val]) + .addRow([Haystack.Ref("b"), Marker.val]) + .toGrid() + + // Test zinc encoding + try app.test( + .POST, + "/read", + headers: [ + HTTPHeaders.Name.accept.description: HTTPMediaType.zinc.description + ], + body: .init(string: requestGrid.toZinc()), + beforeRequest: { req in + req.headers.contentType = .zinc + } + ) { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqual(res.headers.contentType, .zinc) + XCTAssertEqual( + res.body.string, + responseGrid.toZinc() + ) + } + + // Test JSON encoding + try app.test( + .POST, + "/read", + headers: [ + HTTPHeaders.Name.accept.description: HTTPMediaType.json.description + ], + beforeRequest: { req in + req.headers.contentType = .json + try req.content.encode(requestGrid) + } + ) { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqual(res.headers.contentType, .json) + try XCTAssertEqual( + res.content.decode(Grid.self), + responseGrid + ) + } + } + + func testPostBadQuery() throws { + let app = Application(.testing) + try app.register(collection: HaystackRouteCollection(delegate: HaystackAPIMock())) + defer { app.shutdown() } + + let requestGrid = try GridBuilder() + .addCol(name: "id") + .addRow(["a"]) // Invalid because expecting Ref, not String + .addRow(["b"]) + .toGrid() + + // Test zinc encoding + try app.test( + .POST, + "/read", + body: .init(string: requestGrid.toZinc()), + beforeRequest: { req in + req.headers.contentType = .zinc + } + ) { res in + XCTAssertEqual(res.status, .badRequest) + } + + // Test JSON encoding + try app.test( + .POST, + "/read", + beforeRequest: { req in + req.headers.contentType = .json + try req.content.encode(requestGrid) + } + ) { res in + XCTAssertEqual(res.status, .badRequest) + } + } +} + +struct HaystackAPIMock: Haystack.API { + func close() async throws { + return + } + + func about() async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func defs(filter: String?, limit: Haystack.Number?) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func libs(filter: String?, limit: Haystack.Number?) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func ops(filter: String?, limit: Haystack.Number?) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func filetypes(filter: String?, limit: Haystack.Number?) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func read(ids: [Haystack.Ref]) async throws -> Haystack.Grid { + let gb = GridBuilder() + try gb.addCol(name: "id") + try gb.addCol(name: "foo") + for id in ids { + try gb.addRow([id, Marker.val]) + } + return gb.toGrid() + } + + func read(filter: String, limit: Haystack.Number?) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func nav(navId: Haystack.Ref?) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func hisRead(id: Haystack.Ref, range: Haystack.HisReadRange) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func hisWrite(id: Haystack.Ref, items: [Haystack.HisItem]) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func pointWrite(id: Haystack.Ref, level: Haystack.Number, val: any Haystack.Val, who: String?, duration: Haystack.Number?) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func pointWriteStatus(id: Haystack.Ref) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func watchSubCreate(watchDis: String, lease: Haystack.Number?, ids: [Haystack.Ref]) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func watchSubAdd(watchId: String, lease: Haystack.Number?, ids: [Haystack.Ref]) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func watchUnsubRemove(watchId: String, ids: [Haystack.Ref]) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func watchUnsubDelete(watchId: String) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func watchPoll(watchId: String, refresh: Bool) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func invokeAction(id: Haystack.Ref, action: String, args: [String : any Haystack.Val]) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } + + func eval(expression: String) async throws -> Haystack.Grid { + return GridBuilder().toGrid() + } +} diff --git a/Tests/HaystackTests/DictTests.swift b/Tests/HaystackTests/DictTests.swift index ae0b124..d0ec793 100644 --- a/Tests/HaystackTests/DictTests.swift +++ b/Tests/HaystackTests/DictTests.swift @@ -150,4 +150,33 @@ final class DictTests: XCTestCase { try XCTAssertNil(dict.get("b")) try XCTAssertNil(dict.get("c")) } + + func testCollection() { + let dict: Dict = [ + "bool": true, + "str": "abc", + "number": Number(42, unit: "furloghs"), + "dict": Dict([ + "bool": true, + "str": "xyz" + ]) + ] + + // Test index access + XCTAssertEqual(dict["bool"] as? Bool, true) + XCTAssertEqual(dict["str"] as? String, "abc") + XCTAssertEqual(dict["number"] as? Number, Number(42, unit: "furloghs")) + XCTAssertEqual(dict["dict"] as? Dict, ["bool": true, "str": "xyz"]) + + // Test loop + for (key, value) in dict { + switch key { + case "bool": XCTAssertEqual(value as? Bool, true) + case "str": XCTAssertEqual(value as? String, "abc") + case "number": XCTAssertEqual((value as? Number), Number(42, unit: "furloghs")) + case "dict": XCTAssertEqual((value as? Dict), ["bool": true, "str": "xyz"]) + default: break + } + } + } } diff --git a/Tests/HaystackTests/FilterTests.swift b/Tests/HaystackTests/FilterTests.swift new file mode 100644 index 0000000..92c897b --- /dev/null +++ b/Tests/HaystackTests/FilterTests.swift @@ -0,0 +1,265 @@ +import Haystack +import XCTest + +final class FilterTests: XCTestCase { + func testIdentity() throws { + try XCTAssertTrue(FilterFactory.has("a").equals(FilterFactory.has("a"))) + try XCTAssertFalse(FilterFactory.has("a").equals(FilterFactory.has("b"))) + } + + func testBasics() throws { + try verifyParse("x", FilterFactory.has("x")) + try verifyParse("foo", FilterFactory.has("foo")) + try verifyParse("fooBar", FilterFactory.has("fooBar")) + try verifyParse("foo7Bar", FilterFactory.has("foo7Bar")) + try verifyParse("foo_bar->a", FilterFactory.has("foo_bar->a")) + try verifyParse("a->b->c", FilterFactory.has("a->b->c")) + try verifyParse("not foo", FilterFactory.missing("foo")) + } + + func testZincOnlyLiteralsDontWork() throws { + try XCTAssertThrowsError(FilterFactory.make("x==T")) + try XCTAssertThrowsError(FilterFactory.make("x==F")) + try XCTAssertThrowsError(FilterFactory.make("x==F")) + } + + func testBool() throws { + try verifyParse("x->y==true", FilterFactory.eq("x->y", true)) + try verifyParse("x->y!=false", FilterFactory.ne("x->y", false)) + } + + func testStr() throws { + try verifyParse("x==\"hi\"", FilterFactory.eq("x", "hi")) + try verifyParse("x!=\"\\\"hi\\\"\"", FilterFactory.ne("x", "\"hi\"")) + try verifyParse("x==\"_\\uabcd_\\n_\"", FilterFactory.eq("x", "_\u{abcd}_\n_")) + } + + func testUri() throws { + try verifyParse("ref==`http://foo/?bar`", FilterFactory.eq("ref", Uri("http://foo/?bar"))) + try verifyParse("ref->x==`file name`", FilterFactory.eq("ref->x", Uri("file name"))) + try verifyParse("ref == `foo bar`", FilterFactory.eq("ref", Uri("foo bar"))) + } + + func testInt() throws { + try verifyParse("num < 4", FilterFactory.lt("num", Number(4))) + try verifyParse("num <= -99", FilterFactory.le("num", Number(-99))) + } + + func testFloat() throws { + try verifyParse("num < 4.0", FilterFactory.lt("num", Number(4.0))) + try verifyParse("num <= -9.6", FilterFactory.le("num", Number(-9.6))) + try verifyParse("num > 400000", FilterFactory.gt("num", Number(4e5))) + try verifyParse("num >= 16000", FilterFactory.ge("num", Number(1.6e+4))) + try verifyParse("num >= 2.16", FilterFactory.ge("num", Number(2.16))) + } + + func testUnit() throws { + try verifyParse("dur < 5ns", FilterFactory.lt("dur", Number(5, unit: "ns"))) + try verifyParse("dur < 10kg", FilterFactory.lt("dur", Number(10, unit: "kg"))) + try verifyParse("dur < -9sec", FilterFactory.lt("dur", Number(-9, unit: "sec"))) + try verifyParse("dur < 2.5hr", FilterFactory.lt("dur", Number(2.5, unit: "hr"))) + } + + func testDateTime() throws { + try verifyParse("foo < 2009-10-30", FilterFactory.lt("foo", Date("2009-10-30"))) + try verifyParse("foo < 08:30:00", FilterFactory.lt("foo", Time("08:30:00"))) + try verifyParse("foo < 13:00:00", FilterFactory.lt("foo", Time("13:00:00"))) + } + + func testRef() throws { + try verifyParse("author == @xyz", FilterFactory.eq("author", Ref("xyz"))) + try verifyParse("author==@xyz:foo.bar", FilterFactory.eq("author", Ref("xyz:foo.bar"))) + } + + func testAnd() throws { + try verifyParse("a and b", FilterFactory.has("a").and(FilterFactory.has("b"))) + try verifyParse("a and b and c == 3", FilterFactory.has("a").and(FilterFactory.has("b").and(FilterFactory.eq("c", Number(3))))) + } + + func testOr() throws { + try verifyParse("a or b", FilterFactory.has("a").or(FilterFactory.has("b"))) + try verifyParse("a or b or c == 3", FilterFactory.has("a").or(FilterFactory.has("b").or(FilterFactory.eq("c", Number(3))))) + } + + func testParens() throws { + try verifyParse("(a)", FilterFactory.has("a")) + try verifyParse("(a) and (b)", FilterFactory.has("a").and(FilterFactory.has("b"))) + try verifyParse("( a ) and ( b ) ", FilterFactory.has("a").and(FilterFactory.has("b"))) + try verifyParse("(a or b) or (c == 3)", FilterFactory.has("a").or(FilterFactory.has("b")).or(FilterFactory.eq("c", Number(3)))) + } + + func testCombo() throws { + let isA = try FilterFactory.has("a") + let isB = try FilterFactory.has("b") + let isC = try FilterFactory.has("c") + let isD = try FilterFactory.has("d") + try verifyParse("a and b or c", (isA.and(isB)).or(isC)) + try verifyParse("a or b and c", isA.or(isB.and(isC))) + try verifyParse("a and b or c and d", (isA.and(isB)).or(isC.and(isD))) + try verifyParse("(a and (b or c)) and d", isA.and(isB.or(isC)).and(isD)) + try verifyParse("(a or (b and c)) or d", isA.or(isB.and(isC)).or(isD)) + } + + func verifyParse(_ s: String, _ expected: any Filter) throws { + let actual = try FilterFactory.make(s) + XCTAssertTrue(actual.equals(expected)) + } + + func testInclude() throws { + let a: Dict = try [ + "dis": "a", + "num": Number(10), + "date": Date(year: 2016, month: 1, day: 1), + "foo": "baz", + ] + + let b: Dict = try [ + "dis": "b", + "num": Number(20), + "date": Date(year: 2016, month: 1, day: 2), + "foo": Number(12), + "ref": Ref("a"), + ] + + let c: Dict = try [ + "dis": "c", + "num": Number(30), + "date": Date(year: 2016, month: 1, day: 3), + "foo": Number(13), + "ref": Ref("b"), + "thru": "c", + ] + + let d: Dict = try [ + "dis": "d", + "num": Number(30), + "date": Date(year: 2016, month: 1, day: 3), + "ref": Ref("c"), + ] + + let nested: Dict = [ + "thru": "e", + ] + let e: Dict = try [ + "dis": "e", + "num": Number(40), + "date": Date(year: 2016, month: 1, day: 6), + "ref": nested, + ] + + let db = [ + "a": a, + "b": b, + "c": c, + "d": d, + "e": e, + ] + + try verifyInclude(db, "ref->thru", "d,e") + + try verifyInclude(db, "dis", "a,b,c,d,e") + try verifyInclude(db, "foo", "a,b,c") + + try verifyInclude(db, "not dis", "") + try verifyInclude(db, "not foo", "d,e") + + try verifyInclude(db, "dis == \"c\"", "c") + try verifyInclude(db, "num == 30", "c,d") + try verifyInclude(db, "date==2016-01-02", "b") + try verifyInclude(db, "foo==12", "b") + + try verifyInclude(db, "dis != \"c\"", "a,b,d,e") + try verifyInclude(db, "num != 30", "a,b,e") + try verifyInclude(db, "date != 2016-01-02", "a,c,d,e") + try verifyInclude(db, "foo != 13", "a,b") + + try verifyInclude(db, "dis < \"c\"", "a,b") + try verifyInclude(db, "num < 20", "a") + try verifyInclude(db, "date < 2016-01-04", "a,b,c,d") + try verifyInclude(db, "foo < 13", "b") + try verifyInclude(db, "foo < \"c\"", "a") + + try verifyInclude(db, "dis <= \"c\"", "a,b,c") + try verifyInclude(db, "num <= 20", "a,b") + try verifyInclude(db, "date <= 2016-01-02", "a,b") + try verifyInclude(db, "foo <= 13", "b,c") + try verifyInclude(db, "foo <= \"baz\"", "a") + + try verifyInclude(db, "dis > \"c\"", "d,e") + try verifyInclude(db, "num > 20", "c,d,e") + try verifyInclude(db, "date > 2016-01-02", "c,d,e") + try verifyInclude(db, "foo > 12", "c") + try verifyInclude(db, "foo > \"a\"", "a") + + try verifyInclude(db, "dis >= \"c\"", "c,d,e") + try verifyInclude(db, "num >= 20", "b,c,d,e") + try verifyInclude(db, "date >= 2016-01-02", "b,c,d,e") + try verifyInclude(db, "foo >= 12", "b,c") + try verifyInclude(db, "foo >= \"baz\"", "a") + + try verifyInclude(db, "dis==\"c\" or num == 30", "c,d") + try verifyInclude(db, "dis==\"c\" and num == 30", "c") + try verifyInclude(db, "dis==\"c\" or num == 30 or dis==\"b\"", "b,c,d") + try verifyInclude(db, "dis==\"c\" and num == 30 and foo==13", "c") + try verifyInclude(db, "dis==\"c\" and num == 30 and foo==12", "") + try verifyInclude(db, "dis==\"c\" and num == 30 or foo==12", "b,c") + try verifyInclude(db, "(dis==\"c\" or num == 30) and not foo", "d") + try verifyInclude(db, "(num == 30 and foo) or (num <= 10)", "a,c") + + try verifyInclude(db, "ref->dis == \"a\"", "b") + try verifyInclude(db, "ref->ref->dis == \"a\"", "c") + try verifyInclude(db, "ref->ref->ref->dis == \"a\"", "d") + try verifyInclude(db, "ref->num <= 20", "b,c") + try verifyInclude(db, "ref->thru", "d,e") + try verifyInclude(db, "ref->thru == \"e\"", "e") + } + + func verifyInclude(_ map: [String: Dict], _ query: String, _ expected: String) throws { + let q = try FilterFactory.make(query) + + var actual = "" + for id in ["a", "b", "c", "d", "e"] { + if + let dict = map[id], + try q.include( + dict: dict, + pather: { ref in + map[ref] + } + ) + { + if actual.count > 0 { + actual += "," + } + actual += id + } + } + XCTAssertEqual(actual, expected) + } + + func testPath() throws { + // single name + var path = try Path.make(path: "foo") + XCTAssertEqual(path.count, 1) + XCTAssertEqual(path[0], "foo") + XCTAssertEqual(path.description, "foo") + try XCTAssertEqual(path, Path.make(path: "foo")) + + // two names + path = try Path.make(path: "foo->bar") + XCTAssertEqual(path.count, 2) + XCTAssertEqual(path[0], "foo") + XCTAssertEqual(path[1], "bar") + XCTAssertEqual(path.description, "foo->bar") + try XCTAssertEqual(path, Path.make(path: "foo->bar")) + + // three names + path = try Path.make(path: "x->y->z") + XCTAssertEqual(path.count, 3) + XCTAssertEqual(path[0], "x") + XCTAssertEqual(path[1], "y") + XCTAssertEqual(path[2], "z") + XCTAssertEqual(path.description, "x->y->z") + try XCTAssertEqual(path, Path.make(path: "x->y->z")) + } +} diff --git a/Tests/HaystackTests/GridTests.swift b/Tests/HaystackTests/GridTests.swift index 46a7b07..e2c9c39 100644 --- a/Tests/HaystackTests/GridTests.swift +++ b/Tests/HaystackTests/GridTests.swift @@ -109,4 +109,30 @@ final class GridTests: XCTestCase { builder2.toGrid() ) } + + func testCollection() throws { + let grid = try GridBuilder() + .setMeta(["ver": "3.0", "foo": "bar"]) + .addCol(name: "dis", meta: ["dis": "Equip Name"]) + .addCol(name: "equip") + .addCol(name: "siteRef") + .addCol(name: "managed") + .addRow(["dis": "RTU-1", "equip": marker, "siteRef": Ref("153c-699a", dis: "HQ"), "managed": true]) + .addRow(["dis": "RTU-2", "equip": marker, "siteRef": Ref("153c-699b", dis: "Library"), "managed": false]) + .toGrid() + + // Test index access + try XCTAssertEqual(grid[0], ["dis": "RTU-1", "equip": marker, "siteRef": Ref("153c-699a", dis: "HQ"), "managed": true]) + try XCTAssertEqual(grid[1], ["dis": "RTU-2", "equip": marker, "siteRef": Ref("153c-699b", dis: "Library"), "managed": false]) + XCTAssertEqual(grid[0]["dis"] as? String, "RTU-1") + + // Test loop + for (i, row) in grid.enumerated() { + switch i { + case 0: try XCTAssertEqual(row, ["dis": "RTU-1", "equip": marker, "siteRef": Ref("153c-699a", dis: "HQ"), "managed": true]) + case 1: try XCTAssertEqual(row, ["dis": "RTU-2", "equip": marker, "siteRef": Ref("153c-699b", dis: "Library"), "managed": false]) + default: break + } + } + } } diff --git a/Tests/HaystackTests/ListTests.swift b/Tests/HaystackTests/ListTests.swift index 58ab79f..6423dbe 100644 --- a/Tests/HaystackTests/ListTests.swift +++ b/Tests/HaystackTests/ListTests.swift @@ -128,4 +128,30 @@ final class ListTests: XCTestCase { ]) ) } + + func testCollection() { + let list: List = [ + true, + "abc", + Number(42, unit: "furloghs"), + List([true, "xyz"]) + ] + + // Test index access + XCTAssertEqual(list[0] as? Bool, true) + XCTAssertEqual(list[1] as? String, "abc") + XCTAssertEqual((list[2] as? Number), Number(42, unit: "furloghs")) + XCTAssertEqual((list[3] as? List), [true, "xyz"]) + + // Test loop + for (i, element) in list.enumerated() { + switch i { + case 0: XCTAssertEqual(element as? Bool, true) + case 1: XCTAssertEqual(element as? String, "abc") + case 2: XCTAssertEqual((element as? Number), Number(42, unit: "furloghs")) + case 3: XCTAssertEqual((element as? List), [true, "xyz"]) + default: break + } + } + } }