swiftgemini

[ACTIVE] gemini protocol in swift
git clone git://git.figbert.com/swiftgemini.git
Log | Files | Refs | README

commit 9ac6c7476c974f26fc5faeb7548bfa3eecb9ac33
parent aec8d755c2b3e5b2a8fd7d719dd005617058e8a6
Author: FIGBERT <figbert@figbert.com>
Date:   Tue, 30 Aug 2022 18:19:51 -0700

Manage synchronicity with async/await

Diffstat:
MSources/SwiftGemini/SwiftGemini.swift | 93++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
MTests/SwiftGeminiTests/SwiftGeminiTests.swift | 14+++++++-------
2 files changed, 64 insertions(+), 43 deletions(-)

diff --git a/Sources/SwiftGemini/SwiftGemini.swift b/Sources/SwiftGemini/SwiftGemini.swift @@ -1,56 +1,63 @@ import Foundation import Network -class GeminiRequest { +class GeminiRequestor { static let queue = DispatchQueue(label: "gemini", qos: .default) fileprivate var conn: NWConnection? - fileprivate var response: Data? - let url: URL - var status: StatusCode? - var meta: MetaHeader? - var body: Data? - var text: String? - var gemtext: String? + enum GeminiRequestorError: Error { + case invalidResponseHeader + } - init?(_ url: URL) throws { - guard url.user == nil && url.password == nil, - url.host != nil && url.absoluteString.data(using: .utf8)!.count <= 1024 else { return nil } - self.url = url - request() + func request(url: URL) async throws -> GeminiResponse { + let response: GeminiResponse = try await withCheckedThrowingContinuation({ continuation in + requestWithCompletion(url: url, completion: { result in + switch result { + case .success(let response): + continuation.resume(returning: response) + case .failure(let error): + continuation.resume(throwing: error) + } + }) + }) + return response } - fileprivate func request() { - let host = NWEndpoint.Host(self.url.host!) - let port = NWEndpoint.Port(integerLiteral: NWEndpoint.Port.IntegerLiteralType(self.url.port ?? 1965)) + func requestWithCompletion(url: URL, completion: @escaping (Result<GeminiResponse, Error>) -> Void) { + guard url.user == nil && url.password == nil, + url.host != nil && url.absoluteString.data(using: .utf8)!.count <= 1024 else { return } + + let host = NWEndpoint.Host(url.host!) + let port = NWEndpoint.Port(integerLiteral: NWEndpoint.Port.IntegerLiteralType(url.port ?? 1965)) let opts = NWProtocolTLS.Options() sec_protocol_options_set_min_tls_protocol_version(opts.securityProtocolOptions, .TLSv12) sec_protocol_options_set_verify_block(opts.securityProtocolOptions, { (sec_protocol_metadata, sec_trust, sec_protocol_verify_complete) in sec_protocol_metadata_copy_peer_public_key(sec_protocol_metadata) sec_protocol_verify_complete(true) - }, GeminiRequest.queue) + }, GeminiRequestor.queue) - let request = "\(self.url.absoluteString)\r\n" + let request = "\(url.absoluteString)\r\n" let data = request.data(using: .utf8) let conn = NWConnection(host: host, port: port, using: NWParameters(tls: opts)) self.conn = conn - conn.start(queue: GeminiRequest.queue) + conn.start(queue: GeminiRequestor.queue) conn.send(content: data, contentContext: .defaultMessage, isComplete: true, completion: .contentProcessed({ (err) in conn.receiveMessage { (data, ctx, isComplete, err) in conn.cancel() - self.parseResponse(data: data!) + completion(self.parseResponse(url: url, data: data!)) } })) } - fileprivate func parseResponse(data: Data) { + fileprivate func parseResponse(url: URL, data: Data) -> Result<GeminiResponse, Error> { guard let ix = data.firstIndex(of: 13), - data[ix+1] == 10, ix < (1024+3) else { return } + data[ix+1] == 10, ix < (1024+3) else { return .failure(GeminiRequestorError.invalidResponseHeader) } var header = String(data: data.prefix(upTo: ix), encoding: .utf8)! let status = StatusCode(rawValue: Int(header.components(separatedBy: " ").first!)!)! let meta: MetaHeader + var body: GeminiBody? header = String(header.dropFirst(3)) switch status { @@ -65,26 +72,40 @@ class GeminiRequest { .ClientCertRequired: meta = MetaHeader.CertInfo(header) } - self.status = status - self.meta = meta - self.response = data - - if self.status == StatusCode.Success { - self.parseBody() + if status == StatusCode.Success { + body = parseBody(header: meta, data: data) } + + return .success(GeminiResponse( + url: url, status: status, + header: meta, body: body + )) } - fileprivate func parseBody() { - let ix = self.response!.firstIndex(of: 13)! + 2 - let body = self.response!.suffix(from: ix) + fileprivate func parseBody(header: MetaHeader, data: Data) -> GeminiBody { + let ix = data.firstIndex(of: 13)! + 2 + let body = data.suffix(from: ix) + var gemtext, text: String? - if case let .MIME(mime) = self.meta, mime.hasPrefix("text/") || mime.isEmpty { + if case let .MIME(mime) = header, mime.hasPrefix("text/") || mime.isEmpty { if mime.hasPrefix("text/gemini") || mime.isEmpty { - self.gemtext = String(data: body, encoding: .utf8) + gemtext = String(data: body, encoding: .utf8) } - self.text = String(data: body, encoding: .utf8) - } else { - self.body = body + text = String(data: body, encoding: .utf8) } + + return GeminiBody(raw: body, text: text, gemtext: gemtext) } } + +struct GeminiResponse { + let url: URL + let status: StatusCode + let header: MetaHeader + let body: GeminiBody? +} + +struct GeminiBody { + let raw: Data + let text, gemtext: String? +} diff --git a/Tests/SwiftGeminiTests/SwiftGeminiTests.swift b/Tests/SwiftGeminiTests/SwiftGeminiTests.swift @@ -2,15 +2,15 @@ import XCTest @testable import SwiftGemini final class SwiftGeminiTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - let _ = try! GeminiRequest(URL(string: "gemini://figbert.com")!) - sleep(1) + func testFigbertDotCom() async { + let engine = GeminiRequestor() + let test = try! await engine.request(url: URL(string: "gemini://figbert.com")!) + XCTAssertEqual(URL(string: "gemini://figbert.com"), test.url) + XCTAssertEqual(test.status, StatusCode.Success) + XCTAssertEqual(test.body!.gemtext!.prefix(13), "# Hello World") } static var allTests = [ - ("testExample", testExample), + ("testFigbertDotCom", testFigbertDotCom), ] }