diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..b67469b
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,16 @@
+name: test
+on:
+ pull_request:
+ push: { branches: [ main ] }
+
+jobs:
+ test:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: fwal/setup-swift@v1
+ - uses: actions/checkout@v2
+ - name: Run tests
+ run: swift test
diff --git a/.gitignore b/.gitignore
index 3b29812..5e81697 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,5 @@
/*.xcodeproj
xcuserdata/
DerivedData/
-.swiftpm/config/registries.json
-.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+.swiftpm/
diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
deleted file mode 100644
index 18d9810..0000000
--- a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
- IDEDidComputeMac32BitWarning
-
-
-
diff --git a/Package.swift b/Package.swift
index cc3381f..944cb6c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -9,6 +9,10 @@ let package = Package(
name: "Haystack",
targets: ["Haystack"]
),
+ .library(
+ name: "HaystackClient",
+ targets: ["HaystackClient"]
+ ),
],
dependencies: [],
targets: [
@@ -16,9 +20,21 @@ let package = Package(
name: "Haystack",
dependencies: []
),
+ .target(
+ name: "HaystackClient",
+ dependencies: ["Haystack"]
+ ),
.testTarget(
name: "HaystackTests",
dependencies: ["Haystack"]
),
+ .testTarget(
+ name: "HaystackClientTests",
+ dependencies: ["HaystackClient"]
+ ),
+ .testTarget(
+ name: "HaystackClientIntegrationTests",
+ dependencies: ["HaystackClient"]
+ ),
]
)
diff --git a/Sources/Haystack/Grid.swift b/Sources/Haystack/Grid.swift
index c508104..dd3dd6b 100644
--- a/Sources/Haystack/Grid.swift
+++ b/Sources/Haystack/Grid.swift
@@ -5,6 +5,8 @@ import Foundation
/// unit of data exchange over the
/// [HTTP API](https://project-haystack.org/doc/docHaystack/HttpApi).
///
+/// To create a Grid, use a `GridBuilder`.
+///
/// [Docs](https://project-haystack.org/doc/docHaystack/Kinds#grid)
public struct Grid: Val {
public static var valType: ValType { .Grid }
@@ -22,30 +24,39 @@ public struct Grid: Val {
/// Converts to Zinc formatted string.
/// See [Zinc Literals](https://project-haystack.org/doc/docHaystack/Zinc#literals)
public func toZinc() -> String {
- var zinc = #"ver:"3.0""#
- if meta.elements.count > 0 {
- zinc += " \(meta.toZinc(withBraces: false))"
- }
- zinc += "\n"
+ // Ensure `ver` is listed first in meta
+ let ver = meta.elements["ver"] ?? "3.0"
+ var zinc = "ver:\(ver.toZinc())"
- let zincCols = cols.map { col in
- var colZinc = col.name
- if let colMeta = col.meta, colMeta.elements.count > 0 {
- colZinc += " \(colMeta.toZinc(withBraces: false))"
- }
- return colZinc
+ var metaWithoutVer = meta.elements
+ metaWithoutVer["ver"] = nil
+ if metaWithoutVer.count > 0 {
+ zinc += " \(Dict(metaWithoutVer).toZinc(withBraces: false))"
}
- zinc += zincCols.joined(separator: ", ")
zinc += "\n"
- let zincRows = rows.map { row in
- let rowZincElements = cols.map { col in
- let element = row.elements[col.name] ?? null
- return element.toZinc()
+ if cols.isEmpty {
+ zinc += "empty\n"
+ } else {
+ let zincCols = cols.map { col in
+ var colZinc = col.name
+ if let colMeta = col.meta, colMeta.elements.count > 0 {
+ colZinc += " \(colMeta.toZinc(withBraces: false))"
+ }
+ return colZinc
}
- return rowZincElements.joined(separator: ", ")
+ zinc += zincCols.joined(separator: ", ")
+ zinc += "\n"
+
+ let zincRows = rows.map { row in
+ let rowZincElements = cols.map { col in
+ let element = row.elements[col.name] ?? null
+ return element.toZinc()
+ }
+ return rowZincElements.joined(separator: ", ")
+ }
+ zinc += zincRows.joined(separator: "\n")
}
- zinc += zincRows.joined(separator: "\n")
return zinc
}
@@ -77,8 +88,14 @@ extension Grid {
}
self.meta = try container.decode(Dict.self, forKey: .meta)
- self.cols = try container.decode([Col].self, forKey: .cols)
- self.rows = try container.decode([Dict].self, forKey: .rows)
+ let cols = try container.decode([Col].self, forKey: .cols)
+ if cols.map(\.name) == ["empty"] {
+ self.cols = []
+ self.rows = []
+ } else {
+ self.cols = cols
+ self.rows = try container.decode([Dict].self, forKey: .rows)
+ }
} else {
throw DecodingError.typeMismatch(
Self.self,
@@ -96,8 +113,13 @@ extension Grid {
var container = encoder.container(keyedBy: Self.CodingKeys)
try container.encode(Self.kindValue, forKey: ._kind)
try container.encode(meta, forKey: .meta)
- try container.encode(cols, forKey: .cols)
- try container.encode(rows, forKey: .rows)
+ if cols.isEmpty {
+ try container.encode([Col(name: "empty")], forKey: .cols)
+ try container.encode([Dict](), forKey: .rows)
+ } else {
+ try container.encode(cols, forKey: .cols)
+ try container.encode(rows, forKey: .rows)
+ }
}
}
diff --git a/Sources/Haystack/IO/ZincReader.swift b/Sources/Haystack/IO/ZincReader.swift
index eb9c8a8..3037e12 100644
--- a/Sources/Haystack/IO/ZincReader.swift
+++ b/Sources/Haystack/IO/ZincReader.swift
@@ -127,10 +127,10 @@ public class ZincReader {
private func parseLiteral() throws -> any Val {
var val = self.curVal
if cur == .ref, peek == .str {
- guard let refVal = curVal as? String, let dis = peekVal as? String else {
+ guard let refVal = curVal as? Ref, let dis = peekVal as? String else {
throw ZincReaderError.invalidRef
}
- val = try Ref(refVal, dis: dis)
+ val = try Ref(refVal.val, dis: dis)
try consume(.ref)
}
try consume()
@@ -139,6 +139,7 @@ public class ZincReader {
private func parseList() throws -> List {
var elements = [any Val]()
+ try consume(.lbracket)
while cur != .rbracket, cur != .eof {
try elements.append(parseVal())
guard cur == .comma else {
diff --git a/Sources/Haystack/Number.swift b/Sources/Haystack/Number.swift
index e2c1b6a..b6ef7bb 100644
--- a/Sources/Haystack/Number.swift
+++ b/Sources/Haystack/Number.swift
@@ -51,6 +51,10 @@ public struct Number: Val {
}
return zinc
}
+
+ public var isInt: Bool {
+ return val == val.rounded()
+ }
}
// Number + Codable
diff --git a/Sources/Haystack/Utils/GridBuilder.swift b/Sources/Haystack/Utils/GridBuilder.swift
index c330250..7fa8347 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 = [:]
+ meta = ["ver":"3.0"] // We don't back-support old grid versions
colNames = []
colMeta = [:]
rows = []
@@ -17,6 +17,15 @@ public class GridBuilder {
/// Construct a grid from the assets of this instance
/// - Returns: The resulting grid
public func toGrid() -> Grid {
+ // empty grid handler
+ if colNames == ["empty"] {
+ return Grid(
+ meta: Dict(meta),
+ cols: [],
+ rows: []
+ )
+ }
+
let cols = colNames.map { colName in
if let meta = colMeta[colName] {
return Col(name: colName, meta: Dict(meta))
diff --git a/Sources/HaystackClient/Authentication/AuthHash.swift b/Sources/HaystackClient/Authentication/AuthHash.swift
new file mode 100644
index 0000000..c64ef69
--- /dev/null
+++ b/Sources/HaystackClient/Authentication/AuthHash.swift
@@ -0,0 +1,16 @@
+import CryptoKit
+
+@available(macOS 10.15, *)
+enum AuthHash: String {
+ case SHA512 = "SHA-512"
+ case SHA256 = "SHA-256"
+
+ var hash: any HashFunction.Type {
+ switch self {
+ case .SHA256:
+ return CryptoKit.SHA256.self
+ case .SHA512:
+ return CryptoKit.SHA512.self
+ }
+ }
+}
diff --git a/Sources/HaystackClient/Authentication/AuthMessage.swift b/Sources/HaystackClient/Authentication/AuthMessage.swift
new file mode 100644
index 0000000..add025b
--- /dev/null
+++ b/Sources/HaystackClient/Authentication/AuthMessage.swift
@@ -0,0 +1,25 @@
+struct AuthMessage: CustomStringConvertible {
+ let scheme: String
+ let attributes: [String: String]
+
+ var description: String {
+ // Unwrap is safe because attributes is immutable
+ "\(scheme) \(attributes.keys.sorted().map { "\($0)=\(attributes[$0]!)" }.joined(separator: ", "))"
+ }
+
+ static func from(_ string: String) throws -> Self {
+ // Example input: "SCRAM hash=SHA-256, handshakeToken=aabbcc"
+ let scheme: String
+ let attributes: [String: String]
+ // If space exists then parse attributes as well.
+ if let spaceIndex = string.firstIndex(of: " ") {
+ scheme = String(string[.. String
+}
diff --git a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift
new file mode 100644
index 0000000..a47755f
--- /dev/null
+++ b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift
@@ -0,0 +1,141 @@
+import CryptoKit
+import Foundation
+
+@available(macOS 13.0, *)
+struct ScramAuthenticator: Authenticator {
+ let url: URL
+ let username: String
+ let password: String
+ let handshakeToken: String
+ let session: URLSession
+
+ init(
+ url: URL,
+ username: String,
+ password: String,
+ handshakeToken: String,
+ session: URLSession
+ ) {
+ self.url = url
+ self.username = username
+ self.password = password
+ self.handshakeToken = handshakeToken
+ self.session = session
+ }
+
+ func getAuthToken() async throws -> String {
+ let aboutUrl = url.appending(path: "about")
+
+ let scram = ScramClient(
+ hash: Hash.self,
+ username: username,
+ password: password
+ )
+
+ // Authentication Exchange
+
+ // Client Initiation
+ let clientFirstMessage = scram.clientFirstMessage()
+ var firstRequest = URLRequest(url: aboutUrl)
+ firstRequest.addValue(
+ AuthMessage(
+ scheme: "scram",
+ attributes: [
+ "handshakeToken": handshakeToken,
+ "data": clientFirstMessage.encodeBase64UrlSafe()
+ ]
+ ).description,
+ forHTTPHeaderField: HTTPHeader.authorization
+ )
+ let (_, firstResponseGen) = try await session.data(for: firstRequest)
+ let firstResponse = (firstResponseGen as! HTTPURLResponse)
+
+ // Server Initiation Response
+ guard firstResponse.statusCode == 401 else {
+ throw ScramAuthenticatorError.FirstResponseStatusIsNot401(firstResponse.statusCode)
+ }
+ guard let firstResponseHeaderString = firstResponse.value(forHTTPHeaderField: HTTPHeader.wwwAuthenticate) else {
+ throw ScramAuthenticatorError.FirstResponseNoHeaderWwwAuthenticate
+ }
+ let firstResponseAuth = try AuthMessage.from(firstResponseHeaderString)
+ guard AuthMechanism(rawValue: firstResponseAuth.scheme.uppercased()) == .SCRAM else {
+ throw ScramAuthenticatorError.FirstResponseInconsistentMechanism
+ }
+ guard let handshakeToken2 = firstResponseAuth.attributes["handshakeToken"] else {
+ throw ScramAuthenticatorError.FirstResponseNoAttributeHandshakeToken
+ }
+ guard let firstResponseData = firstResponseAuth.attributes["data"] else {
+ throw ScramAuthenticatorError.FirstResponseNoAttributeData
+ }
+ guard let firstResponseHashString = firstResponseAuth.attributes["hash"] else {
+ throw ScramAuthenticatorError.FirstResponseNoAttributeHash
+ }
+ guard let firstResponseHash = AuthHash(rawValue: firstResponseHashString) else {
+ throw HaystackClientError.authHashFunctionNotRecognized(firstResponseHashString)
+ }
+ guard firstResponseHash.hash == Hash.self else {
+ throw ScramAuthenticatorError.FirstResponseInconsistentHash
+ }
+ let serverFirstMessage = firstResponseData.decodeBase64UrlSafe()
+
+ // Client Continuation
+ let clientFinalMessage = try scram.clientFinalMessage(serverFirstMessage: serverFirstMessage)
+ var finalRequest = URLRequest(url: aboutUrl)
+ finalRequest.addValue(
+ AuthMessage(
+ scheme: "scram",
+ attributes: [
+ "handshakeToken": handshakeToken2,
+ "data": clientFinalMessage.encodeBase64UrlSafe()
+ ]
+ ).description,
+ forHTTPHeaderField: HTTPHeader.authorization
+ )
+ let (_, finalResponseGen) = try await session.data(for: finalRequest)
+ let finalResponse = (finalResponseGen as! HTTPURLResponse)
+
+ // Final Server Message
+ guard finalResponse.statusCode == 200 else {
+ throw ScramAuthenticatorError.authFailedWithHttpCode(finalResponse.statusCode)
+ }
+ guard let finalResponseHeaderString = finalResponse.value(forHTTPHeaderField: HTTPHeader.authenticationInfo) else {
+ throw ScramAuthenticatorError.SecondResponseNoHeaderAuthenticationInfo
+ }
+ let finalResponseAttributes = extractNameValuePairs(from: finalResponseHeaderString)
+ guard let authToken = finalResponseAttributes["authToken"] else {
+ throw ScramAuthenticatorError.SecondResponseNoAttributeAuthToken
+ }
+ guard let finalResponseData = finalResponseAttributes["data"] else {
+ throw ScramAuthenticatorError.SecondResponseNoAttributeData
+ }
+ guard let finalResponseHashString = finalResponseAttributes["hash"] else {
+ throw ScramAuthenticatorError.SecondResponseNoAttributeHash
+ }
+ guard let finalResponseHash = AuthHash(rawValue: finalResponseHashString) else {
+ throw HaystackClientError.authHashFunctionNotRecognized(finalResponseHashString)
+ }
+ guard finalResponseHash.hash == Hash.self else {
+ throw ScramAuthenticatorError.SecondResponseInconsistentHash
+ }
+ let serverFinalMessage = finalResponseData.decodeBase64UrlSafe()
+ try scram.validate(serverFinalMessage: serverFinalMessage)
+ return authToken
+ }
+
+
+ enum ScramAuthenticatorError: Error {
+ case FirstResponseInconsistentMechanism
+ case FirstResponseNoAttributeData
+ case FirstResponseNoAttributeHandshakeToken
+ case FirstResponseNoAttributeHash
+ case FirstResponseInconsistentHash
+ case FirstResponseNoHeaderWwwAuthenticate
+ case FirstResponseStatusIsNot401(Int)
+ case SecondResponseNoAttributeAuthToken
+ case SecondResponseNoAttributeData
+ case SecondResponseNoAttributeHash
+ case SecondResponseInconsistentHash
+ case SecondResponseNoHeaderAuthenticationInfo
+ case authFailedWithHttpCode(Int)
+ }
+}
diff --git a/Sources/HaystackClient/Authentication/ScramClient.swift b/Sources/HaystackClient/Authentication/ScramClient.swift
new file mode 100644
index 0000000..c44d01f
--- /dev/null
+++ b/Sources/HaystackClient/Authentication/ScramClient.swift
@@ -0,0 +1,197 @@
+import CryptoKit
+import Foundation
+
+@available(macOS 10.15, *)
+/// A Salted Challenge Response Authentication Mechanism (SCRAM) Client that is compatible with
+/// [RFC 5802](https://www.rfc-editor.org/rfc/rfc5802)
+class ScramClient {
+ private let clientKeyData = "Client Key".data(using: .utf8)!
+ private let serverKeyData = "Server Key".data(using: .utf8)!
+
+ private let username: String
+ private let password: String
+ private let clientNonce: String
+
+ // Populated as messages are built up
+ private var saltedPassword: Data? = nil
+ private var authMessage: Data? = nil
+
+ init(
+ hash: Hash.Type,
+ username: String,
+ password: String,
+ nonce: String? = nil
+ ) {
+ self.username = username
+ self.password = password
+ if let nonce = nonce {
+ self.clientNonce = nonce
+ } else {
+ self.clientNonce = Self.generateNonce()
+ }
+ }
+
+ func clientFirstMessage() -> String {
+ return "n,,\(clientFirstMessageBare())"
+ }
+
+ private func clientFirstMessageBare() -> String {
+ return "n=\(username),r=\(clientNonce)"
+ }
+
+ func clientFinalMessage(serverFirstMessage: String) throws -> String {
+ let serverFirstMessageParts = extractNameValuePairs(from: serverFirstMessage)
+ guard let serverNonce = serverFirstMessageParts["r"] else {
+ throw ScramClientError.serverFirstMessageMissingAttribute("r")
+ }
+ guard let saltString = serverFirstMessageParts["s"] else {
+ throw ScramClientError.serverFirstMessageMissingAttribute("s")
+ }
+ guard let salt = Data(base64Encoded: saltString) else {
+ throw ScramClientError.serverFirstMessageSaltIsNotBase64Encoded(saltString)
+ }
+ guard let iterationCountString = serverFirstMessageParts["i"] else {
+ throw ScramClientError.serverFirstMessageMissingAttribute("i")
+ }
+ guard let iterationCount = Int(iterationCountString) else {
+ throw ScramClientError.serverFirstMessageIterationCountIsNotInt(iterationCountString)
+ }
+
+ guard serverNonce.hasPrefix(clientNonce) else {
+ throw ScramClientError.serverFirstMessageNonceNotPrefixedByClientNonce
+ }
+
+ let saltedPassword = try saltPassword(salt: salt, iterationCount: iterationCount)
+ self.saltedPassword = saltedPassword // Store for later verification
+ let clientKey = clientKey(saltedPassword: saltedPassword)
+ let storedKey = storedKey(clientKey: clientKey)
+ let clientFinalMessageWithoutProof = "c=biws,r=\(serverNonce)"
+ let authMessageString = "\(clientFirstMessageBare()),\(serverFirstMessage),\(clientFinalMessageWithoutProof)"
+ guard let authMessage = authMessageString.data(using: .utf8) else {
+ throw ScramClientError.authMessageIsNotUtf8(authMessageString)
+ }
+ self.authMessage = authMessage // Store for later verification
+ let clientSignature = clientSignature(storedKey: storedKey, authMessage: authMessage)
+ let clientProof = Data(zip(clientKey, clientSignature).map { $0 ^ $1 }).base64EncodedString()
+ let clientFinalMessage = "\(clientFinalMessageWithoutProof),p=\(clientProof)"
+ return clientFinalMessage
+ }
+
+ func validate(serverFinalMessage: String) throws {
+ let serverFinalMessageParts = extractNameValuePairs(from: serverFinalMessage)
+ if let error = serverFinalMessageParts["e"] {
+ throw ScramClientError.authError(error)
+ }
+ guard let actualServerSignature = serverFinalMessageParts["v"] else {
+ throw ScramClientError.serverFinalMessageMissingAttribute("v")
+ }
+ guard
+ let authMessage = self.authMessage,
+ let saltedPassword = self.saltedPassword
+ else {
+ throw ScramClientError.validateCalledBeforeClientFinalMessage
+ }
+ let serverKey = serverKey(saltedPassword: saltedPassword)
+ let expectedServerSignature = serverSignature(serverKey: serverKey, authMessage: authMessage)
+
+ if actualServerSignature != expectedServerSignature.base64EncodedString() {
+ throw ScramClientError.serverFinalMessageDoesNotMatchExpected
+ }
+ }
+
+ private func saltPassword(salt: Data, iterationCount: Int) throws -> Data {
+ guard let passwordData = password.data(using: .ascii) else {
+ throw ScramClientError.passwordIsNotAscii(password)
+ }
+ var saltData = salt
+ saltData.append(contentsOf: [0, 0, 0, 1])
+
+ let key = SymmetricKey(data: passwordData)
+ var Ui = hmac(key: key, data: saltData)
+ var Hi = Ui
+ for _ in 2 ... iterationCount {
+ Ui = hmac(key: key, data: Ui)
+ Hi = Data(zip(Hi, Ui).map { $0 ^ $1 })
+ }
+ return Hi
+ }
+
+ private func clientKey(saltedPassword: Data) -> Data {
+ return hmac(key: saltedPassword, data: clientKeyData)
+ }
+
+ private func serverKey(saltedPassword: Data) -> Data {
+ return hmac(key: saltedPassword, data: serverKeyData)
+ }
+
+ private func storedKey(clientKey: Data) -> Data {
+ var hash = Hash()
+ hash.update(data: clientKey)
+ return Data(hash.finalize())
+ }
+
+ private func clientSignature(storedKey: Data, authMessage: Data) -> Data {
+ return hmac(key: storedKey, data: authMessage)
+ }
+
+ private func serverSignature(serverKey: Data, authMessage: Data) -> Data {
+ return hmac(key: serverKey, data: authMessage)
+ }
+
+ private func hmac(key: Data, data: Data) -> Data {
+ return hmac(key: SymmetricKey(data: key), data: data)
+ }
+
+ private func hmac(key: SymmetricKey, data: Data) -> Data {
+ var hmac = HMAC(key: key)
+ hmac.update(data: data)
+ return Data(hmac.finalize())
+ }
+
+ private static func generateNonce() -> String {
+ let nonceLen = 16
+ let nonceData = (0.. 43 {
+ asciiCode += 1
+ }
+ data.append(asciiCode)
+ }
+ // Force unwrap is guaranteed because of byte control above.
+ var clientNonce = String(data: nonceData, encoding: .utf8)!
+ clientNonce = clientNonce.encodeBase64UrlSafe()
+ return clientNonce
+ }
+}
+
+func extractNameValuePairs(from fieldsString: String) -> [String: String] {
+ // Example input: "hash=SHA-256, handshakeToken=aabbcc"
+ var attributes = [String: String]()
+ for pair in fieldsString.split(separator: ",") {
+ // If "=" does not exist, just parse the entire section as the name, and the value is ""
+ let assnIndex = pair.firstIndex(of: "=") ?? pair.endIndex
+ let name = String(pair[.. 0 {
+ // Remove "=" prefix
+ value.removeFirst()
+ }
+
+ attributes[name] = value
+ }
+ return attributes
+}
+
+enum ScramClientError: Error {
+ case authError(String)
+ case authMessageIsNotUtf8(String)
+ case passwordIsNotAscii(String)
+ case serverFirstMessageMissingAttribute(String)
+ case serverFirstMessageSaltIsNotBase64Encoded(String)
+ case serverFirstMessageIterationCountIsNotInt(String)
+ case serverFirstMessageNonceNotPrefixedByClientNonce
+ case serverFinalMessageMissingAttribute(String)
+ case serverFinalMessageDoesNotMatchExpected
+ case validateCalledBeforeClientFinalMessage
+}
diff --git a/Sources/HaystackClient/Authentication/String+Base64.swift b/Sources/HaystackClient/Authentication/String+Base64.swift
new file mode 100644
index 0000000..6b6a27d
--- /dev/null
+++ b/Sources/HaystackClient/Authentication/String+Base64.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+extension String {
+ func encodeBase64Standard() -> String {
+ return Data(self.utf8).base64EncodedString()
+ }
+
+ func decodeBase64Standard() -> String {
+ let data = Data(base64Encoded: self)!
+ let string = String(data: data, encoding: .utf8)!
+ return string
+ }
+
+ func encodeBase64UrlSafe() -> String {
+ self.encodeBase64Standard()
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ func decodeBase64UrlSafe() -> String {
+ var base64 = self.replacingOccurrences(of: "_", with: "/")
+ .replacingOccurrences(of: "-", with: "+")
+ // Add necessary `=` padding
+ let trailingCharCount = base64.count % 4
+ if trailingCharCount != 0 {
+ base64.append(String(repeating: "=", count: 4 - trailingCharCount))
+ }
+ return base64.decodeBase64Standard()
+ }
+}
diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift
new file mode 100644
index 0000000..f63b8c7
--- /dev/null
+++ b/Sources/HaystackClient/Client.swift
@@ -0,0 +1,602 @@
+import CryptoKit
+import Haystack
+import Foundation
+
+@available(macOS 13.0, *)
+/// A Haystack API client. Once created, call the `open` method to connect.
+///
+/// ```swift
+/// let client = Client(
+/// baseUrl: "http://localhost:8080/api",
+/// username: "user",
+/// password: "abc123"
+/// )
+/// await try client.open()
+/// let about = await try client.about()
+/// await try client.close()
+/// ```
+public class Client {
+ let baseUrl: URL
+ let username: String
+ let password: String
+ let format: DataFormat
+ let session: URLSession
+
+ /// Set when `open` is called.
+ private var authToken: String? = nil
+
+ private let jsonEncoder = JSONEncoder()
+ private let jsonDecoder = JSONDecoder()
+
+ /// Create a client instance.This may be reused across multiple logins if needed.
+ /// - Parameters:
+ /// - baseUrl: The URL of the Haystack API server
+ /// - username: The username to authenticate with
+ /// - password: The password to authenticate with
+ /// - format: The transfer data format. Defaults to `zinc` to reduce data transfer.
+ public init(
+ baseUrl: URL,
+ username: String,
+ password: String,
+ format: DataFormat = .zinc
+ ) throws {
+ guard !baseUrl.isFileURL else {
+ throw HaystackClientError.baseUrlCannotBeFile
+ }
+ self.baseUrl = baseUrl
+ self.username = username
+ self.password = password
+ self.format = format
+
+ // Disable all cookies, otherwise haystack thinks we're a browser client
+ // and asks for an Attest-Key header
+ let sessionConfig = URLSessionConfiguration.ephemeral
+ sessionConfig.httpCookieAcceptPolicy = .never
+ sessionConfig.httpShouldSetCookies = false
+ sessionConfig.httpCookieStorage = nil
+
+ self.session = URLSession(configuration: sessionConfig)
+ }
+
+ /// Authenticate the client and store the authentication token
+ public func open() async throws {
+ let url = baseUrl.appending(path: "about")
+
+ // Hello
+ let helloRequestAuth = AuthMessage(scheme: "hello", attributes: ["username": username.encodeBase64UrlSafe()])
+ var helloRequest = URLRequest(url: url)
+ helloRequest.addValue(helloRequestAuth.description, forHTTPHeaderField: HTTPHeader.authorization)
+ let (_, helloResponse) = try await session.data(for: helloRequest)
+ guard let helloHeaderString = (helloResponse as! HTTPURLResponse).value(forHTTPHeaderField: HTTPHeader.wwwAuthenticate) else {
+ throw HaystackClientError.authHelloNoWwwAuthenticateHeader
+ }
+ let helloResponseAuth = try AuthMessage.from(helloHeaderString)
+ guard let authMechanism = AuthMechanism(rawValue: helloResponseAuth.scheme.uppercased()) else {
+ throw HaystackClientError.authMechanismNotRecognized(helloResponseAuth.scheme)
+ }
+ guard let hashString = helloResponseAuth.attributes["hash"] else {
+ throw HaystackClientError.authHelloHashFunctionNotPresent
+ }
+ guard let hash = AuthHash(rawValue: hashString) else {
+ throw HaystackClientError.authHashFunctionNotRecognized(hashString)
+ }
+ guard let handshakeToken = helloResponseAuth.attributes["handshakeToken"] else {
+ throw HaystackClientError.authHelloHashFunctionNotPresent
+ }
+
+ let authenticator: any Authenticator
+ switch authMechanism {
+ case .SCRAM:
+ switch hash {
+ case .SHA256:
+ authenticator = ScramAuthenticator(
+ url: baseUrl,
+ username: username,
+ password: password,
+ handshakeToken: handshakeToken,
+ session: session
+ )
+ case .SHA512:
+ authenticator = ScramAuthenticator(
+ url: baseUrl,
+ username: username,
+ password: password,
+ handshakeToken: handshakeToken,
+ session: session
+ )
+ }
+ // TODO: Implement PLAINTEXT auth scheme
+ }
+ self.authToken = try await authenticator.getAuthToken()
+ }
+
+ /// Closes the current authentication session.
+ ///
+ /// https://project-haystack.org/doc/docHaystack/Ops#close
+ public func close() async throws {
+ try await post(path: "close")
+ self.authToken = nil
+ }
+
+ /// Queries basic information about the server
+ ///
+ /// https://project-haystack.org/doc/docHaystack/Ops#about
+ public func about() async throws -> Grid {
+ return try await post(path: "about")
+ }
+
+ /// 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
+ public func defs(filter: String? = nil, limit: Number? = nil) async throws -> Grid {
+ var args: [String: any Val] = [:]
+ if let filter = filter {
+ args["filter"] = filter
+ }
+ if let limit = limit {
+ args["limit"] = limit
+ }
+ return try await post(path: "defs", args: args)
+ }
+
+ /// 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
+ public func libs(filter: String? = nil, limit: Number? = nil) async throws -> Grid {
+ var args: [String: any Val] = [:]
+ if let filter = filter {
+ args["filter"] = filter
+ }
+ if let limit = limit {
+ args["limit"] = limit
+ }
+ return try await post(path: "libs", args: args)
+ }
+
+ /// 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
+ public func ops(filter: String? = nil, limit: Number? = nil) async throws -> Grid {
+ var args: [String: any Val] = [:]
+ if let filter = filter {
+ args["filter"] = filter
+ }
+ if let limit = limit {
+ args["limit"] = limit
+ }
+ return try await post(path: "ops", args: args)
+ }
+
+ /// 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
+ public func filetypes(filter: String? = nil, limit: Number? = nil) async throws -> Grid {
+ var args: [String: any Val] = [:]
+ if let filter = filter {
+ args["filter"] = filter
+ }
+ if let limit = limit {
+ args["limit"] = limit
+ }
+ return try await post(path: "filetypes", args: args)
+ }
+
+ /// 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
+ public func read(ids: [Ref]) async throws -> Grid {
+ let builder = GridBuilder()
+ try builder.addCol(name: "id")
+ for id in ids {
+ try builder.addRow([id])
+ }
+ return try await post(path: "read", grid: builder.toGrid())
+ }
+
+ /// 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
+ public func read(filter: String, limit: Number? = nil) async throws -> Grid {
+ var args: [String: any Val] = ["filter": filter]
+ if let limit = limit {
+ args["limit"] = limit
+ }
+ return try await post(path: "read", args: args)
+ }
+
+ /// 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
+ public func nav(navId: Ref?) async throws -> Grid {
+ if let navId = navId {
+ return try await post(path: "nav", args: ["navId": navId])
+ } else {
+ return try await post(path: "nav", args: [:])
+ }
+ }
+
+ /// 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
+ public func hisRead(id: Ref, range: HisReadRange) async throws -> Grid {
+ return try await post(path: "hisRead", args: ["id": id, "range": range.toRequestString()])
+ }
+
+ /// 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
+ public func hisWrite(id: Ref, items: [HisItem]) async throws -> Grid {
+ let builder = GridBuilder()
+ builder.setMeta(["id": id])
+ try builder.addCol(name: "ts")
+ try builder.addCol(name: "val")
+ for item in items {
+ try builder.addRow([item.ts, item.val])
+ }
+ return try await post(path: "hisWrite", grid: builder.toGrid())
+ }
+
+ /// 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
+ public func pointWrite(
+ id: Ref,
+ level: Number,
+ val: any Val,
+ who: String? = nil,
+ duration: Number? = nil
+ ) async throws -> Grid {
+ // level must be int between 1 & 17, check duration is duration unit and is present when level is 8
+ guard
+ level.isInt,
+ 1 <= level.val,
+ level.val <= 17
+ else {
+ throw HaystackClientError.pointWriteLevelIsNotIntBetween1And17
+ }
+
+ var args: [String: any Val] = [
+ "id": id,
+ "level": level,
+ "val": val
+ ]
+ if let who = who {
+ args["who"] = who
+ }
+ if level.val == 8, let duration = duration {
+ // TODO: Check that duration has time units
+ args["duration"] = duration
+ }
+
+ return try await post(path: "pointWrite", args: args)
+ }
+
+ /// 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
+ public func pointWriteStatus(id: Ref) async throws -> Grid {
+ return try await post(path: "pointWrite", args: ["id": id])
+ }
+
+ /// 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`.
+ public func watchSubCreate(
+ watchDis: String,
+ lease: Number? = nil,
+ ids: [Ref]
+ ) async throws -> Grid {
+ var gridMeta: [String: any Val] = ["watchDis": watchDis]
+ if let lease = lease {
+ gridMeta["lease"] = lease
+ }
+
+ let builder = GridBuilder()
+ builder.setMeta(gridMeta)
+ try builder.addCol(name: "id")
+ for id in ids {
+ try builder.addRow([id])
+ }
+
+ return try await post(path: "watchSub", grid: builder.toGrid())
+ }
+
+ /// 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`.
+ public func watchSubAdd(
+ watchId: String,
+ lease: Number? = nil,
+ ids: [Ref]
+ ) async throws -> Grid {
+ var gridMeta: [String: any Val] = ["watchId": watchId]
+ if let lease = lease {
+ gridMeta["lease"] = lease
+ }
+
+ let builder = GridBuilder()
+ builder.setMeta(gridMeta)
+ try builder.addCol(name: "id")
+ for id in ids {
+ try builder.addRow([id])
+ }
+
+ return try await post(path: "watchSub", grid: builder.toGrid())
+ }
+
+ /// Used to close a watch entirely or 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
+ public func watchUnsub(
+ watchId: String,
+ ids: [Ref]
+ ) async throws -> Grid {
+ var gridMeta: [String: any Val] = ["watchId": watchId]
+ if ids.isEmpty {
+ gridMeta["close"] = marker
+ }
+
+ let builder = GridBuilder()
+ builder.setMeta(gridMeta)
+ try builder.addCol(name: "id")
+ for id in ids {
+ try builder.addRow([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
+ ///
+ /// - Parameters:
+ /// - watchId: Watch identifier
+ /// - refresh: Whether a full refresh should occur
+ /// - Returns: A grid where each row correspondes to a watched entity
+ public func watchPoll(
+ watchId: String,
+ refresh: Bool = false
+ ) async throws -> Grid {
+ var gridMeta: [String: any Val] = ["watchId": watchId]
+ if refresh {
+ gridMeta["refresh"] = marker
+ }
+
+ let builder = GridBuilder()
+ builder.setMeta(gridMeta)
+
+ return try await post(path: "watchPoll", grid: builder.toGrid())
+ }
+
+ /// 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
+ public func invokeAction(
+ id: Ref,
+ action: String,
+ args: [String: any Val]
+ ) async throws -> Grid {
+ let gridMeta: [String: any Val] = [
+ "id": id,
+ "action": action
+ ]
+ let builder = GridBuilder()
+ builder.setMeta(gridMeta)
+ var row = [any Val]()
+ for (argName, argVal) in args {
+ try builder.addCol(name: argName)
+ row.append(argVal)
+ }
+ try builder.addRow(row)
+
+ return try await post(path: "invokeAction", grid: builder.toGrid())
+ }
+
+ /// Evaluate an Axon expression
+ ///
+ /// https://haxall.io/doc/lib-hx/op~eval
+ ///
+ /// - Parameter expression: A string Axon expression
+ /// - Returns: A grid of undefined shape
+ public func eval(expression: String) async throws -> Grid {
+ return try await post(path: "eval", args: ["expr": expression])
+ }
+
+ @discardableResult
+ private func post(path: String, args: [String: any Val] = [:]) async throws -> Grid {
+ let grid: Grid
+ if args.isEmpty {
+ // Create empty grid
+ grid = GridBuilder().toGrid()
+ } else {
+ let builder = GridBuilder()
+ var row = [any Val]()
+ for (argName, argValue) in args {
+ try builder.addCol(name: argName)
+ row.append(argValue)
+ }
+ try builder.addRow(row)
+ grid = builder.toGrid()
+ }
+
+ return try await post(path: path, grid: grid)
+ }
+
+ @discardableResult
+ private func post(path: String, grid: Grid) async throws -> Grid {
+ let url = baseUrl.appending(path: path)
+ return try await execute(url: url, method: .POST, grid: grid)
+ }
+
+ @discardableResult
+ private func get(path: String, args: [String: any Val] = [:]) async throws -> Grid {
+ var url = baseUrl.appending(path: path)
+ // Adjust url based on GET args
+ if !args.isEmpty {
+ var queryItems = [URLQueryItem]()
+ for (argName, argValue) in args {
+ queryItems.append(.init(name: argName, value: argValue.toZinc()))
+ }
+ url = url.appending(queryItems: queryItems)
+ }
+ return try await execute(url: url, method: .GET)
+ }
+
+ private func execute(url: URL, method: HttpMethod, grid: Grid? = nil) async throws -> Grid {
+ var request = URLRequest(url: url)
+ request.httpMethod = method.rawValue
+
+ if method == .POST, let grid = grid {
+ let requestData: Data
+ switch format {
+ case .json:
+ requestData = try jsonEncoder.encode(grid)
+ case .zinc:
+ requestData = grid.toZinc().data(using: .utf8)! // Unwrap is safe b/c zinc is always UTF8 compatible
+ }
+ request.addValue(format.contentTypeHeaderValue, forHTTPHeaderField: HTTPHeader.contentType)
+ request.httpBody = requestData
+ }
+
+ // Set auth token header
+ guard let authToken = authToken else {
+ throw HaystackClientError.notLoggedIn
+ }
+ request.addValue(
+ AuthMessage(scheme: "Bearer", attributes: ["authToken": authToken]).description,
+ forHTTPHeaderField: HTTPHeader.authorization
+ )
+ // See Content Negotiation: https://haxall.io/doc/docHaystack/HttpApi.html#contentNegotiation
+ request.addValue(format.acceptHeaderValue, forHTTPHeaderField: HTTPHeader.accept)
+ request.addValue(userAgentHeaderValue, forHTTPHeaderField: HTTPHeader.userAgent)
+ let (data, responseGen) = try await session.data(for: request)
+ let response = (responseGen as! HTTPURLResponse)
+ guard response.statusCode == 200 else {
+ throw HaystackClientError.requestFailed(
+ httpCode: response.statusCode,
+ message: String(data: data, encoding: .utf8)
+ )
+ }
+ guard
+ let contentType = response.value(forHTTPHeaderField: HTTPHeader.contentType),
+ contentType.hasPrefix(format.acceptHeaderValue)
+ else {
+ throw HaystackClientError.responseIsNotZinc
+ }
+ switch format {
+ case .json:
+ return try jsonDecoder.decode(Grid.self, from: data)
+ case .zinc:
+ return try ZincReader(data).readGrid()
+ }
+ }
+
+ private enum HttpMethod: String {
+ case GET
+ case POST
+ }
+}
+
+private let userAgentHeaderValue = "swift-haystack-client"
+
+enum HaystackClientError: Error {
+ case authHelloNoWwwAuthenticateHeader
+ case authHelloHandshakeTokenNotPresent
+ case authHelloHashFunctionNotPresent
+ case authHashFunctionNotRecognized(String)
+ case authMechanismNotRecognized(String)
+ case authMechanismNotImplemented(AuthMechanism)
+ case baseUrlCannotBeFile
+ case notLoggedIn
+ case pointWriteLevelIsNotIntBetween1And17
+ case responseIsNotZinc
+ case requestFailed(httpCode: Int, message: String?)
+}
+
+enum AuthMechanism: String {
+ case SCRAM
+}
+
+enum HTTPHeader {
+ static let accept = "Accept"
+ static let authenticationInfo = "Authentication-Info"
+ static let authorization = "Authorization"
+ static let contentType = "Content-Type"
+ static let userAgent = "User-Agent"
+ static let wwwAuthenticate = "Www-Authenticate"
+}
diff --git a/Sources/HaystackClient/DataFormat.swift b/Sources/HaystackClient/DataFormat.swift
new file mode 100644
index 0000000..69daf19
--- /dev/null
+++ b/Sources/HaystackClient/DataFormat.swift
@@ -0,0 +1,21 @@
+/// Haystack data serialization formats. For more information, see
+/// https://project-haystack.org/doc/docHaystack/HttpApi#contentNegotiation
+public enum DataFormat: String {
+ case json
+ case zinc
+
+ // See Content Negotiation: https://haxall.io/doc/docHaystack/HttpApi.html#contentNegotiation
+ var acceptHeaderValue: String {
+ switch self {
+ case .json: return "application/json"
+ case .zinc: return "text/zinc"
+ }
+ }
+
+ var contentTypeHeaderValue: String {
+ switch self {
+ case .json: return "application/json"
+ case .zinc: return "text/zinc; charset=utf-8"
+ }
+ }
+}
diff --git a/Sources/HaystackClient/HisItem.swift b/Sources/HaystackClient/HisItem.swift
new file mode 100644
index 0000000..c4ab4a3
--- /dev/null
+++ b/Sources/HaystackClient/HisItem.swift
@@ -0,0 +1,12 @@
+import Haystack
+
+/// A timestamp/value pair.
+public struct HisItem {
+ let ts: DateTime
+ let val: any Val
+
+ public init(ts: DateTime, val: any Val) {
+ self.ts = ts
+ self.val = val
+ }
+}
diff --git a/Sources/HaystackClient/HisReadRange.swift b/Sources/HaystackClient/HisReadRange.swift
new file mode 100644
index 0000000..8310552
--- /dev/null
+++ b/Sources/HaystackClient/HisReadRange.swift
@@ -0,0 +1,22 @@
+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/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift b/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift
new file mode 100644
index 0000000..e2cd8fa
--- /dev/null
+++ b/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift
@@ -0,0 +1,93 @@
+import XCTest
+import Haystack
+import HaystackClient
+
+@available(macOS 13.0, *)
+/// To use these tests, run a [Haxall](https://github.com/haxall/haxall) server and set the username and password
+/// in the `HAYSTACK_USER` and `HAYSTACK_PASSWORD` environment variables
+final class HaystackClientIntegrationTests: XCTestCase {
+ var client: Client = try! Client(
+ baseUrl: URL(string: "http://localhost:8080/api/")!,
+ username: ProcessInfo.processInfo.environment["HAYSTACK_USER"] ?? "su",
+ password: ProcessInfo.processInfo.environment["HAYSTACK_PASSWORD"] ?? "su"
+ )
+
+ override func setUp() async throws {
+ try await client.open()
+ }
+
+ override func tearDown() async throws {
+ try await client.close()
+ }
+
+ func testCloseAndOpen() async throws {
+ print(try await client.close())
+ print(try await client.open())
+ }
+
+ func testAbout() async throws {
+ print(try await client.about().toZinc())
+ }
+
+ func testDefs() async throws {
+ print(try await client.defs().toZinc())
+ print(try await client.defs(filter: "lib==^lib:phIoT").toZinc())
+ print(try await client.defs(limit: Number(1)).toZinc())
+ print(try await client.defs(filter: "lib==^lib:phIoT", limit: Number(1)).toZinc())
+ }
+
+ func testLibs() async throws {
+ print(try await client.libs().toZinc())
+ print(try await client.libs(filter: "lib==^lib:phIoT").toZinc())
+ print(try await client.libs(limit: Number(1)).toZinc())
+ print(try await client.libs(filter: "lib==^lib:phIoT", limit: Number(1)).toZinc())
+ }
+
+ func testOps() async throws {
+ print(try await client.ops().toZinc())
+ print(try await client.ops(filter: "lib==^lib:phIoT").toZinc())
+ print(try await client.ops(limit: Number(1)).toZinc())
+ print(try await client.ops(filter: "lib==^lib:phIoT", limit: Number(1)).toZinc())
+ }
+
+ func testFiletypes() async throws {
+ print(try await client.filetypes().toZinc())
+ print(try await client.filetypes(filter: "lib==^lib:phIoT").toZinc())
+ print(try await client.filetypes(limit: Number(1)).toZinc())
+ print(try await client.filetypes(filter: "lib==^lib:phIoT", limit: Number(1)).toZinc())
+ }
+
+ func testRead() async throws {
+ print(try await client.read(ids: [Ref("28e7fb47-d67ab19a")]).toZinc())
+ }
+
+ func testReadAll() async throws {
+ print(try await client.read(filter: "site").toZinc())
+ }
+
+ func testHisRead() async throws {
+ print(try await client.hisRead(id: Ref("28e7fb7d-e20316e0"), range: .today).toZinc())
+ print(try await client.hisRead(id: Ref("28e7fb7d-e20316e0"), range: .yesterday).toZinc())
+ print(try await client.hisRead(id: Ref("28e7fb7d-e20316e0"), range: .date(Date("2022-01-01"))).toZinc())
+ print(try await client.hisRead(id: Ref("28e7fb7d-e20316e0"), range: .dateRange(from: Date("2022-01-01"), to: Date("2022-02-01"))).toZinc())
+ print(try await client.hisRead(id: Ref("28e7fb7d-e20316e0"), range: .dateTimeRange(from: DateTime("2022-01-01T00:00:00Z"), to: DateTime("2022-02-01T00:00:00Z"))).toZinc())
+ print(try await client.hisRead(id: Ref("28e7fb7d-e20316e0"), range: .after(DateTime("2022-01-01T00:00:00Z"))).toZinc())
+ }
+
+ func testHisWrite() async throws {
+ print(try await client.hisWrite(
+ id: Ref("28e7fb7d-e20316e0"),
+ items: [
+ HisItem(ts: DateTime("2022-01-01T00:00:00-07:00 Denver"), val: Number(14))
+ ]
+ ).toZinc())
+ }
+
+ func testEval() async throws {
+ print(try await client.eval(expression: "readAll(site)").toZinc())
+ }
+
+ func testWatchUnsub() async throws {
+ print(try await client.watchUnsub(watchId: "id", ids: [Ref("28e7fb47-d67ab19a")]))
+ }
+}
diff --git a/Tests/HaystackClientTests/SCRAMTests.swift b/Tests/HaystackClientTests/SCRAMTests.swift
new file mode 100644
index 0000000..5168a09
--- /dev/null
+++ b/Tests/HaystackClientTests/SCRAMTests.swift
@@ -0,0 +1,103 @@
+import CryptoKit
+import XCTest
+@testable import HaystackClient
+
+final class SCRAMTests: XCTestCase {
+ let scram = ScramClient(
+ hash: Insecure.SHA1.self,
+ username: "user",
+ password: "pencil",
+ nonce: "fyko+d2lbbFgONRv9qkxdawL"
+ )
+
+ /// Tests example from [RFC-5802's SCRAM Authentication Exchange section](https://www.rfc-editor.org/rfc/rfc5802#section-5)
+ func testClientExample() async throws {
+ XCTAssertEqual(
+ scram.clientFirstMessage(),
+ "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL"
+ )
+
+ XCTAssertEqual(
+ try scram.clientFinalMessage(
+ serverFirstMessage: "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096"
+ ),
+ "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts="
+ )
+
+ XCTAssertNoThrow(
+ try scram.validate(serverFinalMessage: "v=rmF9pqV8S7suAoZWja4dJRkFsKQ=")
+ )
+ }
+
+ func testServerFirstMessageResponseErrors() async throws {
+ XCTAssertEqual(
+ scram.clientFirstMessage(),
+ "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL"
+ )
+
+ // Server first message has no nonce attribute
+ XCTAssertThrowsError(
+ try scram.clientFinalMessage(
+ serverFirstMessage: "s=QSXCR+Q6sek8bf92,i=4096"
+ )
+ )
+
+ // Server first message has no salt attribute
+ XCTAssertThrowsError(
+ try scram.clientFinalMessage(
+ serverFirstMessage: "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,i=4096"
+ )
+ )
+
+ // Server first message has no iteration attribute
+ XCTAssertThrowsError(
+ try scram.clientFinalMessage(
+ serverFirstMessage: "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92"
+ )
+ )
+
+ // Server first message iteration is not integer
+ XCTAssertThrowsError(
+ try scram.clientFinalMessage(
+ serverFirstMessage: "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=BAD"
+ )
+ )
+
+ // Server nonce isn't prefixed with client nonce
+ XCTAssertThrowsError(
+ try scram.clientFinalMessage(
+ serverFirstMessage: "r=BAD_fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096"
+ )
+ )
+
+ }
+
+ func testServerFinalMessageValidateErrors() async throws {
+ XCTAssertEqual(
+ scram.clientFirstMessage(),
+ "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL"
+ )
+
+ XCTAssertEqual(
+ try scram.clientFinalMessage(
+ serverFirstMessage: "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096"
+ ),
+ "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts="
+ )
+
+ // Throws if server final message is unexpected
+ XCTAssertThrowsError(
+ try scram.validate(serverFinalMessage: "v=BAD_rmF9pqV8S7suAoZWja4dJRkFsKQ=")
+ )
+
+ // Throws if server final message has error attribute
+ XCTAssertThrowsError(
+ try scram.validate(serverFinalMessage: "e=No way Jose,v=rmF9pqV8S7suAoZWja4dJRkFsKQ=")
+ )
+
+ // Throws if server final message has no server key attribute
+ XCTAssertThrowsError(
+ try scram.validate(serverFinalMessage: "n=user")
+ )
+ }
+}
diff --git a/Tests/HaystackClientTests/UrlSafeBase64Tests.swift b/Tests/HaystackClientTests/UrlSafeBase64Tests.swift
new file mode 100644
index 0000000..e83f367
--- /dev/null
+++ b/Tests/HaystackClientTests/UrlSafeBase64Tests.swift
@@ -0,0 +1,32 @@
+import XCTest
+@testable import HaystackClient
+
+final class UrlSafeBase64Tests: XCTestCase {
+ func testEncodeStandard() throws {
+ XCTAssertEqual(
+ "user".encodeBase64Standard(),
+ "dXNlcg=="
+ )
+ }
+
+ func testEncodeUrlSafe() throws {
+ XCTAssertEqual(
+ "user".encodeBase64UrlSafe(),
+ "dXNlcg"
+ )
+ }
+
+ func testDecodeStandard() throws {
+ XCTAssertEqual(
+ "dXNlcg==".decodeBase64Standard(),
+ "user"
+ )
+ }
+
+ func testDecodeUrlSafe() throws {
+ XCTAssertEqual(
+ "dXNlcg".decodeBase64UrlSafe(),
+ "user"
+ )
+ }
+}
diff --git a/Tests/HaystackTests/GridTests.swift b/Tests/HaystackTests/GridTests.swift
index fd663a6..5ddf357 100644
--- a/Tests/HaystackTests/GridTests.swift
+++ b/Tests/HaystackTests/GridTests.swift
@@ -12,7 +12,26 @@ final class GridTests: XCTestCase {
.addRow(["RTU-1", marker, Ref("153c-699a", dis: "HQ"), Date(year: 2005, month: 6, day: 1)])
.addRow(["RTU-2", marker, Ref("153c-699b", dis: "Library"), Date(year: 1997, month: 7, day: 12)])
.toGrid()
- let jsonString = #"{"_kind":"grid","meta":{"foo":"bar"},"cols":[{"name":"dis","meta":{"dis":"Equip Name"}},{"name":"equip"},{"name":"siteRef"},{"name":"installed"}],"rows":[{"dis":"RTU-1","equip":{"_kind":"marker"},"siteRef":{"_kind":"ref","val":"153c-699a","dis":"HQ"},"installed":{"_kind":"date","val":"2005-06-01"}},{"dis": "RTU-2","equip":{"_kind":"marker"},"siteRef":{"_kind":"ref","val":"153c-699b","dis":"Library"},"installed":{"_kind":"date","val":"1997-07-12"}}]}"#
+ let jsonString = #"{"_kind":"grid","meta":{"ver":"3.0","foo":"bar"},"cols":[{"name":"dis","meta":{"dis":"Equip Name"}},{"name":"equip"},{"name":"siteRef"},{"name":"installed"}],"rows":[{"dis":"RTU-1","equip":{"_kind":"marker"},"siteRef":{"_kind":"ref","val":"153c-699a","dis":"HQ"},"installed":{"_kind":"date","val":"2005-06-01"}},{"dis": "RTU-2","equip":{"_kind":"marker"},"siteRef":{"_kind":"ref","val":"153c-699b","dis":"Library"},"installed":{"_kind":"date","val":"1997-07-12"}}]}"#
+
+ // Since Swift doesn't guarantee JSON attribute ordering, we must round-trip this instead of
+ // comparing to the string
+ let encodedData = try JSONEncoder().encode(value)
+ XCTAssertEqual(
+ try JSONDecoder().decode(Grid.self, from: encodedData),
+ value
+ )
+
+ let decodedData = try XCTUnwrap(jsonString.data(using: .utf8))
+ XCTAssertEqual(
+ try JSONDecoder().decode(Grid.self, from: decodedData),
+ value
+ )
+ }
+
+ func testJsonCoding_empty() throws {
+ let value = GridBuilder().toGrid()
+ let jsonString = #"{"_kind":"grid","meta":{"ver":"3.0"},"cols":[{"name":"empty"}],"rows":[]}"#
// Since Swift doesn't guarantee JSON attribute ordering, we must round-trip this instead of
// comparing to the string
@@ -48,6 +67,18 @@ final class GridTests: XCTestCase {
"RTU-2", M, @153c-699b Library, 1997-07-12
"""
)
+
+ // Test empty grid
+ XCTAssertEqual(
+ GridBuilder()
+ .toGrid()
+ .toZinc(),
+ """
+ ver:"3.0"
+ empty
+
+ """
+ )
}
func testEquatable() throws {
diff --git a/Tests/HaystackTests/NumberTests.swift b/Tests/HaystackTests/NumberTests.swift
index 5c0a5f1..5abc850 100644
--- a/Tests/HaystackTests/NumberTests.swift
+++ b/Tests/HaystackTests/NumberTests.swift
@@ -2,6 +2,13 @@ import XCTest
import Haystack
final class NumberTests: XCTestCase {
+ func testIsInt() throws {
+ XCTAssertTrue(Number(5).isInt)
+ XCTAssertFalse(Number(5.5).isInt)
+ XCTAssertTrue(Number(-1).isInt)
+ XCTAssertFalse(Number(-1.99999).isInt)
+ }
+
func testJsonCoding() throws {
let value = Number(12.199, unit: "kWh")
let jsonString = #"{"_kind":"number","val":12.199,"unit":"kWh"}"#