From 3916948004dadc045739c786236ccda82590a130 Mon Sep 17 00:00:00 2001 From: Navan Chauhan Date: Tue, 12 Dec 2023 22:51:56 -0700 Subject: add initial swiftGopherClient --- Package.swift | 4 + Sources/swiftGopherClient/gopherClient.swift | 48 ++++++++ .../gopherRequestResponseHandler.swift | 111 +++++++++++++++++++ Sources/swiftGopherClient/gopherTypes.swift | 122 +++++++++++++++++++++ .../swiftGopherClientTests.swift | 37 +++++++ 5 files changed, 322 insertions(+) create mode 100644 Sources/swiftGopherClient/gopherClient.swift create mode 100644 Sources/swiftGopherClient/gopherRequestResponseHandler.swift create mode 100644 Sources/swiftGopherClient/gopherTypes.swift create mode 100644 Tests/swiftGopherClientTests/swiftGopherClientTests.swift diff --git a/Package.swift b/Package.swift index 4fdc0ea..9cbb057 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,10 @@ let package = Package( dependencies: [ .product(name: "NIO", package: "swift-nio") ] + ), + .testTarget( + name: "swiftGopherClientTests", + dependencies: ["swiftGopherClient"] ) ] ) diff --git a/Sources/swiftGopherClient/gopherClient.swift b/Sources/swiftGopherClient/gopherClient.swift new file mode 100644 index 0000000..6b76d94 --- /dev/null +++ b/Sources/swiftGopherClient/gopherClient.swift @@ -0,0 +1,48 @@ +// +// gopherClient.swift +// +// +// Created by Navan Chauhan on 12/12/23. +// + +import Foundation +import NIO + +public class GopherClient { + private let group: EventLoopGroup + + public init() { + self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + } + + deinit { + try? group.syncShutdownGracefully() + } + + public func sendRequest(to host: String, port: Int = 70, message: String, completion: @escaping (Result) -> Void) { + let bootstrap = ClientBootstrap(group: group) + .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .channelInitializer { channel in + channel.pipeline.addHandler(GopherRequestResponseHandler(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)) + } + } + } + + 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..0cd040a --- /dev/null +++ b/Sources/swiftGopherClient/gopherRequestResponseHandler.swift @@ -0,0 +1,111 @@ +// +// gopherRequestResponseHandler.swift +// +// +// Created by Navan Chauhan on 12/12/23. +// + +import Foundation +import NIO + +final class GopherRequestResponseHandler: ChannelInboundHandler { + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + private var accumulatedData: ByteBuffer + private let message: String + private let completion: (Result) -> Void + + init(message: String, completion: @escaping (Result) -> 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) + if let receivedString = buffer.getString(at: 0, length: buffer.readableBytes) { + print("Received from server: \(receivedString)") + } + //completion(.success(receivedString)) + //context.close(promise: nil) + } + + func channelInactive(context: ChannelHandlerContext) { + // Parse GopherServerResponse + parseGopherServerResponse(response: accumulatedData.readString(length: accumulatedData.readableBytes) ?? "") + //completion(.success(accumulatedData.readString(length: accumulatedData.readableBytes) ?? "")) + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + print("Error: ", error) + context.close(promise: nil) + } + + func createGopherItem(rawLine: String, itemType: gopherItemType = .info) -> gopherItem { + var item = gopherItem(rawLine: rawLine) + item.parsedItemType = itemType + + 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) { + 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)") + + for line in response.split(separator: "\r\n") { + let lineItemType = getGopherFileType(item: "\(line.first ?? " ")") + let item = createGopherItem(rawLine: String(line), itemType: lineItemType) + print(item.message) + gopherServerResponse.append(item) + + } + + print("done parsing") + + completion(.success(response)) + } +} + +struct gopherItem { + var rawLine: String + var message: String = "" + var parsedItemType: gopherItemType = .info + var host: String = "error.host" + var port: Int = 1 + var selector: String = "" + var valid: Bool = true +} + diff --git a/Sources/swiftGopherClient/gopherTypes.swift b/Sources/swiftGopherClient/gopherTypes.swift new file mode 100644 index 0000000..2f1c99f --- /dev/null +++ b/Sources/swiftGopherClient/gopherTypes.swift @@ -0,0 +1,122 @@ +// +// gopherTypes.swift +// +// +// Created by Navan Chauhan on 12/12/23. +// + +import Foundation + +/* + + From Wikipedia + + Canonical types + 0 Text file + 1 Gopher submenu + 2 CCSO Nameserver + 3 Error code returned by a Gopher server to indicate failure + 4 BinHex-encoded file (primarily for Macintosh computers) + 5 DOS file + 6 uuencoded file + 7 Gopher full-text search + 8 Telnet + 9 Binary file + + Mirror or alternate server (for load balancing or in case of primary server downtime) + g GIF file + I Image file + T Telnet 3270 + gopher+ types + : Bitmap image + ; Movie file + < Sound file + Non-canonical types + d Doc. Seen used alongside PDF's and .DOC's + h HTML file + i Informational message, widely used.[25] + p image file "(especially the png format)" + r document rtf file "rich text Format") + s Sound file (especially the WAV format) + P document pdf file "Portable Document Format") + X document xml file "eXtensive Markup Language" ) + */ + +enum gopherItemType { + case text + case directory + case nameserver + case error + case binhex + case bindos + case uuencoded + case search + case telnet + case binary + case mirror + case gif + case image + case tn3270Session + case bitmap + case movie + case sound + case doc + case html + case info +} + +func getGopherFileType(item: String) -> gopherItemType { + switch item { + case "0": + return .text + case "1": + return .directory + case "2": + return .nameserver + case "3": + return .error + case "4": + return .binhex + case "5": + return .bindos + case "6": + return .uuencoded + case "7": + return .search + case "8": + return .telnet + case "9": + return .binary + case "+": + return .mirror + case "g": + return .gif + case "I": + return .image + case "T": + return .tn3270Session + case ":": + return .bitmap + case ";": + return .movie + case "<": + return .sound + case "d": + return .doc + case "h": + return .html + case "i": + return .info + case "p": + return .image + case "r": + return .doc + case "s": + return .doc + case "P": + return .doc + case "X": + return .doc + default: + return .info + } +} diff --git a/Tests/swiftGopherClientTests/swiftGopherClientTests.swift b/Tests/swiftGopherClientTests/swiftGopherClientTests.swift new file mode 100644 index 0000000..a687a3e --- /dev/null +++ b/Tests/swiftGopherClientTests/swiftGopherClientTests.swift @@ -0,0 +1,37 @@ +// +// swiftGopherClientTests.swift +// +// +// Created by Navan Chauhan on 12/12/23. +// + +import XCTest +import NIO + +@testable import swiftGopherClient + +final class GopherClientTests: XCTestCase { + + override func setUp() { + super.setUp() + } + + override func tearDown() { + super.tearDown() + } + + func testGopherServerConnection() { + let expectation = XCTestExpectation(description: "Connect and receive response from Gopher server") + let client = GopherClient() + client.sendRequest(to: "gopher.floodgap.com", message: "\r\n") { result in + switch result { + case .success(_): + expectation.fulfill() + case .failure(let error): + print("Error \(error)") + } + } + + wait(for: [expectation], timeout: 30) + } +} -- cgit v1.2.3