From e486b45c8629f12d8aa1be1711a44dd6f0846ef5 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 21 Nov 2022 00:39:01 -0700 Subject: [PATCH 01/29] feature: Adds URL-safe base64 codings --- Sources/HaystackClient/String + Base64.swift | 31 ++++++++++++++++++ .../UrlSafeBase64Tests.swift | 32 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 Sources/HaystackClient/String + Base64.swift create mode 100644 Tests/HaystackClientTests/UrlSafeBase64Tests.swift diff --git a/Sources/HaystackClient/String + Base64.swift b/Sources/HaystackClient/String + Base64.swift new file mode 100644 index 0000000..6b6a27d --- /dev/null +++ b/Sources/HaystackClient/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/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" + ) + } +} From 2ebffb69edeb968e14c9891d62031c7ab1f48977 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 23 Nov 2022 00:04:17 -0700 Subject: [PATCH 02/29] feature: Adds SCRAM client --- Package.swift | 12 ++ Sources/HaystackClient/ScramClient.swift | 156 +++++++++++++++++++++ Tests/HaystackClientTests/SCRAMTests.swift | 36 +++++ 3 files changed, 204 insertions(+) create mode 100644 Sources/HaystackClient/ScramClient.swift create mode 100644 Tests/HaystackClientTests/SCRAMTests.swift diff --git a/Package.swift b/Package.swift index cc3381f..7d195f5 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,17 @@ let package = Package( name: "Haystack", dependencies: [] ), + .target( + name: "HaystackClient", + dependencies: ["Haystack"] + ), .testTarget( name: "HaystackTests", dependencies: ["Haystack"] ), + .testTarget( + name: "HaystackClientTests", + dependencies: ["HaystackClient"] + ), ] ) diff --git a/Sources/HaystackClient/ScramClient.swift b/Sources/HaystackClient/ScramClient.swift new file mode 100644 index 0000000..7066b6b --- /dev/null +++ b/Sources/HaystackClient/ScramClient.swift @@ -0,0 +1,156 @@ +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 username: String + private let password: String + private let clientNonce: String + + 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.serverFirstMessageSaltCannotBeEncoded(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) + let clientKey = clientKey(saltedPassword: saltedPassword) + let storedKey = storedKey(clientKey: clientKey) + let clientFinalMessageWithoutProof = "c=biws,r=\(serverNonce)" + let authMessage = "\(clientFirstMessageBare()),\(serverFirstMessage),\(clientFinalMessageWithoutProof)" + guard let authMessageData = authMessage.data(using: .utf8) else { + throw ScramClientError.authMessageIsNotUtf8(authMessage) + } + let clientSignature = clientSignature(storedKey: storedKey, authMessage: authMessageData) + let clientProof = Data(zip(clientKey, clientSignature).map { $0 ^ $1 }).base64EncodedString() + + let clientFinalMessage = "\(clientFinalMessageWithoutProof),p=\(clientProof)" + return clientFinalMessage + } + + 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 { + var hmac = HMAC(key: SymmetricKey(data: saltedPassword)) + hmac.update(data: clientKeyData) + return Data(hmac.finalize()) + } + + 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 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[.. Date: Fri, 25 Nov 2022 10:40:57 -0700 Subject: [PATCH 03/29] feature: Adds SCRAM final message validation --- Sources/HaystackClient/ScramClient.swift | 54 ++++++++++++++++++---- Tests/HaystackClientTests/SCRAMTests.swift | 17 +++---- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/Sources/HaystackClient/ScramClient.swift b/Sources/HaystackClient/ScramClient.swift index 7066b6b..96e5638 100644 --- a/Sources/HaystackClient/ScramClient.swift +++ b/Sources/HaystackClient/ScramClient.swift @@ -6,11 +6,16 @@ import Foundation /// [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, @@ -57,20 +62,43 @@ class ScramClient { } 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 authMessage = "\(clientFirstMessageBare()),\(serverFirstMessage),\(clientFinalMessageWithoutProof)" - guard let authMessageData = authMessage.data(using: .utf8) else { - throw ScramClientError.authMessageIsNotUtf8(authMessage) + let authMessageString = "\(clientFirstMessageBare()),\(serverFirstMessage),\(clientFinalMessageWithoutProof)" + guard let authMessage = authMessageString.data(using: .utf8) else { + throw ScramClientError.authMessageIsNotUtf8(authMessageString) } - let clientSignature = clientSignature(storedKey: storedKey, authMessage: authMessageData) + 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) @@ -89,9 +117,11 @@ class ScramClient { } private func clientKey(saltedPassword: Data) -> Data { - var hmac = HMAC(key: SymmetricKey(data: saltedPassword)) - hmac.update(data: clientKeyData) - return Data(hmac.finalize()) + return hmac(key: saltedPassword, data: clientKeyData) + } + + private func serverKey(saltedPassword: Data) -> Data { + return hmac(key: saltedPassword, data: serverKeyData) } private func storedKey(clientKey: Data) -> Data { @@ -104,6 +134,10 @@ class ScramClient { 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) } @@ -147,10 +181,14 @@ func extractNameValuePairs(from fieldsString: String) -> [String: String] { } enum ScramClientError: Error { + case authError(String) case authMessageIsNotUtf8(String) case passwordIsNotAscii(String) case serverFirstMessageMissingAttribute(String) case serverFirstMessageSaltCannotBeEncoded(String) case serverFirstMessageIterationCountIsNotInt(String) case serverFirstMessageNonceNotPrefixedByClientNonce + case serverFinalMessageMissingAttribute(String) + case serverFinalMessageDoesNotMatchExpected + case validateCalledBeforeClientFinalMessage } diff --git a/Tests/HaystackClientTests/SCRAMTests.swift b/Tests/HaystackClientTests/SCRAMTests.swift index 5bd8eb0..22e2b31 100644 --- a/Tests/HaystackClientTests/SCRAMTests.swift +++ b/Tests/HaystackClientTests/SCRAMTests.swift @@ -4,7 +4,7 @@ import XCTest /// These tests originate from [RFC-5802's SCRAM Authentication Exchange section](https://www.rfc-editor.org/rfc/rfc5802#section-5) final class SCRAMTests: XCTestCase { - func testClientFirstMessage() async throws { + func testClient() async throws { let scram = ScramClient( hash: Insecure.SHA1.self, username: "user", @@ -16,15 +16,6 @@ final class SCRAMTests: XCTestCase { scram.clientFirstMessage(), "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL" ) - } - - func testClientSecondMessage() async throws { - let scram = ScramClient( - hash: Insecure.SHA1.self, - username: "user", - password: "pencil", - nonce: "fyko+d2lbbFgONRv9qkxdawL" - ) XCTAssertEqual( try scram.clientFinalMessage( @@ -32,5 +23,11 @@ final class SCRAMTests: XCTestCase { ), "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=" ) + + XCTAssertNoThrow( + try scram.validate( + serverFinalMessage: "v=rmF9pqV8S7suAoZWja4dJRkFsKQ=" + ) + ) } } From 631e09f1cd782155e7cdabdea22367f612887a3e Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 25 Nov 2022 10:53:24 -0700 Subject: [PATCH 04/29] test: More complete SCRAM testing --- Sources/HaystackClient/ScramClient.swift | 9 ++- Tests/HaystackClientTests/SCRAMTests.swift | 92 +++++++++++++++++++--- 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/Sources/HaystackClient/ScramClient.swift b/Sources/HaystackClient/ScramClient.swift index 96e5638..c44d01f 100644 --- a/Sources/HaystackClient/ScramClient.swift +++ b/Sources/HaystackClient/ScramClient.swift @@ -48,7 +48,7 @@ class ScramClient { throw ScramClientError.serverFirstMessageMissingAttribute("s") } guard let salt = Data(base64Encoded: saltString) else { - throw ScramClientError.serverFirstMessageSaltCannotBeEncoded(saltString) + throw ScramClientError.serverFirstMessageSaltIsNotBase64Encoded(saltString) } guard let iterationCountString = serverFirstMessageParts["i"] else { throw ScramClientError.serverFirstMessageMissingAttribute("i") @@ -173,7 +173,10 @@ func extractNameValuePairs(from fieldsString: String) -> [String: String] { let assnIndex = pair.firstIndex(of: "=") ?? pair.endIndex let name = String(pair[.. 0 { + // Remove "=" prefix + value.removeFirst() + } attributes[name] = value } @@ -185,7 +188,7 @@ enum ScramClientError: Error { case authMessageIsNotUtf8(String) case passwordIsNotAscii(String) case serverFirstMessageMissingAttribute(String) - case serverFirstMessageSaltCannotBeEncoded(String) + case serverFirstMessageSaltIsNotBase64Encoded(String) case serverFirstMessageIterationCountIsNotInt(String) case serverFirstMessageNonceNotPrefixedByClientNonce case serverFinalMessageMissingAttribute(String) diff --git a/Tests/HaystackClientTests/SCRAMTests.swift b/Tests/HaystackClientTests/SCRAMTests.swift index 22e2b31..5168a09 100644 --- a/Tests/HaystackClientTests/SCRAMTests.swift +++ b/Tests/HaystackClientTests/SCRAMTests.swift @@ -2,16 +2,16 @@ import CryptoKit import XCTest @testable import HaystackClient -/// These tests originate from [RFC-5802's SCRAM Authentication Exchange section](https://www.rfc-editor.org/rfc/rfc5802#section-5) final class SCRAMTests: XCTestCase { - func testClient() async throws { - let scram = ScramClient( - hash: Insecure.SHA1.self, - username: "user", - password: "pencil", - nonce: "fyko+d2lbbFgONRv9qkxdawL" - ) - + 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" @@ -25,9 +25,79 @@ final class SCRAMTests: XCTestCase { ) XCTAssertNoThrow( - try scram.validate( - serverFinalMessage: "v=rmF9pqV8S7suAoZWja4dJRkFsKQ=" + 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") + ) } } From 4ca05774f29a0d775da1eab55f8596f05dc6b9b1 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Fri, 25 Nov 2022 16:44:48 -0700 Subject: [PATCH 05/29] feature: Haystack Client with login --- Sources/HaystackClient/Authenticators.swift | 164 ++++++++++++++++++++ Sources/HaystackClient/HaystackClient.swift | 115 ++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 Sources/HaystackClient/Authenticators.swift create mode 100644 Sources/HaystackClient/HaystackClient.swift diff --git a/Sources/HaystackClient/Authenticators.swift b/Sources/HaystackClient/Authenticators.swift new file mode 100644 index 0000000..95223cf --- /dev/null +++ b/Sources/HaystackClient/Authenticators.swift @@ -0,0 +1,164 @@ +import CryptoKit +import Foundation + +@available(macOS 13.0, *) +protocol Authenticator { + func getAuthToken() async throws -> String +} + +@available(macOS 13.0, *) +struct ScramAuthenticator: Authenticator { + let url: URL + let username: String + let password: String + let handshakeToken: String + + init(url: URL, username: String, password: String, handshakeToken: String) { + self.url = url + self.username = username + self.password = password + self.handshakeToken = handshakeToken + } + + 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: "Authorization" + ) + let (_, firstResponseGen) = try await URLSession.shared.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: "Www-Authenticate") 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: "Authorization" + ) + let (_, finalResponseGen) = try await URLSession.shared.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: "Authentication-Info") 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) + } +} + +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[..( + url: baseUrl, + username: username, + password: password, + handshakeToken: handshakeToken + ) + case .SHA512: + authenticator = ScramAuthenticator( + url: baseUrl, + username: username, + password: password, + handshakeToken: handshakeToken + ) + } + // TODO: Implement PLAINTEXT auth scheme + } + self.authToken = try await authenticator.getAuthToken() + } + + public func about() async throws -> Grid { + let aboutUrl = baseUrl.appending(path: "about") + + var request = URLRequest(url: aboutUrl) + request.httpMethod = "GET" + request.addValue("text/zinc", forHTTPHeaderField: "Accept") + let (data, responseGen) = try await URLSession.shared.data(for: request) + let response = (responseGen as! HTTPURLResponse) + guard response.value(forHTTPHeaderField: "Content-Type") == "text/zinc" else { + throw HaystackClientError.responseIsNotZinc + } + return try ZincReader(data).readGrid() + } +} + +enum HaystackClientError: Error { + case authHelloNoWwwAuthenticateHeader + case authHelloHandshakeTokenNotPresent + case authHelloHashFunctionNotPresent + case authHashFunctionNotRecognized(String) + case authMechanismNotRecognized(String) + case authMechanismNotImplemented(AuthMechanism) + case baseUrlCannotBeFile + case responseIsNotZinc +} + +enum AuthMechanism: String { + case SCRAM +} + +@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 + } + } +} From 500816aa2aa248eee72dc598d8a0c93a62ddd131 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sat, 26 Nov 2022 22:03:14 -0700 Subject: [PATCH 06/29] feature: Adds format selection support --- Sources/HaystackClient/HaystackClient.swift | 53 +++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index 566ec72..d39db7f 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -7,17 +7,21 @@ public class HaystackClient { public let baseUrl: URL private let username: String private let password: String + private let format: DataFormat /// Set when `login` is called. private var authToken: String? = nil - public init(baseUrl: URL, username: String, password: String) throws { + private let jsonDecoder = JSONDecoder() + + 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 } public func login() async throws { @@ -70,17 +74,49 @@ public class HaystackClient { } public func about() async throws -> Grid { - let aboutUrl = baseUrl.appending(path: "about") - - var request = URLRequest(url: aboutUrl) - request.httpMethod = "GET" - request.addValue("text/zinc", forHTTPHeaderField: "Accept") + return try await requestGrid(path: "about", method: .GET) + } + + private func requestGrid(path: String, method: HttpMethod) async throws -> Grid { + let url = baseUrl.appending(path: path) + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + guard let authToken = authToken else { + throw HaystackClientError.notLoggedIn + } + request.addValue("BEARER \(authToken)", forHTTPHeaderField: "Authentication") + // See Content Negotiation: https://haxall.io/doc/docHaystack/HttpApi.html#contentNegotiation + request.addValue(format.acceptHeaderValue, forHTTPHeaderField: "Accept") let (data, responseGen) = try await URLSession.shared.data(for: request) let response = (responseGen as! HTTPURLResponse) - guard response.value(forHTTPHeaderField: "Content-Type") == "text/zinc" else { + guard + let contentType = response.value(forHTTPHeaderField: "Content-Type"), + contentType.hasPrefix(format.acceptHeaderValue) + else { throw HaystackClientError.responseIsNotZinc } - return try ZincReader(data).readGrid() + 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 + } +} + +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" + } } } @@ -91,6 +127,7 @@ enum HaystackClientError: Error { case authHashFunctionNotRecognized(String) case authMechanismNotRecognized(String) case authMechanismNotImplemented(AuthMechanism) + case notLoggedIn case baseUrlCannotBeFile case responseIsNotZinc } From 643689980f6f95a512856c02bea53b27c70fdc6e Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sat, 26 Nov 2022 22:03:44 -0700 Subject: [PATCH 07/29] fix: Fixes zinc parseList infinite loop --- Sources/Haystack/IO/ZincReader.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Haystack/IO/ZincReader.swift b/Sources/Haystack/IO/ZincReader.swift index eb9c8a8..ba025f8 100644 --- a/Sources/Haystack/IO/ZincReader.swift +++ b/Sources/Haystack/IO/ZincReader.swift @@ -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 { From 3a6ccea5a22cd9142502a287854bd08bab711051 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 27 Nov 2022 22:35:34 -0700 Subject: [PATCH 08/29] fix: Empty grid and `ver` meta support --- Sources/Haystack/Grid.swift | 66 ++++++++++++++++-------- Sources/Haystack/Utils/GridBuilder.swift | 2 +- Tests/HaystackTests/GridTests.swift | 32 +++++++++++- 3 files changed, 76 insertions(+), 24 deletions(-) diff --git a/Sources/Haystack/Grid.swift b/Sources/Haystack/Grid.swift index c508104..e1a6778 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" + } 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/Utils/GridBuilder.swift b/Sources/Haystack/Utils/GridBuilder.swift index c330250..b439c6a 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 = [] diff --git a/Tests/HaystackTests/GridTests.swift b/Tests/HaystackTests/GridTests.swift index fd663a6..5753ca5 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,17 @@ 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 { From 7ac736b344197865bf1c16b68b1c92b3d0ac25cf Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sat, 3 Dec 2022 23:57:50 -0700 Subject: [PATCH 09/29] fix: Empty grid gets newline --- Sources/Haystack/Grid.swift | 2 +- Sources/Haystack/Utils/GridBuilder.swift | 9 +++++++++ Tests/HaystackTests/GridTests.swift | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/Haystack/Grid.swift b/Sources/Haystack/Grid.swift index e1a6778..dd3dd6b 100644 --- a/Sources/Haystack/Grid.swift +++ b/Sources/Haystack/Grid.swift @@ -36,7 +36,7 @@ public struct Grid: Val { zinc += "\n" if cols.isEmpty { - zinc += "empty" + zinc += "empty\n" } else { let zincCols = cols.map { col in var colZinc = col.name diff --git a/Sources/Haystack/Utils/GridBuilder.swift b/Sources/Haystack/Utils/GridBuilder.swift index b439c6a..7fa8347 100644 --- a/Sources/Haystack/Utils/GridBuilder.swift +++ b/Sources/Haystack/Utils/GridBuilder.swift @@ -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/Tests/HaystackTests/GridTests.swift b/Tests/HaystackTests/GridTests.swift index 5753ca5..5ddf357 100644 --- a/Tests/HaystackTests/GridTests.swift +++ b/Tests/HaystackTests/GridTests.swift @@ -76,6 +76,7 @@ final class GridTests: XCTestCase { """ ver:"3.0" empty + """ ) } From 6ef28bcd825ba53f58d25912c0507cf578e7d3c4 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 00:04:31 -0700 Subject: [PATCH 10/29] feature: Isolates sessions to prevent cookie storage --- Sources/HaystackClient/Authenticators.swift | 15 ++++++++++++--- Sources/HaystackClient/HaystackClient.swift | 2 ++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Sources/HaystackClient/Authenticators.swift b/Sources/HaystackClient/Authenticators.swift index 95223cf..6ec3138 100644 --- a/Sources/HaystackClient/Authenticators.swift +++ b/Sources/HaystackClient/Authenticators.swift @@ -12,12 +12,21 @@ struct ScramAuthenticator: Authenticator { let username: String let password: String let handshakeToken: String + let session: URLSession - init(url: URL, username: String, password: String, handshakeToken: String) { + init( + url: URL, + username: String, + password: String, + handshakeToken: String + ) { self.url = url self.username = username self.password = password self.handshakeToken = handshakeToken + + // It seems we need a separate session to avoid storing cookies? I guess? + self.session = URLSession(configuration: .ephemeral) } func getAuthToken() async throws -> String { @@ -44,7 +53,7 @@ struct ScramAuthenticator: Authenticator { ).description, forHTTPHeaderField: "Authorization" ) - let (_, firstResponseGen) = try await URLSession.shared.data(for: firstRequest) + let (_, firstResponseGen) = try await session.data(for: firstRequest) let firstResponse = (firstResponseGen as! HTTPURLResponse) // Server Initiation Response @@ -88,7 +97,7 @@ struct ScramAuthenticator: Authenticator { ).description, forHTTPHeaderField: "Authorization" ) - let (_, finalResponseGen) = try await URLSession.shared.data(for: finalRequest) + let (_, finalResponseGen) = try await session.data(for: finalRequest) let finalResponse = (finalResponseGen as! HTTPURLResponse) // Final Server Message diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index d39db7f..d0e6ba4 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -8,6 +8,7 @@ public class HaystackClient { private let username: String private let password: String private let format: DataFormat + private let session: URLSession /// Set when `login` is called. private var authToken: String? = nil @@ -22,6 +23,7 @@ public class HaystackClient { self.username = username self.password = password self.format = format + self.session = URLSession(configuration: .ephemeral) } public func login() async throws { From 54fcad95deeef8662791e51607abf5a17943095a Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 16:48:24 -0700 Subject: [PATCH 11/29] Feature: Adds client JSON support Also formalizes HTTP header strings --- Sources/HaystackClient/Authenticators.swift | 8 +- Sources/HaystackClient/HaystackClient.swift | 122 +++++++++++++++++--- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/Sources/HaystackClient/Authenticators.swift b/Sources/HaystackClient/Authenticators.swift index 6ec3138..39eaa17 100644 --- a/Sources/HaystackClient/Authenticators.swift +++ b/Sources/HaystackClient/Authenticators.swift @@ -51,7 +51,7 @@ struct ScramAuthenticator: Authenticator { "data": clientFirstMessage.encodeBase64UrlSafe() ] ).description, - forHTTPHeaderField: "Authorization" + forHTTPHeaderField: HTTPHeader.authorization ) let (_, firstResponseGen) = try await session.data(for: firstRequest) let firstResponse = (firstResponseGen as! HTTPURLResponse) @@ -60,7 +60,7 @@ struct ScramAuthenticator: Authenticator { guard firstResponse.statusCode == 401 else { throw ScramAuthenticatorError.FirstResponseStatusIsNot401(firstResponse.statusCode) } - guard let firstResponseHeaderString = firstResponse.value(forHTTPHeaderField: "Www-Authenticate") else { + guard let firstResponseHeaderString = firstResponse.value(forHTTPHeaderField: HTTPHeader.wwwAuthenticate) else { throw ScramAuthenticatorError.FirstResponseNoHeaderWwwAuthenticate } let firstResponseAuth = try AuthMessage.from(firstResponseHeaderString) @@ -95,7 +95,7 @@ struct ScramAuthenticator: Authenticator { "data": clientFinalMessage.encodeBase64UrlSafe() ] ).description, - forHTTPHeaderField: "Authorization" + forHTTPHeaderField: HTTPHeader.authorization ) let (_, finalResponseGen) = try await session.data(for: finalRequest) let finalResponse = (finalResponseGen as! HTTPURLResponse) @@ -104,7 +104,7 @@ struct ScramAuthenticator: Authenticator { guard finalResponse.statusCode == 200 else { throw ScramAuthenticatorError.authFailedWithHttpCode(finalResponse.statusCode) } - guard let finalResponseHeaderString = finalResponse.value(forHTTPHeaderField: "Authentication-Info") else { + guard let finalResponseHeaderString = finalResponse.value(forHTTPHeaderField: HTTPHeader.authenticationInfo) else { throw ScramAuthenticatorError.SecondResponseNoHeaderAuthenticationInfo } let finalResponseAttributes = extractNameValuePairs(from: finalResponseHeaderString) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index d0e6ba4..afc90e7 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -4,6 +4,8 @@ import Foundation @available(macOS 13.0, *) public class HaystackClient { + private let userAgentHeaderValue = "swift-haystack-client" + public let baseUrl: URL private let username: String private let password: String @@ -13,9 +15,15 @@ public class HaystackClient { /// Set when `login` is called. private var authToken: String? = nil + private let jsonEncoder = JSONEncoder() private let jsonDecoder = JSONDecoder() - public init(baseUrl: URL, username: String, password: String, format: DataFormat = .zinc) throws { + public init( + baseUrl: URL, + username: String, + password: String, + format: DataFormat = .zinc + ) throws { guard !baseUrl.isFileURL else { throw HaystackClientError.baseUrlCannotBeFile } @@ -32,9 +40,9 @@ public class HaystackClient { // Hello let helloRequestAuth = AuthMessage(scheme: "hello", attributes: ["username": username.encodeBase64UrlSafe()]) var helloRequest = URLRequest(url: url) - helloRequest.addValue(helloRequestAuth.description, forHTTPHeaderField: "Authorization") + helloRequest.addValue(helloRequestAuth.description, forHTTPHeaderField: HTTPHeader.authorization) let (_, helloResponse) = try await URLSession.shared.data(for: helloRequest) - guard let helloHeaderString = (helloResponse as! HTTPURLResponse).value(forHTTPHeaderField: "Www-Authenticate") else { + guard let helloHeaderString = (helloResponse as! HTTPURLResponse).value(forHTTPHeaderField: HTTPHeader.wwwAuthenticate) else { throw HaystackClientError.authHelloNoWwwAuthenticateHeader } let helloResponseAuth = try AuthMessage.from(helloHeaderString) @@ -76,31 +84,90 @@ public class HaystackClient { } public func about() async throws -> Grid { - return try await requestGrid(path: "about", method: .GET) + return try await request(path: "about", method: .POST) + } + + public func ops() async throws -> Grid { + return try await request(path: "ops", method: .POST) } - private func requestGrid(path: String, method: HttpMethod) async throws -> Grid { - let url = baseUrl.appending(path: path) + private func request(path: String, method: HttpMethod, args: [String: any Val] = [:]) async throws -> Grid { + var url = baseUrl.appending(path: path) + // Adjust url based on GET args + if method == .GET && !args.isEmpty { + var queryItems = [URLQueryItem]() + for (argName, argValue) in args { + queryItems.append(.init(name: argName, value: argValue.toZinc())) + } + url = url.appending(queryItems: queryItems) + } + var request = URLRequest(url: url) request.httpMethod = method.rawValue + + // Adjust body based on POST args + if method == .POST { + 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() + } + let data: Data + switch format { + case .json: + data = try jsonEncoder.encode(grid) + case .zinc: + data = grid.toZinc().data(using: .utf8)! // Unwrap is safe b/c zinc is always UTF8 compatible + } + request.addValue(format.contentTypeHeaderValue, forHTTPHeaderField: HTTPHeader.contentType) + request.httpBody = data + } + + // Set auth token header guard let authToken = authToken else { throw HaystackClientError.notLoggedIn } - request.addValue("BEARER \(authToken)", forHTTPHeaderField: "Authentication") + 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: "Accept") - let (data, responseGen) = try await URLSession.shared.data(for: request) - let response = (responseGen as! HTTPURLResponse) - guard - let contentType = response.value(forHTTPHeaderField: "Content-Type"), - 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() + request.addValue(format.acceptHeaderValue, forHTTPHeaderField: HTTPHeader.accept) + request.addValue(userAgentHeaderValue, forHTTPHeaderField: HTTPHeader.userAgent) + do { + 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() + } + } catch { + throw error } + } private enum HttpMethod: String { @@ -120,6 +187,13 @@ public enum DataFormat: String { case .zinc: return "text/zinc" } } + + var contentTypeHeaderValue: String { + switch self { + case .json: return "application/json" + case .zinc: return "text/zinc; charset=utf-8" + } + } } enum HaystackClientError: Error { @@ -132,6 +206,7 @@ enum HaystackClientError: Error { case notLoggedIn case baseUrlCannotBeFile case responseIsNotZinc + case requestFailed(httpCode: Int, message: String?) } enum AuthMechanism: String { @@ -152,3 +227,12 @@ enum AuthHash: String { } } } + +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" +} From 203d41fcdf12396ab7d4aa54632959d883ec147a Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 17:04:32 -0700 Subject: [PATCH 12/29] fix: Unifies URL session by ignoring cookies --- Sources/HaystackClient/Authenticators.swift | 7 +++---- Sources/HaystackClient/HaystackClient.swift | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Sources/HaystackClient/Authenticators.swift b/Sources/HaystackClient/Authenticators.swift index 39eaa17..0cc06d9 100644 --- a/Sources/HaystackClient/Authenticators.swift +++ b/Sources/HaystackClient/Authenticators.swift @@ -18,15 +18,14 @@ struct ScramAuthenticator: Authenticator { url: URL, username: String, password: String, - handshakeToken: String + handshakeToken: String, + session: URLSession ) { self.url = url self.username = username self.password = password self.handshakeToken = handshakeToken - - // It seems we need a separate session to avoid storing cookies? I guess? - self.session = URLSession(configuration: .ephemeral) + self.session = session } func getAuthToken() async throws -> String { diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index afc90e7..3c961ad 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -31,7 +31,15 @@ public class HaystackClient { self.username = username self.password = password self.format = format - self.session = URLSession(configuration: .ephemeral) + + // 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) } public func login() async throws { @@ -41,7 +49,7 @@ public class HaystackClient { 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 URLSession.shared.data(for: helloRequest) + let (_, helloResponse) = try await session.data(for: helloRequest) guard let helloHeaderString = (helloResponse as! HTTPURLResponse).value(forHTTPHeaderField: HTTPHeader.wwwAuthenticate) else { throw HaystackClientError.authHelloNoWwwAuthenticateHeader } @@ -68,14 +76,16 @@ public class HaystackClient { url: baseUrl, username: username, password: password, - handshakeToken: handshakeToken + handshakeToken: handshakeToken, + session: session ) case .SHA512: authenticator = ScramAuthenticator( url: baseUrl, username: username, password: password, - handshakeToken: handshakeToken + handshakeToken: handshakeToken, + session: session ) } // TODO: Implement PLAINTEXT auth scheme From 77a818015c948749fb3b0d5fbf25f6d820b18a53 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 18:18:48 -0700 Subject: [PATCH 13/29] fix: Corrects zinc ref reading --- Sources/Haystack/IO/ZincReader.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Haystack/IO/ZincReader.swift b/Sources/Haystack/IO/ZincReader.swift index ba025f8..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() From c258818530f44e42badcde586b2328e073a6dcc8 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 18:19:19 -0700 Subject: [PATCH 14/29] feature: Adds defs, libs, ops, filetypes, read, & readAll --- Sources/HaystackClient/HaystackClient.swift | 68 ++++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index 3c961ad..2ae9f70 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -42,7 +42,7 @@ public class HaystackClient { self.session = URLSession(configuration: sessionConfig) } - public func login() async throws { + public func open() async throws { let url = baseUrl.appending(path: "about") // Hello @@ -97,10 +97,72 @@ public class HaystackClient { return try await request(path: "about", method: .POST) } - public func ops() async throws -> Grid { - return try await request(path: "ops", method: .POST) + public func close() async throws { + try await request(path: "close", method: .POST) } + 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 request(path: "defs", method: .POST, args: args) + } + + 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 request(path: "libs", method: .POST, args: args) + } + + 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 request(path: "ops", method: .POST, args: args) + } + + 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 request(path: "filetypes", method: .POST, args: args) + } + + public func read(id: Ref) async throws -> Grid { + // TODO: This doesn't work if you pass multiple IDs (like is documented). Report as bug to Haxall. + return try await request(path: "read", method: .POST, args: ["id": id]) + } + + public func readAll(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 request(path: "read", method: .POST, args: args) + } + + public func nav(navId: Ref) async throws -> Grid { + return try await request(path: "nav", method: .POST, args: ["navId": navId]) + } + + @discardableResult private func request(path: String, method: HttpMethod, args: [String: any Val] = [:]) async throws -> Grid { var url = baseUrl.appending(path: path) // Adjust url based on GET args From dadaf3d33212a375c97987a9cd150250521bd206 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 22:11:56 -0700 Subject: [PATCH 15/29] refactor: Allow Grid post requests --- Sources/HaystackClient/HaystackClient.swift | 129 +++++++++++--------- 1 file changed, 71 insertions(+), 58 deletions(-) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index 2ae9f70..f4502f2 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -94,11 +94,11 @@ public class HaystackClient { } public func about() async throws -> Grid { - return try await request(path: "about", method: .POST) + return try await post(path: "about") } public func close() async throws { - try await request(path: "close", method: .POST) + try await post(path: "close") } public func defs(filter: String? = nil, limit: Number? = nil) async throws -> Grid { @@ -109,7 +109,7 @@ public class HaystackClient { if let limit = limit { args["limit"] = limit } - return try await request(path: "defs", method: .POST, args: args) + return try await post(path: "defs", args: args) } public func libs(filter: String? = nil, limit: Number? = nil) async throws -> Grid { @@ -120,7 +120,7 @@ public class HaystackClient { if let limit = limit { args["limit"] = limit } - return try await request(path: "libs", method: .POST, args: args) + return try await post(path: "libs", args: args) } public func ops(filter: String? = nil, limit: Number? = nil) async throws -> Grid { @@ -131,7 +131,7 @@ public class HaystackClient { if let limit = limit { args["limit"] = limit } - return try await request(path: "ops", method: .POST, args: args) + return try await post(path: "ops", args: args) } public func filetypes(filter: String? = nil, limit: Number? = nil) async throws -> Grid { @@ -142,12 +142,16 @@ public class HaystackClient { if let limit = limit { args["limit"] = limit } - return try await request(path: "filetypes", method: .POST, args: args) + return try await post(path: "filetypes", args: args) } - public func read(id: Ref) async throws -> Grid { - // TODO: This doesn't work if you pass multiple IDs (like is documented). Report as bug to Haxall. - return try await request(path: "read", method: .POST, args: ["id": id]) + 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()) } public func readAll(filter: String, limit: Number? = nil) async throws -> Grid { @@ -155,53 +159,67 @@ public class HaystackClient { if let limit = limit { args["limit"] = limit } - return try await request(path: "read", method: .POST, args: args) + return try await post(path: "read", args: args) } public func nav(navId: Ref) async throws -> Grid { - return try await request(path: "nav", method: .POST, args: ["navId": navId]) + return try await post(path: "nav", args: ["navId": navId]) } @discardableResult - private func request(path: String, method: HttpMethod, args: [String: any Val] = [:]) async throws -> Grid { + 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 method == .GET && !args.isEmpty { + 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 - // Adjust body based on POST args - if method == .POST { - 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() - } - let data: Data + if method == .POST, let grid = grid { + let requestData: Data switch format { case .json: - data = try jsonEncoder.encode(grid) + requestData = try jsonEncoder.encode(grid) case .zinc: - data = grid.toZinc().data(using: .utf8)! // Unwrap is safe b/c zinc is always UTF8 compatible + 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 = data + request.httpBody = requestData } // Set auth token header @@ -215,31 +233,26 @@ public class HaystackClient { // See Content Negotiation: https://haxall.io/doc/docHaystack/HttpApi.html#contentNegotiation request.addValue(format.acceptHeaderValue, forHTTPHeaderField: HTTPHeader.accept) request.addValue(userAgentHeaderValue, forHTTPHeaderField: HTTPHeader.userAgent) - do { - 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() - } - } catch { - throw error + 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 { From 0c8daa3b97ba47a09d0f539ea925f664682c8e54 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 22:31:09 -0700 Subject: [PATCH 16/29] feature: Adds watch ops --- Sources/HaystackClient/HaystackClient.swift | 75 +++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index f4502f2..abb4c5f 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -166,6 +166,81 @@ public class HaystackClient { return try await post(path: "nav", args: ["navId": navId]) } + + 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()) + } + + 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()) + } + + 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()) + } + + 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()) + } + @discardableResult private func post(path: String, args: [String: any Val] = [:]) async throws -> Grid { let grid: Grid From 5a6142f990860d0674540a2ec6269280f0639a95 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 23:06:32 -0700 Subject: [PATCH 17/29] fix: Adds Number.isInt --- Sources/Haystack/Number.swift | 4 ++++ Tests/HaystackTests/NumberTests.swift | 7 +++++++ 2 files changed, 11 insertions(+) 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/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"}"# From 5080e5e7dbf1ccc29b66942ba2949d8d9c4549c3 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 23:06:51 -0700 Subject: [PATCH 18/29] feature: Adds HisRead --- Sources/HaystackClient/HaystackClient.swift | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index abb4c5f..cc3ba47 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -166,6 +166,10 @@ public class HaystackClient { return try await post(path: "nav", args: ["navId": navId]) } + public func hisRead(id: Ref, range: HisReadRange) async throws -> Grid { + return try await post(path: "hisRead", args: ["id": id, "range": range.toRequestString()]) + } + public func watchSubCreate( watchDis: String, @@ -396,3 +400,23 @@ enum HTTPHeader { static let userAgent = "User-Agent" static let wwwAuthenticate = "Www-Authenticate" } + +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())" + } + } +} From b5d2ba8bfca9a5d4f56e27a407cea5a4cde1ed33 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 23:13:50 -0700 Subject: [PATCH 19/29] feature: Adds hisWrite --- Sources/HaystackClient/HaystackClient.swift | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index cc3ba47..b25cadf 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -170,6 +170,17 @@ public class HaystackClient { return try await post(path: "hisRead", args: ["id": id, "range": range.toRequestString()]) } + 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()) + } + public func watchSubCreate( watchDis: String, @@ -420,3 +431,13 @@ public enum HisReadRange { } } } + +public struct HisItem { + let ts: DateTime + let val: any Val + + public init(ts: DateTime, val: any Val) { + self.ts = ts + self.val = val + } +} From 13bb3babb5b8f2e3667b8adc1211e1a119d5d744 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 23:14:57 -0700 Subject: [PATCH 20/29] feature: Adds pointWrite --- Sources/HaystackClient/HaystackClient.swift | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index b25cadf..e5fb810 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -181,6 +181,41 @@ public class HaystackClient { return try await post(path: "hisWrite", grid: builder.toGrid()) } + 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) + } + + public func pointWriteStatus(id: Ref) async throws -> Grid { + return try await post(path: "pointWrite", args: ["id": id]) + } public func watchSubCreate( watchDis: String, @@ -382,6 +417,7 @@ enum HaystackClientError: Error { case baseUrlCannotBeFile case responseIsNotZinc case requestFailed(httpCode: Int, message: String?) + case pointWriteLevelIsNotIntBetween1And17 } enum AuthMechanism: String { From adde4123dd2bab59872253374209809512e30648 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 23:21:50 -0700 Subject: [PATCH 21/29] feature: Adds invokeAction --- Sources/HaystackClient/HaystackClient.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index e5fb810..5e86957 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -291,6 +291,23 @@ public class HaystackClient { return try await post(path: "watchPoll", grid: builder.toGrid()) } + 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()) + } + @discardableResult private func post(path: String, args: [String: any Val] = [:]) async throws -> Grid { let grid: Grid From 00130a8b2c4c467069a150543353bb9b5dfd417f Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 23:27:19 -0700 Subject: [PATCH 22/29] feature: Adds eval --- Sources/HaystackClient/HaystackClient.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/HaystackClient/HaystackClient.swift b/Sources/HaystackClient/HaystackClient.swift index 5e86957..3afd98d 100644 --- a/Sources/HaystackClient/HaystackClient.swift +++ b/Sources/HaystackClient/HaystackClient.swift @@ -308,6 +308,10 @@ public class HaystackClient { return try await post(path: "invokeAction", grid: builder.toGrid()) } + 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 From c090c3cb02c1413963209c05e626eec84b989f87 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 4 Dec 2022 23:47:04 -0700 Subject: [PATCH 23/29] refactor: Splits up files --- .../Authentication/AuthHash.swift | 16 +++++ .../Authentication/AuthMessage.swift | 25 +++++++ .../Authentication/Authenticator.swift | 4 ++ .../ScramAuthenticator.swift} | 31 -------- .../{ => Authentication}/ScramClient.swift | 0 .../String+Base64.swift} | 0 .../{HaystackClient.swift => Client.swift} | 71 +------------------ Sources/HaystackClient/DataFormat.swift | 19 +++++ Sources/HaystackClient/HisItem.swift | 11 +++ Sources/HaystackClient/HisReadRange.swift | 21 ++++++ 10 files changed, 99 insertions(+), 99 deletions(-) create mode 100644 Sources/HaystackClient/Authentication/AuthHash.swift create mode 100644 Sources/HaystackClient/Authentication/AuthMessage.swift create mode 100644 Sources/HaystackClient/Authentication/Authenticator.swift rename Sources/HaystackClient/{Authenticators.swift => Authentication/ScramAuthenticator.swift} (83%) rename Sources/HaystackClient/{ => Authentication}/ScramClient.swift (100%) rename Sources/HaystackClient/{String + Base64.swift => Authentication/String+Base64.swift} (100%) rename Sources/HaystackClient/{HaystackClient.swift => Client.swift} (89%) create mode 100644 Sources/HaystackClient/DataFormat.swift create mode 100644 Sources/HaystackClient/HisItem.swift create mode 100644 Sources/HaystackClient/HisReadRange.swift 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/Authenticators.swift b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift similarity index 83% rename from Sources/HaystackClient/Authenticators.swift rename to Sources/HaystackClient/Authentication/ScramAuthenticator.swift index 0cc06d9..a47755f 100644 --- a/Sources/HaystackClient/Authenticators.swift +++ b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift @@ -1,11 +1,6 @@ import CryptoKit import Foundation -@available(macOS 13.0, *) -protocol Authenticator { - func getAuthToken() async throws -> String -} - @available(macOS 13.0, *) struct ScramAuthenticator: Authenticator { let url: URL @@ -144,29 +139,3 @@ struct ScramAuthenticator: Authenticator { case authFailedWithHttpCode(Int) } } - -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 { - 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())" - } - } -} - -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/DataFormat.swift b/Sources/HaystackClient/DataFormat.swift new file mode 100644 index 0000000..4312186 --- /dev/null +++ b/Sources/HaystackClient/DataFormat.swift @@ -0,0 +1,19 @@ +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..61468bb --- /dev/null +++ b/Sources/HaystackClient/HisItem.swift @@ -0,0 +1,11 @@ +import Haystack + +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..d59e538 --- /dev/null +++ b/Sources/HaystackClient/HisReadRange.swift @@ -0,0 +1,21 @@ +import Haystack + +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())" + } + } +} From 0371f3c5a9a12d94c014f3c833afb9e58bfa1fff Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 14 Dec 2022 20:28:40 -0700 Subject: [PATCH 24/29] doc: Adds public API documentation --- Sources/HaystackClient/Client.swift | 197 ++++++++++++++++++++-- Sources/HaystackClient/DataFormat.swift | 2 + Sources/HaystackClient/HisItem.swift | 1 + Sources/HaystackClient/HisReadRange.swift | 1 + 4 files changed, 186 insertions(+), 15 deletions(-) diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift index b1670e5..db88aa8 100644 --- a/Sources/HaystackClient/Client.swift +++ b/Sources/HaystackClient/Client.swift @@ -3,21 +3,37 @@ 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 { - private let userAgentHeaderValue = "swift-haystack-client" + let baseUrl: URL + let username: String + let password: String + let format: DataFormat + let session: URLSession - public let baseUrl: URL - private let username: String - private let password: String - private let format: DataFormat - private let session: URLSession - - /// Set when `login` is called. + /// 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, @@ -42,6 +58,7 @@ public class Client { self.session = URLSession(configuration: sessionConfig) } + /// Authenticate the client and store the authentication token public func open() async throws { let url = baseUrl.appending(path: "about") @@ -93,14 +110,29 @@ public class Client { self.authToken = try await authenticator.getAuthToken() } - public func about() async throws -> Grid { - return try await post(path: "about") - } - + /// 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 { @@ -112,6 +144,14 @@ public class Client { 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 { @@ -123,6 +163,14 @@ public class Client { 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 { @@ -134,6 +182,14 @@ public class Client { 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 { @@ -145,6 +201,12 @@ public class Client { 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") @@ -154,6 +216,14 @@ public class Client { 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 readAll(filter: String, limit: Number? = nil) async throws -> Grid { var args: [String: any Val] = ["filter": filter] if let limit = limit { @@ -162,14 +232,40 @@ public class Client { return try await post(path: "read", args: args) } - public func nav(navId: Ref) async throws -> Grid { - return try await post(path: "nav", args: ["navId": navId]) + /// 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]) @@ -181,6 +277,17 @@ public class Client { 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, @@ -213,10 +320,26 @@ public class Client { 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, @@ -237,6 +360,16 @@ public class Client { 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, @@ -257,6 +390,14 @@ public class Client { 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] @@ -276,6 +417,14 @@ public class Client { 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 @@ -291,7 +440,17 @@ public class Client { return try await post(path: "watchPoll", grid: builder.toGrid()) } - public func invokeAction(id: Ref, action: String, args: [String: any Val]) 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 + public func invokeAction( + id: Ref, + action: String, + args: [String: any Val] + ) async throws -> Grid { let gridMeta: [String: any Val] = [ "id": id, "action": action @@ -308,6 +467,12 @@ public class Client { 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]) } @@ -407,6 +572,8 @@ public class Client { } } +private let userAgentHeaderValue = "swift-haystack-client" + enum HaystackClientError: Error { case authHelloNoWwwAuthenticateHeader case authHelloHandshakeTokenNotPresent diff --git a/Sources/HaystackClient/DataFormat.swift b/Sources/HaystackClient/DataFormat.swift index 4312186..69daf19 100644 --- a/Sources/HaystackClient/DataFormat.swift +++ b/Sources/HaystackClient/DataFormat.swift @@ -1,3 +1,5 @@ +/// Haystack data serialization formats. For more information, see +/// https://project-haystack.org/doc/docHaystack/HttpApi#contentNegotiation public enum DataFormat: String { case json case zinc diff --git a/Sources/HaystackClient/HisItem.swift b/Sources/HaystackClient/HisItem.swift index 61468bb..c4ab4a3 100644 --- a/Sources/HaystackClient/HisItem.swift +++ b/Sources/HaystackClient/HisItem.swift @@ -1,5 +1,6 @@ import Haystack +/// A timestamp/value pair. public struct HisItem { let ts: DateTime let val: any Val diff --git a/Sources/HaystackClient/HisReadRange.swift b/Sources/HaystackClient/HisReadRange.swift index d59e538..8310552 100644 --- a/Sources/HaystackClient/HisReadRange.swift +++ b/Sources/HaystackClient/HisReadRange.swift @@ -1,5 +1,6 @@ import Haystack +/// Query-able DateTime ranges, which support relative and absolute values. public enum HisReadRange { case today case yesterday From a04cb95ded5917797b674156cf8e1c6f8f57a813 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 14 Dec 2022 20:42:49 -0700 Subject: [PATCH 25/29] fix: Fixes client op method name --- Sources/HaystackClient/Client.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift index db88aa8..f63b8c7 100644 --- a/Sources/HaystackClient/Client.swift +++ b/Sources/HaystackClient/Client.swift @@ -224,7 +224,7 @@ public class Client { /// - 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 readAll(filter: String, limit: Number? = nil) async throws -> Grid { + 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 From cc13dcb1a14949360985d9c81e090db06b03ee0b Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 14 Dec 2022 20:43:02 -0700 Subject: [PATCH 26/29] feature: Adds Haxall integration tests --- Package.swift | 4 + .../HaystackClientIntegrationTests.swift | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift diff --git a/Package.swift b/Package.swift index 7d195f5..944cb6c 100644 --- a/Package.swift +++ b/Package.swift @@ -32,5 +32,9 @@ let package = Package( name: "HaystackClientTests", dependencies: ["HaystackClient"] ), + .testTarget( + name: "HaystackClientIntegrationTests", + dependencies: ["HaystackClient"] + ), ] ) 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")])) + } +} From 305c924dce911e72672d72c244a8173e9512761d Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 14 Dec 2022 20:44:13 -0700 Subject: [PATCH 27/29] chore: Removes spm artifacts --- .../xcshareddata/IDEWorkspaceChecks.plist | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist 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 - - - From 4fbd1bd0f17b82074de1a4b7292199747e5a33ab Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 14 Dec 2022 20:44:37 -0700 Subject: [PATCH 28/29] chore: ignores spm artifacts --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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/ From eecc797ffb826a9470196b54908b3fd8df3a4177 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Wed, 14 Dec 2022 20:47:03 -0700 Subject: [PATCH 29/29] chore: Adds merge request tests --- .github/workflows/test.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/test.yml 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