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