From 8ec1985aaf854215db38282cd7a5092507ac2914 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sat, 17 Dec 2022 18:50:01 -0700 Subject: [PATCH 01/11] feature: Changes from CryptoKit to Crypto This adds linux support --- Package.resolved | 14 ++++++++++++++ Package.swift | 15 +++++++++++++-- .../HaystackClient/Authentication/AuthHash.swift | 6 +++--- .../Authentication/ScramAuthenticator.swift | 2 +- .../Authentication/ScramClient.swift | 2 +- Sources/HaystackClient/Client.swift | 2 +- Tests/HaystackClientTests/SCRAMTests.swift | 2 +- 7 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 Package.resolved diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..bc881c6 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "92a04c10fc5ce0504f8396aac7392126033e547c", + "version" : "2.2.2" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 944cb6c..3d07ad4 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,12 @@ import PackageDescription let package = Package( name: "Haystack", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + ], products: [ .library( name: "Haystack", @@ -14,7 +20,9 @@ let package = Package( targets: ["HaystackClient"] ), ], - dependencies: [], + dependencies: [ + .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"), + ], targets: [ .target( name: "Haystack", @@ -22,7 +30,10 @@ let package = Package( ), .target( name: "HaystackClient", - dependencies: ["Haystack"] + dependencies: [ + "Haystack", + .product(name: "Crypto", package: "swift-crypto") + ] ), .testTarget( name: "HaystackTests", diff --git a/Sources/HaystackClient/Authentication/AuthHash.swift b/Sources/HaystackClient/Authentication/AuthHash.swift index c64ef69..69a9970 100644 --- a/Sources/HaystackClient/Authentication/AuthHash.swift +++ b/Sources/HaystackClient/Authentication/AuthHash.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto @available(macOS 10.15, *) enum AuthHash: String { @@ -8,9 +8,9 @@ enum AuthHash: String { var hash: any HashFunction.Type { switch self { case .SHA256: - return CryptoKit.SHA256.self + return Crypto.SHA256.self case .SHA512: - return CryptoKit.SHA512.self + return Crypto.SHA512.self } } } diff --git a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift index a47755f..ceffa8d 100644 --- a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift +++ b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto import Foundation @available(macOS 13.0, *) diff --git a/Sources/HaystackClient/Authentication/ScramClient.swift b/Sources/HaystackClient/Authentication/ScramClient.swift index c44d01f..9c69aed 100644 --- a/Sources/HaystackClient/Authentication/ScramClient.swift +++ b/Sources/HaystackClient/Authentication/ScramClient.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto import Foundation @available(macOS 10.15, *) diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift index f63b8c7..f242992 100644 --- a/Sources/HaystackClient/Client.swift +++ b/Sources/HaystackClient/Client.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto import Haystack import Foundation diff --git a/Tests/HaystackClientTests/SCRAMTests.swift b/Tests/HaystackClientTests/SCRAMTests.swift index 5168a09..2057bbd 100644 --- a/Tests/HaystackClientTests/SCRAMTests.swift +++ b/Tests/HaystackClientTests/SCRAMTests.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto import XCTest @testable import HaystackClient From 164cbd4d8dc9eb0695cbdc3859372887a364ba3a Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 18 Dec 2022 00:41:38 -0700 Subject: [PATCH 02/11] feature: Loosens macos version requirements --- .../Authentication/AuthHash.swift | 1 - .../Authentication/ScramAuthenticator.swift | 15 +++++--- Sources/HaystackClient/Client.swift | 35 +++++++++++-------- .../HaystackClientIntegrationTests.swift | 3 +- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/Sources/HaystackClient/Authentication/AuthHash.swift b/Sources/HaystackClient/Authentication/AuthHash.swift index 69a9970..888556e 100644 --- a/Sources/HaystackClient/Authentication/AuthHash.swift +++ b/Sources/HaystackClient/Authentication/AuthHash.swift @@ -1,6 +1,5 @@ import Crypto -@available(macOS 10.15, *) enum AuthHash: String { case SHA512 = "SHA-512" case SHA256 = "SHA-256" diff --git a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift index ceffa8d..7f8e7c2 100644 --- a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift +++ b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift @@ -1,22 +1,25 @@ import Crypto import Foundation -@available(macOS 13.0, *) struct ScramAuthenticator: Authenticator { - let url: URL + let url: String let username: String let password: String let handshakeToken: String let session: URLSession init( - url: URL, + url: String, username: String, password: String, handshakeToken: String, session: URLSession ) { - self.url = url + var urlWithSlash = url + if !urlWithSlash.hasSuffix("/") { + urlWithSlash += "/" + } + self.url = urlWithSlash self.username = username self.password = password self.handshakeToken = handshakeToken @@ -24,7 +27,9 @@ struct ScramAuthenticator: Authenticator { } func getAuthToken() async throws -> String { - let aboutUrl = url.appending(path: "about") + guard let aboutUrl = URL(string: url + "about") else { + throw HaystackClientError.invalidUrl(url + "about") + } let scram = ScramClient( hash: Hash.self, diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift index f242992..1fc9352 100644 --- a/Sources/HaystackClient/Client.swift +++ b/Sources/HaystackClient/Client.swift @@ -2,7 +2,6 @@ import Crypto import Haystack import Foundation -@available(macOS 13.0, *) /// A Haystack API client. Once created, call the `open` method to connect. /// /// ```swift @@ -16,7 +15,7 @@ import Foundation /// await try client.close() /// ``` public class Client { - let baseUrl: URL + let baseUrl: String let username: String let password: String let format: DataFormat @@ -35,15 +34,16 @@ public class Client { /// - password: The password to authenticate with /// - format: The transfer data format. Defaults to `zinc` to reduce data transfer. public init( - baseUrl: URL, + baseUrl: String, username: String, password: String, format: DataFormat = .zinc ) throws { - guard !baseUrl.isFileURL else { - throw HaystackClientError.baseUrlCannotBeFile + var urlWithSlash = baseUrl + if !urlWithSlash.hasSuffix("/") { + urlWithSlash += "/" } - self.baseUrl = baseUrl + self.baseUrl = urlWithSlash self.username = username self.password = password self.format = format @@ -60,7 +60,9 @@ public class Client { /// Authenticate the client and store the authentication token public func open() async throws { - let url = baseUrl.appending(path: "about") + guard let url = URL(string: baseUrl + "about") else { + throw HaystackClientError.invalidUrl(baseUrl + "about") + } // Hello let helloRequestAuth = AuthMessage(scheme: "hello", attributes: ["username": username.encodeBase64UrlSafe()]) @@ -499,22 +501,26 @@ public class Client { @discardableResult private func post(path: String, grid: Grid) async throws -> Grid { - let url = baseUrl.appending(path: path) + guard let url = URL(string: baseUrl + path) else { + throw HaystackClientError.invalidUrl(baseUrl + 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) + var url = baseUrl + 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())) + let argStrings = args.map { (argName, argValue) in + "\(argName)=\(argValue.toZinc())" } - url = url.appending(queryItems: queryItems) + url += "?\(argStrings.joined(separator: "&"))" + } + guard let finalUrl = URL(string: url) else { + throw HaystackClientError.invalidUrl(url) } - return try await execute(url: url, method: .GET) + return try await execute(url: finalUrl, method: .GET) } private func execute(url: URL, method: HttpMethod, grid: Grid? = nil) async throws -> Grid { @@ -582,6 +588,7 @@ enum HaystackClientError: Error { case authMechanismNotRecognized(String) case authMechanismNotImplemented(AuthMechanism) case baseUrlCannotBeFile + case invalidUrl(String) case notLoggedIn case pointWriteLevelIsNotIntBetween1And17 case responseIsNotZinc diff --git a/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift b/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift index e2cd8fa..0e40dd0 100644 --- a/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift +++ b/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift @@ -2,12 +2,11 @@ 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/")!, + baseUrl: "http://localhost:8080/api/", username: ProcessInfo.processInfo.environment["HAYSTACK_USER"] ?? "su", password: ProcessInfo.processInfo.environment["HAYSTACK_PASSWORD"] ?? "su" ) From 2ffbbc8043da5ba3aa3db2f574a89fa5d0d902bb Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 25 Dec 2022 14:29:19 -0700 Subject: [PATCH 03/11] refactor: Isolates URLSession Since URLSession is not available on Linux, we plan to remove it an simply offer it as an alternative fetcher on Darwin platforms. --- .../Authentication/ScramAuthenticator.swift | 58 ++++++------ Sources/HaystackClient/Client.swift | 90 ++++++++----------- Sources/HaystackClient/Fetcher.swift | 44 +++++++++ .../HaystackClient/URLSessionFetcher.swift | 47 ++++++++++ 4 files changed, 153 insertions(+), 86 deletions(-) create mode 100644 Sources/HaystackClient/Fetcher.swift create mode 100644 Sources/HaystackClient/URLSessionFetcher.swift diff --git a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift index 7f8e7c2..42f02f9 100644 --- a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift +++ b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift @@ -6,14 +6,14 @@ struct ScramAuthenticator: Authenticator { let username: String let password: String let handshakeToken: String - let session: URLSession + let fetcher: Fetcher init( url: String, username: String, password: String, handshakeToken: String, - session: URLSession + fetcher: Fetcher ) { var urlWithSlash = url if !urlWithSlash.hasSuffix("/") { @@ -23,13 +23,11 @@ struct ScramAuthenticator: Authenticator { self.username = username self.password = password self.handshakeToken = handshakeToken - self.session = session + self.fetcher = fetcher } func getAuthToken() async throws -> String { - guard let aboutUrl = URL(string: url + "about") else { - throw HaystackClientError.invalidUrl(url + "about") - } + let aboutUrl = url + "about" let scram = ScramClient( hash: Hash.self, @@ -41,25 +39,23 @@ struct ScramAuthenticator: Authenticator { // 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 firstRequest = Request( + url: aboutUrl, + headerAuthorization: AuthMessage( + scheme: "scram", + attributes: [ + "handshakeToken": handshakeToken, + "data": clientFirstMessage.encodeBase64UrlSafe() + ] + ).description ) - let (_, firstResponseGen) = try await session.data(for: firstRequest) - let firstResponse = (firstResponseGen as! HTTPURLResponse) + let firstResponse = try await fetcher.fetch(firstRequest) // Server Initiation Response guard firstResponse.statusCode == 401 else { throw ScramAuthenticatorError.FirstResponseStatusIsNot401(firstResponse.statusCode) } - guard let firstResponseHeaderString = firstResponse.value(forHTTPHeaderField: HTTPHeader.wwwAuthenticate) else { + guard let firstResponseHeaderString = firstResponse.headerWwwAuthenticate else { throw ScramAuthenticatorError.FirstResponseNoHeaderWwwAuthenticate } let firstResponseAuth = try AuthMessage.from(firstResponseHeaderString) @@ -85,25 +81,23 @@ struct ScramAuthenticator: Authenticator { // 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 finalRequest = Request( + url: aboutUrl, + headerAuthorization: AuthMessage( + scheme: "scram", + attributes: [ + "handshakeToken": handshakeToken2, + "data": clientFinalMessage.encodeBase64UrlSafe() + ] + ).description ) - let (_, finalResponseGen) = try await session.data(for: finalRequest) - let finalResponse = (finalResponseGen as! HTTPURLResponse) + let finalResponse = try await fetcher.fetch(finalRequest) // Final Server Message guard finalResponse.statusCode == 200 else { throw ScramAuthenticatorError.authFailedWithHttpCode(finalResponse.statusCode) } - guard let finalResponseHeaderString = finalResponse.value(forHTTPHeaderField: HTTPHeader.authenticationInfo) else { + guard let finalResponseHeaderString = finalResponse.headerAuthenticationInfo else { throw ScramAuthenticatorError.SecondResponseNoHeaderAuthenticationInfo } let finalResponseAttributes = extractNameValuePairs(from: finalResponseHeaderString) diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift index 1fc9352..7f17aa4 100644 --- a/Sources/HaystackClient/Client.swift +++ b/Sources/HaystackClient/Client.swift @@ -19,7 +19,7 @@ public class Client { let username: String let password: String let format: DataFormat - let session: URLSession + let fetcher: Fetcher /// Set when `open` is called. private var authToken: String? = nil @@ -47,29 +47,23 @@ public class Client { 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) + self.fetcher = URLSessionFetcher() } /// Authenticate the client and store the authentication token public func open() async throws { - guard let url = URL(string: baseUrl + "about") else { - throw HaystackClientError.invalidUrl(baseUrl + "about") - } + let url = baseUrl + "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 { + let helloRequest = Request( + url: url, + headerAuthorization: AuthMessage( + scheme: "hello", + attributes: ["username": username.encodeBase64UrlSafe()] + ).description + ) + let helloResponse = try await fetcher.fetch(helloRequest) + guard let helloHeaderString = helloResponse.headerWwwAuthenticate else { throw HaystackClientError.authHelloNoWwwAuthenticateHeader } let helloResponseAuth = try AuthMessage.from(helloHeaderString) @@ -96,7 +90,7 @@ public class Client { username: username, password: password, handshakeToken: handshakeToken, - session: session + fetcher: fetcher ) case .SHA512: authenticator = ScramAuthenticator( @@ -104,7 +98,7 @@ public class Client { username: username, password: password, handshakeToken: handshakeToken, - session: session + fetcher: fetcher ) } // TODO: Implement PLAINTEXT auth scheme @@ -501,10 +495,7 @@ public class Client { @discardableResult private func post(path: String, grid: Grid) async throws -> Grid { - guard let url = URL(string: baseUrl + path) else { - throw HaystackClientError.invalidUrl(baseUrl + path) - } - return try await execute(url: url, method: .POST, grid: grid) + return try await execute(url: baseUrl + path, method: .POST, grid: grid) } @discardableResult @@ -517,65 +508,56 @@ public class Client { } url += "?\(argStrings.joined(separator: "&"))" } - guard let finalUrl = URL(string: url) else { - throw HaystackClientError.invalidUrl(url) - } - return try await execute(url: finalUrl, method: .GET) + 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 - + private func execute(url: String, method: HttpMethod, grid: Grid? = nil) async throws -> Grid { + var data: Data? = nil + var headerContentType: String? = nil if method == .POST, let grid = grid { - let requestData: Data switch format { case .json: - requestData = try jsonEncoder.encode(grid) + data = try jsonEncoder.encode(grid) case .zinc: - requestData = grid.toZinc().data(using: .utf8)! // Unwrap is safe b/c zinc is always UTF8 compatible + 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 = requestData + headerContentType = format.contentTypeHeaderValue } // Set auth token header guard let authToken = authToken else { throw HaystackClientError.notLoggedIn } - request.addValue( - AuthMessage(scheme: "Bearer", attributes: ["authToken": authToken]).description, - forHTTPHeaderField: HTTPHeader.authorization + let headerAuthorization = AuthMessage(scheme: "Bearer", attributes: ["authToken": authToken]).description + + let request = Request( + method: method, + url: url, + headerAuthorization: headerAuthorization, + headerAccept: format.acceptHeaderValue, + headerContentType: headerContentType, + data: data ) - // 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) + let response = try await fetcher.fetch(request) guard response.statusCode == 200 else { throw HaystackClientError.requestFailed( httpCode: response.statusCode, - message: String(data: data, encoding: .utf8) + message: String(data: response.data, encoding: .utf8) ) } guard - let contentType = response.value(forHTTPHeaderField: HTTPHeader.contentType), + let contentType = response.headerContentType, contentType.hasPrefix(format.acceptHeaderValue) else { throw HaystackClientError.responseIsNotZinc } switch format { case .json: - return try jsonDecoder.decode(Grid.self, from: data) + return try jsonDecoder.decode(Grid.self, from: response.data) case .zinc: - return try ZincReader(data).readGrid() + return try ZincReader(response.data).readGrid() } } - - private enum HttpMethod: String { - case GET - case POST - } } private let userAgentHeaderValue = "swift-haystack-client" diff --git a/Sources/HaystackClient/Fetcher.swift b/Sources/HaystackClient/Fetcher.swift new file mode 100644 index 0000000..0723497 --- /dev/null +++ b/Sources/HaystackClient/Fetcher.swift @@ -0,0 +1,44 @@ +import Foundation + +protocol Fetcher { + func fetch(_ request: Request) async throws -> Response +} + +struct Response { + let statusCode: Int + let headerAuthenticationInfo: String? + let headerContentType: String? + let headerWwwAuthenticate: String? + let data: Data +} + +struct Request { + let method: HttpMethod + let url: String + let headerAuthorization: String + let headerUserAgent: String = "swift-haystack-client" + let headerAccept: String + let headerContentType: String? + let data: Data? + + init( + method: HttpMethod = .GET, + url: String, + headerAuthorization: String, + headerAccept: String = DataFormat.zinc.acceptHeaderValue, + headerContentType: String? = nil, + data: Data? = nil + ) { + self.method = method + self.url = url + self.headerAuthorization = headerAuthorization + self.headerAccept = headerAccept + self.headerContentType = headerContentType + self.data = data + } +} + +enum HttpMethod: String { + case GET + case POST +} diff --git a/Sources/HaystackClient/URLSessionFetcher.swift b/Sources/HaystackClient/URLSessionFetcher.swift new file mode 100644 index 0000000..ba3a0b1 --- /dev/null +++ b/Sources/HaystackClient/URLSessionFetcher.swift @@ -0,0 +1,47 @@ +import Foundation + +// URLSession IS NOT AVAILABLE ON LINUX! +struct URLSessionFetcher: Fetcher { + let session: URLSession + + init() { + // 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) + } + + func fetch(_ request: Request) async throws -> Response { + guard let url = URL(string: request.url) else { + throw HaystackClientError.invalidUrl(request.url) + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method.rawValue + + if request.method == .POST, + let data = request.data, + let conentType = request.headerContentType { + urlRequest.addValue(conentType, forHTTPHeaderField: HTTPHeader.contentType) + urlRequest.httpBody = data + } + + urlRequest.addValue(request.headerAuthorization, forHTTPHeaderField: HTTPHeader.authorization) + + // See Content Negotiation: https://haxall.io/doc/docHaystack/HttpApi.html#contentNegotiation + urlRequest.addValue(request.headerAccept, forHTTPHeaderField: HTTPHeader.accept) + urlRequest.addValue(request.headerUserAgent, forHTTPHeaderField: HTTPHeader.userAgent) + let (data, responseGen) = try await session.data(for: urlRequest) + let response = (responseGen as! HTTPURLResponse) + return Response( + statusCode: response.statusCode, + headerAuthenticationInfo: response.value(forHTTPHeaderField: HTTPHeader.authenticationInfo), + headerContentType: response.value(forHTTPHeaderField: HTTPHeader.contentType), + headerWwwAuthenticate: response.value(forHTTPHeaderField: HTTPHeader.wwwAuthenticate), + data: data + ) + } +} From 46aa31c3bdb288b86609476c4de85609960e4887 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 26 Dec 2022 10:04:43 -0800 Subject: [PATCH 04/11] refactor: Adds HTTPClientFetcher This is available on linux --- Package.resolved | 81 +++++++++++++++++++ Package.swift | 4 +- Sources/HaystackClient/Client.swift | 35 ++++---- Sources/HaystackClient/Fetcher.swift | 16 ++-- .../HaystackClient/HTTPClientFetcher.swift | 50 ++++++++++++ .../HaystackClient/URLSessionFetcher.swift | 12 +-- 6 files changed, 162 insertions(+), 36 deletions(-) create mode 100644 Sources/HaystackClient/HTTPClientFetcher.swift diff --git a/Package.resolved b/Package.resolved index bc881c6..14112d4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,32 @@ { "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "5bee16a79922e3efcb5cea06ecd27e6f8048b56b", + "version" : "1.13.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "ff3d2212b6b093db7f177d0855adbc4ef9c5f036", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", @@ -8,6 +35,60 @@ "revision" : "92a04c10fc5ce0504f8396aac7392126033e547c", "version" : "2.2.2" } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c", + "version" : "1.4.4" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "e855380cb5234e96b760d93e0bfdc403e381e928", + "version" : "2.45.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "91dd2d61fb772e1311bb5f13b59266b579d77e42", + "version" : "1.15.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "d6656967f33ed8b368b38e4b198631fc7c484a40", + "version" : "1.23.1" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "4fb7ead803e38949eb1d6fabb849206a72c580f3", + "version" : "2.23.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "c0d9a144cfaec8d3d596aadde3039286a266c15c", + "version" : "1.15.0" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 3d07ad4..caa47b3 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.9.0") ], targets: [ .target( @@ -32,7 +33,8 @@ let package = Package( name: "HaystackClient", dependencies: [ "Haystack", - .product(name: "Crypto", package: "swift-crypto") + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), ] ), .testTarget( diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift index 7f17aa4..3807fea 100644 --- a/Sources/HaystackClient/Client.swift +++ b/Sources/HaystackClient/Client.swift @@ -2,6 +2,8 @@ import Crypto import Haystack import Foundation +import AsyncHTTPClient + /// A Haystack API client. Once created, call the `open` method to connect. /// /// ```swift @@ -47,7 +49,7 @@ public class Client { self.username = username self.password = password self.format = format - self.fetcher = URLSessionFetcher() + self.fetcher = HTTPClient(eventLoopGroupProvider: .createNew).haystackFetcher() } /// Authenticate the client and store the authentication token @@ -495,7 +497,18 @@ public class Client { @discardableResult private func post(path: String, grid: Grid) async throws -> Grid { - return try await execute(url: baseUrl + path, method: .POST, grid: grid) + 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 + } + let headerContentType = format.contentTypeHeaderValue + return try await execute( + url: baseUrl + path, + method: .POST(contentType: headerContentType, data: data) + ) } @discardableResult @@ -511,19 +524,7 @@ public class Client { return try await execute(url: url, method: .GET) } - private func execute(url: String, method: HttpMethod, grid: Grid? = nil) async throws -> Grid { - var data: Data? = nil - var headerContentType: String? = nil - if method == .POST, let grid = grid { - 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 - } - headerContentType = format.contentTypeHeaderValue - } - + private func execute(url: String, method: HaystackHttpMethod) async throws -> Grid { // Set auth token header guard let authToken = authToken else { throw HaystackClientError.notLoggedIn @@ -534,9 +535,7 @@ public class Client { method: method, url: url, headerAuthorization: headerAuthorization, - headerAccept: format.acceptHeaderValue, - headerContentType: headerContentType, - data: data + headerAccept: format.acceptHeaderValue ) let response = try await fetcher.fetch(request) guard response.statusCode == 200 else { diff --git a/Sources/HaystackClient/Fetcher.swift b/Sources/HaystackClient/Fetcher.swift index 0723497..2552416 100644 --- a/Sources/HaystackClient/Fetcher.swift +++ b/Sources/HaystackClient/Fetcher.swift @@ -13,32 +13,26 @@ struct Response { } struct Request { - let method: HttpMethod + let method: HaystackHttpMethod let url: String let headerAuthorization: String let headerUserAgent: String = "swift-haystack-client" let headerAccept: String - let headerContentType: String? - let data: Data? init( - method: HttpMethod = .GET, + method: HaystackHttpMethod = .GET, url: String, headerAuthorization: String, - headerAccept: String = DataFormat.zinc.acceptHeaderValue, - headerContentType: String? = nil, - data: Data? = nil + headerAccept: String = DataFormat.zinc.acceptHeaderValue ) { self.method = method self.url = url self.headerAuthorization = headerAuthorization self.headerAccept = headerAccept - self.headerContentType = headerContentType - self.data = data } } -enum HttpMethod: String { +enum HaystackHttpMethod { case GET - case POST + case POST(contentType: String, data: Data) } diff --git a/Sources/HaystackClient/HTTPClientFetcher.swift b/Sources/HaystackClient/HTTPClientFetcher.swift new file mode 100644 index 0000000..87716b4 --- /dev/null +++ b/Sources/HaystackClient/HTTPClientFetcher.swift @@ -0,0 +1,50 @@ +import Foundation +import AsyncHTTPClient +import NIO + +public extension HTTPClient { + func haystackFetcher() -> HTTPClientFetcher { + return HTTPClientFetcher(self) + } +} + +// HTTPClient is available on all platforms but includes many more dependencies +public struct HTTPClientFetcher: Fetcher { + let client: HTTPClient + + init(_ client: HTTPClient) { + self.client = client + } + + func fetch(_ request: Request) async throws -> Response { + var httpClientRequest = HTTPClientRequest(url: request.url) + + switch request.method { + case .GET: + httpClientRequest.method = .GET + case let .POST(contentType, data): + httpClientRequest.method = .POST + httpClientRequest.headers.add(name: HTTPHeader.contentType, value: contentType) + httpClientRequest.body = .bytes(ByteBuffer(data: data)) + } + httpClientRequest.headers.add(name: HTTPHeader.authorization, value: request.headerAuthorization) + + // See Content Negotiation: https://haxall.io/doc/docHaystack/HttpApi.html#contentNegotiation + httpClientRequest.headers.add(name: HTTPHeader.accept, value: request.headerAccept) + httpClientRequest.headers.add(name: HTTPHeader.userAgent, value: request.headerUserAgent) + + let response = try await self.client.execute(httpClientRequest, timeout: .seconds(30)) + + let data = try await response.body.reduce(into: Data(), { partialResult, byteBuffer in + partialResult.append(byteBuffer.getData(at: 0, length: byteBuffer.readableBytes)!) + }) + + return Response( + statusCode: Int(response.status.code), + headerAuthenticationInfo: response.headers.first(name: HTTPHeader.authenticationInfo), + headerContentType: response.headers.first(name: HTTPHeader.contentType), + headerWwwAuthenticate: response.headers.first(name: HTTPHeader.wwwAuthenticate), + data: data + ) + } +} diff --git a/Sources/HaystackClient/URLSessionFetcher.swift b/Sources/HaystackClient/URLSessionFetcher.swift index ba3a0b1..4c5cfaa 100644 --- a/Sources/HaystackClient/URLSessionFetcher.swift +++ b/Sources/HaystackClient/URLSessionFetcher.swift @@ -20,15 +20,15 @@ struct URLSessionFetcher: Fetcher { } var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = request.method.rawValue - if request.method == .POST, - let data = request.data, - let conentType = request.headerContentType { - urlRequest.addValue(conentType, forHTTPHeaderField: HTTPHeader.contentType) + switch request.method { + case .GET: + urlRequest.httpMethod = "GET" + case let .POST(contentType, data): + urlRequest.httpMethod = "POST" + urlRequest.addValue(contentType, forHTTPHeaderField: HTTPHeader.contentType) urlRequest.httpBody = data } - urlRequest.addValue(request.headerAuthorization, forHTTPHeaderField: HTTPHeader.authorization) // See Content Negotiation: https://haxall.io/doc/docHaystack/HttpApi.html#contentNegotiation From 5b8d90cf79d357647dcce0602e220e78e4b8fe6e Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 26 Dec 2022 11:12:08 -0800 Subject: [PATCH 05/11] refactor: Splits Darwin & NIO packages --- Package.swift | 36 ++++++- .../Authentication/ScramAuthenticator.swift | 4 +- Sources/HaystackClient/Client.swift | 25 +++-- Sources/HaystackClient/Fetcher.swift | 44 ++++++--- .../Client+URLSession.swift | 20 ++++ Sources/HaystackClientDarwin/Exports.swift | 1 + .../URLSessionFetcher.swift | 11 ++- .../HaystackClientNIO/Client+HTTPClient.swift | 24 +++++ Sources/HaystackClientNIO/Exports.swift | 1 + .../HTTPClientFetcher.swift | 11 ++- ...aystackClientDarwinIntegrationTests.swift} | 4 +- .../HaystackClientNIOIntegrationTests.swift | 94 +++++++++++++++++++ 12 files changed, 231 insertions(+), 44 deletions(-) create mode 100644 Sources/HaystackClientDarwin/Client+URLSession.swift create mode 100644 Sources/HaystackClientDarwin/Exports.swift rename Sources/{HaystackClient => HaystackClientDarwin}/URLSessionFetcher.swift (87%) create mode 100644 Sources/HaystackClientNIO/Client+HTTPClient.swift create mode 100644 Sources/HaystackClientNIO/Exports.swift rename Sources/{HaystackClient => HaystackClientNIO}/HTTPClientFetcher.swift (90%) rename Tests/{HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift => HaystackClientDarwinIntegrationTests/HaystackClientDarwinIntegrationTests.swift} (97%) create mode 100644 Tests/HaystackClientNIOIntegrationTests/HaystackClientNIOIntegrationTests.swift diff --git a/Package.swift b/Package.swift index caa47b3..61cb2b4 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,18 @@ let package = Package( targets: ["Haystack"] ), .library( - name: "HaystackClient", - targets: ["HaystackClient"] + name: "HaystackClientDarwin", + targets: [ + "HaystackClient", + "HaystackClientDarwin" + ] + ), + .library( + name: "HaystackClientNIO", + targets: [ + "HaystackClient", + "HaystackClientNIO" + ] ), ], dependencies: [ @@ -34,6 +44,20 @@ let package = Package( dependencies: [ "Haystack", .product(name: "Crypto", package: "swift-crypto"), + ] + ), + .target( + name: "HaystackClientDarwin", + dependencies: [ + "Haystack", + "HaystackClient", + ] + ), + .target( + name: "HaystackClientNIO", + dependencies: [ + "Haystack", + "HaystackClient", .product(name: "AsyncHTTPClient", package: "async-http-client"), ] ), @@ -46,8 +70,12 @@ let package = Package( dependencies: ["HaystackClient"] ), .testTarget( - name: "HaystackClientIntegrationTests", - dependencies: ["HaystackClient"] + name: "HaystackClientNIOIntegrationTests", + dependencies: ["HaystackClientNIO"] + ), + .testTarget( + name: "HaystackClientDarwinIntegrationTests", + dependencies: ["HaystackClientDarwin"] ), ] ) diff --git a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift index 42f02f9..9c5053f 100644 --- a/Sources/HaystackClient/Authentication/ScramAuthenticator.swift +++ b/Sources/HaystackClient/Authentication/ScramAuthenticator.swift @@ -39,7 +39,7 @@ struct ScramAuthenticator: Authenticator { // Client Initiation let clientFirstMessage = scram.clientFirstMessage() - let firstRequest = Request( + let firstRequest = HaystackRequest( url: aboutUrl, headerAuthorization: AuthMessage( scheme: "scram", @@ -81,7 +81,7 @@ struct ScramAuthenticator: Authenticator { // Client Continuation let clientFinalMessage = try scram.clientFinalMessage(serverFirstMessage: serverFirstMessage) - let finalRequest = Request( + let finalRequest = HaystackRequest( url: aboutUrl, headerAuthorization: AuthMessage( scheme: "scram", diff --git a/Sources/HaystackClient/Client.swift b/Sources/HaystackClient/Client.swift index 3807fea..c1a03bb 100644 --- a/Sources/HaystackClient/Client.swift +++ b/Sources/HaystackClient/Client.swift @@ -2,8 +2,6 @@ import Crypto import Haystack import Foundation -import AsyncHTTPClient - /// A Haystack API client. Once created, call the `open` method to connect. /// /// ```swift @@ -39,7 +37,8 @@ public class Client { baseUrl: String, username: String, password: String, - format: DataFormat = .zinc + format: DataFormat = .zinc, + fetcher: Fetcher ) throws { var urlWithSlash = baseUrl if !urlWithSlash.hasSuffix("/") { @@ -49,7 +48,7 @@ public class Client { self.username = username self.password = password self.format = format - self.fetcher = HTTPClient(eventLoopGroupProvider: .createNew).haystackFetcher() + self.fetcher = fetcher } /// Authenticate the client and store the authentication token @@ -57,7 +56,7 @@ public class Client { let url = baseUrl + "about" // Hello - let helloRequest = Request( + let helloRequest = HaystackRequest( url: url, headerAuthorization: AuthMessage( scheme: "hello", @@ -531,7 +530,7 @@ public class Client { } let headerAuthorization = AuthMessage(scheme: "Bearer", attributes: ["authToken": authToken]).description - let request = Request( + let request = HaystackRequest( method: method, url: url, headerAuthorization: headerAuthorization, @@ -580,11 +579,11 @@ 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" +public enum HTTPHeader { + public static let accept = "Accept" + public static let authenticationInfo = "Authentication-Info" + public static let authorization = "Authorization" + public static let contentType = "Content-Type" + public static let userAgent = "User-Agent" + public static let wwwAuthenticate = "Www-Authenticate" } diff --git a/Sources/HaystackClient/Fetcher.swift b/Sources/HaystackClient/Fetcher.swift index 2552416..a4d5a11 100644 --- a/Sources/HaystackClient/Fetcher.swift +++ b/Sources/HaystackClient/Fetcher.swift @@ -1,23 +1,37 @@ import Foundation -protocol Fetcher { - func fetch(_ request: Request) async throws -> Response +public protocol Fetcher { + func fetch(_ request: HaystackRequest) async throws -> HaystackResponse } -struct Response { - let statusCode: Int - let headerAuthenticationInfo: String? - let headerContentType: String? - let headerWwwAuthenticate: String? - let data: Data +public struct HaystackResponse { + public let statusCode: Int + public let headerAuthenticationInfo: String? + public let headerContentType: String? + public let headerWwwAuthenticate: String? + public let data: Data + + public init( + statusCode: Int, + headerAuthenticationInfo: String?, + headerContentType: String?, + headerWwwAuthenticate: String?, + data: Data + ) { + self.statusCode = statusCode + self.headerAuthenticationInfo = headerAuthenticationInfo + self.headerContentType = headerContentType + self.headerWwwAuthenticate = headerWwwAuthenticate + self.data = data + } } -struct Request { - let method: HaystackHttpMethod - let url: String - let headerAuthorization: String - let headerUserAgent: String = "swift-haystack-client" - let headerAccept: String +public struct HaystackRequest { + public let method: HaystackHttpMethod + public let url: String + public let headerAuthorization: String + public let headerUserAgent: String = "swift-haystack-client" + public let headerAccept: String init( method: HaystackHttpMethod = .GET, @@ -32,7 +46,7 @@ struct Request { } } -enum HaystackHttpMethod { +public enum HaystackHttpMethod { case GET case POST(contentType: String, data: Data) } diff --git a/Sources/HaystackClientDarwin/Client+URLSession.swift b/Sources/HaystackClientDarwin/Client+URLSession.swift new file mode 100644 index 0000000..1a1bd99 --- /dev/null +++ b/Sources/HaystackClientDarwin/Client+URLSession.swift @@ -0,0 +1,20 @@ +import Foundation +import HaystackClient + +public extension Client { + convenience init( + baseUrl: String, + username: String, + password: String, + format: DataFormat = .zinc + ) throws { + let fetcher = URLSessionFetcher() + try self.init( + baseUrl: baseUrl, + username: username, + password: password, + format: format, + fetcher: fetcher + ) + } +} diff --git a/Sources/HaystackClientDarwin/Exports.swift b/Sources/HaystackClientDarwin/Exports.swift new file mode 100644 index 0000000..4b7fe47 --- /dev/null +++ b/Sources/HaystackClientDarwin/Exports.swift @@ -0,0 +1 @@ +@_exported import HaystackClient diff --git a/Sources/HaystackClient/URLSessionFetcher.swift b/Sources/HaystackClientDarwin/URLSessionFetcher.swift similarity index 87% rename from Sources/HaystackClient/URLSessionFetcher.swift rename to Sources/HaystackClientDarwin/URLSessionFetcher.swift index 4c5cfaa..f2e8ba6 100644 --- a/Sources/HaystackClient/URLSessionFetcher.swift +++ b/Sources/HaystackClientDarwin/URLSessionFetcher.swift @@ -1,4 +1,5 @@ import Foundation +import HaystackClient // URLSession IS NOT AVAILABLE ON LINUX! struct URLSessionFetcher: Fetcher { @@ -14,9 +15,9 @@ struct URLSessionFetcher: Fetcher { self.session = URLSession(configuration: sessionConfig) } - func fetch(_ request: Request) async throws -> Response { + func fetch(_ request: HaystackRequest) async throws -> HaystackResponse { guard let url = URL(string: request.url) else { - throw HaystackClientError.invalidUrl(request.url) + throw URLSessionFetcherError.invalidUrl(request.url) } var urlRequest = URLRequest(url: url) @@ -36,7 +37,7 @@ struct URLSessionFetcher: Fetcher { urlRequest.addValue(request.headerUserAgent, forHTTPHeaderField: HTTPHeader.userAgent) let (data, responseGen) = try await session.data(for: urlRequest) let response = (responseGen as! HTTPURLResponse) - return Response( + return HaystackResponse( statusCode: response.statusCode, headerAuthenticationInfo: response.value(forHTTPHeaderField: HTTPHeader.authenticationInfo), headerContentType: response.value(forHTTPHeaderField: HTTPHeader.contentType), @@ -45,3 +46,7 @@ struct URLSessionFetcher: Fetcher { ) } } + +public enum URLSessionFetcherError: Error { + case invalidUrl(String) +} diff --git a/Sources/HaystackClientNIO/Client+HTTPClient.swift b/Sources/HaystackClientNIO/Client+HTTPClient.swift new file mode 100644 index 0000000..a4a06f6 --- /dev/null +++ b/Sources/HaystackClientNIO/Client+HTTPClient.swift @@ -0,0 +1,24 @@ +import AsyncHTTPClient +import HaystackClient +import Foundation +import NIO + +public extension Client { + convenience init( + baseUrl: String, + username: String, + password: String, + format: DataFormat = .zinc, + eventLoopGroup: EventLoopGroup + ) throws { + let fetcher = HTTPClient(eventLoopGroupProvider: .shared(eventLoopGroup)).haystackFetcher() + try self.init( + baseUrl: baseUrl, + username: username, + password: password, + format: format, + fetcher: fetcher + ) + } +} + diff --git a/Sources/HaystackClientNIO/Exports.swift b/Sources/HaystackClientNIO/Exports.swift new file mode 100644 index 0000000..4b7fe47 --- /dev/null +++ b/Sources/HaystackClientNIO/Exports.swift @@ -0,0 +1 @@ +@_exported import HaystackClient diff --git a/Sources/HaystackClient/HTTPClientFetcher.swift b/Sources/HaystackClientNIO/HTTPClientFetcher.swift similarity index 90% rename from Sources/HaystackClient/HTTPClientFetcher.swift rename to Sources/HaystackClientNIO/HTTPClientFetcher.swift index 87716b4..98a0dca 100644 --- a/Sources/HaystackClient/HTTPClientFetcher.swift +++ b/Sources/HaystackClientNIO/HTTPClientFetcher.swift @@ -1,22 +1,23 @@ -import Foundation import AsyncHTTPClient +import HaystackClient +import Foundation import NIO -public extension HTTPClient { +extension HTTPClient { func haystackFetcher() -> HTTPClientFetcher { return HTTPClientFetcher(self) } } // HTTPClient is available on all platforms but includes many more dependencies -public struct HTTPClientFetcher: Fetcher { +struct HTTPClientFetcher: Fetcher { let client: HTTPClient init(_ client: HTTPClient) { self.client = client } - func fetch(_ request: Request) async throws -> Response { + func fetch(_ request: HaystackRequest) async throws -> HaystackResponse { var httpClientRequest = HTTPClientRequest(url: request.url) switch request.method { @@ -39,7 +40,7 @@ public struct HTTPClientFetcher: Fetcher { partialResult.append(byteBuffer.getData(at: 0, length: byteBuffer.readableBytes)!) }) - return Response( + return HaystackResponse( statusCode: Int(response.status.code), headerAuthenticationInfo: response.headers.first(name: HTTPHeader.authenticationInfo), headerContentType: response.headers.first(name: HTTPHeader.contentType), diff --git a/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift b/Tests/HaystackClientDarwinIntegrationTests/HaystackClientDarwinIntegrationTests.swift similarity index 97% rename from Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift rename to Tests/HaystackClientDarwinIntegrationTests/HaystackClientDarwinIntegrationTests.swift index 0e40dd0..c0cfce3 100644 --- a/Tests/HaystackClientIntegrationTests/HaystackClientIntegrationTests.swift +++ b/Tests/HaystackClientDarwinIntegrationTests/HaystackClientDarwinIntegrationTests.swift @@ -1,10 +1,10 @@ import XCTest import Haystack -import HaystackClient +import HaystackClientDarwin /// 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 { +final class HaystackClientDarwinIntegrationTests: XCTestCase { var client: Client = try! Client( baseUrl: "http://localhost:8080/api/", username: ProcessInfo.processInfo.environment["HAYSTACK_USER"] ?? "su", diff --git a/Tests/HaystackClientNIOIntegrationTests/HaystackClientNIOIntegrationTests.swift b/Tests/HaystackClientNIOIntegrationTests/HaystackClientNIOIntegrationTests.swift new file mode 100644 index 0000000..50bf88e --- /dev/null +++ b/Tests/HaystackClientNIOIntegrationTests/HaystackClientNIOIntegrationTests.swift @@ -0,0 +1,94 @@ +import XCTest +import Haystack +import HaystackClientNIO +import NIO + +/// 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 HaystackClientNIOIntegrationTests: XCTestCase { + var client: Client = try! Client( + baseUrl: "http://localhost:8080/api/", + username: ProcessInfo.processInfo.environment["HAYSTACK_USER"] ?? "su", + password: ProcessInfo.processInfo.environment["HAYSTACK_PASSWORD"] ?? "su", + eventLoopGroup: MultiThreadedEventLoopGroup(numberOfThreads: 1) + ) + + 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 a1027c50d1e315f435037293d06136f460d4ae89 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 26 Dec 2022 13:00:23 -0800 Subject: [PATCH 06/11] doc: Adds documentation to new client assets --- Sources/HaystackClient/Fetcher.swift | 51 +++++++++++-------- .../Client+URLSession.swift | 9 ++++ .../URLSessionFetcher.swift | 2 +- .../HaystackClientNIO/Client+HTTPClient.swift | 9 ++++ .../HaystackClientNIO/HTTPClientFetcher.swift | 3 +- 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/Sources/HaystackClient/Fetcher.swift b/Sources/HaystackClient/Fetcher.swift index a4d5a11..c49e9ec 100644 --- a/Sources/HaystackClient/Fetcher.swift +++ b/Sources/HaystackClient/Fetcher.swift @@ -1,9 +1,38 @@ import Foundation +/// A protocol that abstracts HTTP data retrieval for Haystack. public protocol Fetcher { + /// Given a request, execute it across HTTP and return the result. + /// + /// - Parameter request: The request to execute + /// - Returns: The result of executing the request func fetch(_ request: HaystackRequest) async throws -> HaystackResponse } +/// An HTTP request for haystack data. It just collects relevant HTTP +/// artifacts, like binary data and select header values. +public struct HaystackRequest { + public let method: HaystackHttpMethod + public let url: String + public let headerAuthorization: String + public let headerUserAgent: String = "swift-haystack-client" + public let headerAccept: String + + init( + method: HaystackHttpMethod = .GET, + url: String, + headerAuthorization: String, + headerAccept: String = DataFormat.zinc.acceptHeaderValue + ) { + self.method = method + self.url = url + self.headerAuthorization = headerAuthorization + self.headerAccept = headerAccept + } +} + +/// A response to a `HaystackRequest`. It just collects relevant HTTP +/// artifacts, like binary data and select header values. public struct HaystackResponse { public let statusCode: Int public let headerAuthenticationInfo: String? @@ -26,26 +55,8 @@ public struct HaystackResponse { } } -public struct HaystackRequest { - public let method: HaystackHttpMethod - public let url: String - public let headerAuthorization: String - public let headerUserAgent: String = "swift-haystack-client" - public let headerAccept: String - - init( - method: HaystackHttpMethod = .GET, - url: String, - headerAuthorization: String, - headerAccept: String = DataFormat.zinc.acceptHeaderValue - ) { - self.method = method - self.url = url - self.headerAuthorization = headerAuthorization - self.headerAccept = headerAccept - } -} - +/// The HTTP method to use in the `HaystackRequest`. For more information, see +/// [Requests](https://project-haystack.org/doc/docHaystack/HttpApi#requests) public enum HaystackHttpMethod { case GET case POST(contentType: String, data: Data) diff --git a/Sources/HaystackClientDarwin/Client+URLSession.swift b/Sources/HaystackClientDarwin/Client+URLSession.swift index 1a1bd99..3e0edc3 100644 --- a/Sources/HaystackClientDarwin/Client+URLSession.swift +++ b/Sources/HaystackClientDarwin/Client+URLSession.swift @@ -2,6 +2,15 @@ import Foundation import HaystackClient public extension Client { + + /// Create a Haystack API Client that uses a `URLSession` from `Foundation` that + /// is only available on Darwin platforms. + /// + /// - 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. convenience init( baseUrl: String, username: String, diff --git a/Sources/HaystackClientDarwin/URLSessionFetcher.swift b/Sources/HaystackClientDarwin/URLSessionFetcher.swift index f2e8ba6..72b67b3 100644 --- a/Sources/HaystackClientDarwin/URLSessionFetcher.swift +++ b/Sources/HaystackClientDarwin/URLSessionFetcher.swift @@ -1,7 +1,7 @@ import Foundation import HaystackClient -// URLSession IS NOT AVAILABLE ON LINUX! +/// A Haystack API Client fetcher based on `URLSession`. This is only available on Darwin platforms. struct URLSessionFetcher: Fetcher { let session: URLSession diff --git a/Sources/HaystackClientNIO/Client+HTTPClient.swift b/Sources/HaystackClientNIO/Client+HTTPClient.swift index a4a06f6..1bac842 100644 --- a/Sources/HaystackClientNIO/Client+HTTPClient.swift +++ b/Sources/HaystackClientNIO/Client+HTTPClient.swift @@ -4,6 +4,15 @@ import Foundation import NIO public extension Client { + + /// Create a Haystack API Client that uses a NIO-based HTTP client. + /// + /// - 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. + /// - eventLoopGroup: The event loop group on which to create request callbacks convenience init( baseUrl: String, username: String, diff --git a/Sources/HaystackClientNIO/HTTPClientFetcher.swift b/Sources/HaystackClientNIO/HTTPClientFetcher.swift index 98a0dca..e10f95a 100644 --- a/Sources/HaystackClientNIO/HTTPClientFetcher.swift +++ b/Sources/HaystackClientNIO/HTTPClientFetcher.swift @@ -9,7 +9,8 @@ extension HTTPClient { } } -// HTTPClient is available on all platforms but includes many more dependencies +/// A Haystack API Client fetcher based on a NIO `HTTPClient`. This is only available on all platforms, +/// but includes many more dependencies than Foundation's `URLSession` struct HTTPClientFetcher: Fetcher { let client: HTTPClient From 64469c760d63053e1c043c48688074a3dc3b5036 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 26 Dec 2022 13:00:30 -0800 Subject: [PATCH 07/11] doc: Adds readme --- README.md | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc1659a..9164ecf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,120 @@ # Swift Haystack -An implementation of Project Haystack in Swift. +An implementation of [Project Haystack](https://project-haystack.org/) in Swift. + +## Getting Started + +To use this package, add it to your `Package.swift` dependencies: + +```swift +dependencies: [ + .package(url: "https://github.com/NeedleInAJayStack/swift-haystack.git", from: "0.0.0"), +], +targets: [ + .target( + name: "MyTarget", + dependencies: [ + .product(name: "Haystack", package: "swift-haystack"), + ] + ), +] +``` + +You can then import and use the different libraries: + +```swift +import Haystack + +func testGrid() throws -> Grid { + return try ZincReader( + """ + ver:"3.0" foo:"bar" + dis dis:"Equip Name", equip, siteRef, installed + "RTU-1", M, @153c-699a HQ, 2005-06-01 + "RTU-2", M, @153c-699b Library, 1997-07-12 + """ + ).readGrid() +} +``` + +See below for available libraries and descriptions. + +## Available Packages + +### Haystack + +This contains the +[Haystack type-system primitives](https://project-haystack.org/doc/docHaystack/Kinds) +and utilities to interact with them. + +### HaystackClientDarwin + +A Darwin-only client driver for the +[Haystack HTTP API](https://project-haystack.org/doc/docHaystack/HttpApi) that +requires minimal dependencies. Use this if you are only deploying to MacOS, iOS, etc and want +to reduce dependencies. + +Here's an example of how to use it: + +```swift +import HaystackClientDarwin + +func client() throws -> Client { + return try Client( + baseUrl: "http://mydomain.com/api/", + username: "username", + password: "password" + ) +} +``` + +### HaystackClientNIO + +A cross-platform client driver for the +[Haystack HTTP API](https://project-haystack.org/doc/docHaystack/HttpApi) that +has larger dependency requirements. Use this if you are only deploying to Linux or if you +are deploying to Darwin platforms and are willing to accept more dependencies. + +Here's an example of how to use it: + +```swift +import HaystackClientNIO + +func client() throws -> Client { + return try Client( + baseUrl: "http://mydomain.com/api/", + username: "username", + password: "password", + eventLoopGroup: MultiThreadedEventLoopGroup(numberOfThreads: 1) + ) +} +``` + +### HaystackClient + +This defines the main functionality of Haystack API clients. It should not be imported directly; +its assets are imported automatically by `HaystackClientDarwin` or `HaystackClientNIO`. + +Once you create a client, you can use it to make requests: + +```swift +func yesterdaysValues() async throws -> Grid { + let client = ... + + // Open and authenticate. This must be called before requests can be made + try await client.open() + + // Request the historical values for @28e7fb7d-e20316e0 + let grid = try await client.hisRead(id: Ref("28e7fb7d-e20316e0"), range: .yesterday) + + // Close the client session and log out + try await client.close() + + return grid +} +``` + +## License + +This package is licensed under the Academic Free License 3.0 for maximum compatibility with +Project Haystack itself. From 5c25515b2ff2e1e3c014e8cff3f31993a53cff5b Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 26 Dec 2022 13:11:26 -0800 Subject: [PATCH 08/11] fix: imports NIOFoundationCompat --- Sources/HaystackClientNIO/HTTPClientFetcher.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/HaystackClientNIO/HTTPClientFetcher.swift b/Sources/HaystackClientNIO/HTTPClientFetcher.swift index e10f95a..89f4806 100644 --- a/Sources/HaystackClientNIO/HTTPClientFetcher.swift +++ b/Sources/HaystackClientNIO/HTTPClientFetcher.swift @@ -2,6 +2,7 @@ import AsyncHTTPClient import HaystackClient import Foundation import NIO +import NIOFoundationCompat extension HTTPClient { func haystackFetcher() -> HTTPClientFetcher { From 557b23eca4e82133ea51a0eec80c926bb40d8628 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Tue, 27 Dec 2022 09:12:33 -0800 Subject: [PATCH 09/11] chore: CI excludes linux tests and IntegrationTests --- .github/workflows/test.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b67469b..1cd3b18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,13 +4,17 @@ on: push: { branches: [ main ] } jobs: - test: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} + darwin-test: + runs-on: macos-latest steps: - uses: fwal/setup-swift@v1 - uses: actions/checkout@v2 - - name: Run tests - run: swift test + - name: Darwin build & test + run: swift test --skip IntegrationTests + linux-build: + runs-on: ubuntu-latest + steps: + - uses: fwal/setup-swift@v1 + - uses: actions/checkout@v2 + - name: Linux build + run: swift build --target HaystackClientNIO From 48693f002021035b2e01ab3d7980f5bbff0825fa Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Tue, 27 Dec 2022 09:32:47 -0800 Subject: [PATCH 10/11] chore: Bumps darwin versions for URLSession.data --- Package.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index 61cb2b4..c707414 100644 --- a/Package.swift +++ b/Package.swift @@ -5,10 +5,10 @@ import PackageDescription let package = Package( name: "Haystack", platforms: [ - .macOS(.v10_15), - .iOS(.v13), - .watchOS(.v6), - .tvOS(.v13), + .macOS(.v12), + .iOS(.v15), + .tvOS(.v15), + .watchOS(.v8) ], products: [ .library( @@ -61,6 +61,8 @@ let package = Package( .product(name: "AsyncHTTPClient", package: "async-http-client"), ] ), + + // Tests .testTarget( name: "HaystackTests", dependencies: ["Haystack"] From e30666a3c4fc7f4103f2bf7d8e5c0e2c68305aaa Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Tue, 27 Dec 2022 15:32:23 -0800 Subject: [PATCH 11/11] chore: Fix macos CI Changes to `maxim-lobanov/setup-xcode` See issue: https://github.com/swift-actions/setup-swift/issues/406 --- .github/workflows/test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1cd3b18..6643e75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,14 +7,17 @@ jobs: darwin-test: runs-on: macos-latest steps: - - uses: fwal/setup-swift@v1 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest - uses: actions/checkout@v2 - name: Darwin build & test run: swift test --skip IntegrationTests linux-build: runs-on: ubuntu-latest + container: + image: swift:latest steps: - - uses: fwal/setup-swift@v1 - uses: actions/checkout@v2 - name: Linux build run: swift build --target HaystackClientNIO