diff options
Diffstat (limited to 'Sources/SwiftGopherClient')
| -rw-r--r-- | Sources/SwiftGopherClient/gopherClient.swift | 161 | ||||
| -rw-r--r-- | Sources/SwiftGopherClient/gopherRequestResponseHandler.swift | 113 | 
2 files changed, 274 insertions, 0 deletions
| diff --git a/Sources/SwiftGopherClient/gopherClient.swift b/Sources/SwiftGopherClient/gopherClient.swift new file mode 100644 index 0000000..3fd61ec --- /dev/null +++ b/Sources/SwiftGopherClient/gopherClient.swift @@ -0,0 +1,161 @@ +// +//  gopherClient.swift +// +// +//  Created by Navan Chauhan on 12/12/23. +// + +import Foundation +import GopherHelpers +import NIO +import NIOTransportServices + +/// `GopherClient` is a class for handling network connections and requests to Gopher servers. +/// +/// This client utilizes Swift NIO for efficient, non-blocking network operations. It automatically +/// chooses the appropriate `EventLoopGroup` based on the running platform: +/// - On iOS/macOS 10.14+, it uses `NIOTSEventLoopGroup` for optimal performance. +/// - On Linux or older Apple platforms, it falls back to `MultiThreadedEventLoopGroup`. +/// +/// The client supports both synchronous (completion handler-based) and asynchronous (Swift concurrency) APIs +/// for sending requests to Gopher servers. +public class GopherClient { +    /// The event loop group used for managing network operations. +    private let group: EventLoopGroup + +    /// Initializes a new instance of `GopherClient`. +    /// +    /// This initializer automatically selects the appropriate `EventLoopGroup` based on the running platform. +    public init() { +        #if os(Linux) +            self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) +        #else +            if #available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, visionOS 1.0, *) { +                self.group = NIOTSEventLoopGroup() +            } else { +                self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) +            } +        #endif +    } + +    /// Cleans up resources when the instance is deinitialized. +    deinit { +        self.shutdownEventLoopGroup() +    } + +    /// Sends a request to a Gopher server using a completion handler. +    /// +    /// This method asynchronously establishes a connection, sends the request, and calls the completion +    /// handler with the result. +    /// +    /// - Parameters: +    ///   - host: The host address of the Gopher server. +    ///   - port: The port of the Gopher server. Defaults to 70. +    ///   - message: The message to be sent to the server. +    ///   - completion: A closure that handles the result of the request. It takes a `Result` type +    ///     which either contains an array of `gopherItem` on success or an `Error` on failure. +    public func sendRequest( +        to host: String, +        port: Int = 70, +        message: String, +        completion: @escaping (Result<[gopherItem], Error>) -> Void +    ) { +        let bootstrap = self.createBootstrap(message: message, completion: completion) +        bootstrap.connect(host: host, port: port).whenComplete { result in +            switch result { +            case .success(let channel): +                channel.closeFuture.whenComplete { _ in +                    print("Connection closed") +                } +            case .failure(let error): +                completion(.failure(error)) +            } +        } +    } + +    /// Sends a request to a Gopher server using Swift concurrency. +    /// +    /// This method asynchronously establishes a connection and sends the request, +    /// returning the result as an array of `gopherItem`. +    /// +    /// - Parameters: +    ///   - host: The host address of the Gopher server. +    ///   - port: The port of the Gopher server. Defaults to 70. +    ///   - message: The message to be sent to the server. +    /// +    /// - Returns: An array of `gopherItem` representing the server's response. +    /// +    /// - Throws: An error if the connection fails or the server returns an invalid response. +    @available(iOS 13.0, *) +    @available(macOS 10.15, *) +    public func sendRequest(to host: String, port: Int = 70, message: String) async throws +        -> [gopherItem] +    { +        return try await withCheckedThrowingContinuation { continuation in +            let bootstrap = self.createBootstrap(message: message) { result in +                continuation.resume(with: result) +            } + +            bootstrap.connect(host: host, port: port).whenComplete { result in +                switch result { +                case .success(let channel): +                    channel.closeFuture.whenComplete { _ in +                        print("Connection Closed") +                    } +                case .failure(let error): +                    continuation.resume(throwing: error) +                } +            } +        } +    } + +    /// Creates a bootstrap for connecting to a Gopher server. +    /// +    /// This method sets up the appropriate bootstrap based on the platform and configures +    /// the channel with a `GopherRequestResponseHandler`. +    /// +    /// - Parameters: +    ///   - message: The message to be sent to the server. +    ///   - completion: A closure that handles the result of the request. +    /// +    /// - Returns: A `NIOClientTCPBootstrapProtocol` configured for Gopher communication. +    private func createBootstrap( +        message: String, +        completion: @escaping (Result<[gopherItem], Error>) -> Void +    ) -> NIOClientTCPBootstrapProtocol { +        let handler = GopherRequestResponseHandler(message: message, completion: completion) + +        #if os(Linux) +            return ClientBootstrap(group: group) +                .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) +                .channelInitializer { channel in +                    channel.pipeline.addHandler(handler) +                } +        #else +            if #available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, visionOS 1.0, *) { +                return NIOTSConnectionBootstrap(group: group) +                    .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) +                    .channelInitializer { channel in +                        channel.pipeline.addHandler(handler) +                    } +            } else { +                return ClientBootstrap(group: group) +                    .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) +                    .channelInitializer { channel in +                        channel.pipeline.addHandler(handler) +                    } +            } +        #endif +    } + +    /// Shuts down the event loop group, releasing any resources. +    /// +    /// This method is called during deinitialization to ensure clean shutdown of network resources. +    private func shutdownEventLoopGroup() { +        do { +            try group.syncShutdownGracefully() +        } catch { +            print("Error shutting down event loop group: \(error)") +        } +    } +} diff --git a/Sources/SwiftGopherClient/gopherRequestResponseHandler.swift b/Sources/SwiftGopherClient/gopherRequestResponseHandler.swift new file mode 100644 index 0000000..10da67e --- /dev/null +++ b/Sources/SwiftGopherClient/gopherRequestResponseHandler.swift @@ -0,0 +1,113 @@ +// +//  gopherRequestResponseHandler.swift +// +// +//  Created by Navan Chauhan on 12/12/23. +// + +import Foundation +import GopherHelpers +import NIO + +final class GopherRequestResponseHandler: ChannelInboundHandler { +    typealias InboundIn = ByteBuffer +    typealias OutboundOut = ByteBuffer + +    private var accumulatedData: ByteBuffer +    private let message: String +    private let completion: (Result<[gopherItem], Error>) -> Void + +    init(message: String, completion: @escaping (Result<[gopherItem], Error>) -> Void) { +        self.message = message +        self.completion = completion +        self.accumulatedData = ByteBuffer() +    } + +    func channelActive(context: ChannelHandlerContext) { +        var buffer = context.channel.allocator.buffer(capacity: message.utf8.count) +        buffer.writeString(message) +        context.writeAndFlush(self.wrapOutboundOut(buffer), promise: nil) +    } + +    func channelRead(context: ChannelHandlerContext, data: NIOAny) { +        var buffer = unwrapInboundIn(data) +        accumulatedData.writeBuffer(&buffer) +    } + +    func channelInactive(context: ChannelHandlerContext) { +        if let dataCopy = accumulatedData.getSlice(at: 0, length: accumulatedData.readableBytes) { +            parseGopherServerResponse( +                response: accumulatedData.readString(length: accumulatedData.readableBytes) ?? "", +                originalBytes: dataCopy) +        } +    } + +    func errorCaught(context: ChannelHandlerContext, error: Error) { +        print("Error: ", error) +        context.close(promise: nil) +    } + +    func createGopherItem(rawLine: String, itemType: gopherItemType = .info, rawData: ByteBuffer) +        -> gopherItem +    { +        var item = gopherItem(rawLine: rawLine) +        item.parsedItemType = itemType +        item.rawData = rawData + +        if rawLine.isEmpty { +            item.valid = false +        } else { +            let components = rawLine.components(separatedBy: "\t") + +            // Handle cases where rawLine does not have any itemType in the first character +            item.message = String(components[0].dropFirst()) + +            if components.indices.contains(1) { +                item.selector = String(components[1]) +            } + +            if components.indices.contains(2) { +                item.host = String(components[2]) +            } + +            if components.indices.contains(3) { +                item.port = Int(String(components[3])) ?? 70 +            } +        } + +        return item +    } + +    func parseGopherServerResponse(response: String, originalBytes: ByteBuffer) { +        var gopherServerResponse: [gopherItem] = [] + +        print("parsing") +        let carriageReturnCount = response.filter({ $0 == "\r" }).count +        let newlineCarriageReturnCount = response.filter({ $0 == "\r\n" }).count +        print( +            "Carriage Returns: \(carriageReturnCount), Newline + Carriage Returns: \(newlineCarriageReturnCount)" +        ) + +        if newlineCarriageReturnCount == 0 { +            for line in response.split(separator: "\n") { +                let lineItemType = getGopherFileType(item: "\(line.first ?? " ")") +                let item = createGopherItem( +                    rawLine: String(line), itemType: lineItemType, rawData: originalBytes) +                gopherServerResponse.append(item) + +            } +        } else { +            for line in response.split(separator: "\r\n") { +                let lineItemType = getGopherFileType(item: "\(line.first ?? " ")") +                let item = createGopherItem( +                    rawLine: String(line), itemType: lineItemType, rawData: originalBytes) +                gopherServerResponse.append(item) + +            } +        } + +        print("done parsing") + +        completion(.success(gopherServerResponse)) +    } +} | 
