diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:18:32 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:18:32 -0300 |
| commit | 72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7 (patch) | |
| tree | 3e4d33a614b673bc8cd9de1e688245f340cd3e1e /Tests/CepisTests/TestHTTPServer.swift | |
| download | swift-72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'Tests/CepisTests/TestHTTPServer.swift')
| -rw-r--r-- | Tests/CepisTests/TestHTTPServer.swift | 266 |
1 files changed, 266 insertions, 0 deletions
diff --git a/Tests/CepisTests/TestHTTPServer.swift b/Tests/CepisTests/TestHTTPServer.swift new file mode 100644 index 0000000..66722d4 --- /dev/null +++ b/Tests/CepisTests/TestHTTPServer.swift @@ -0,0 +1,266 @@ +import Foundation + +#if canImport(Glibc) +import Glibc +private let socketStreamType = Int32(SOCK_STREAM.rawValue) +private let sendFlags = Int32(MSG_NOSIGNAL) +#elseif canImport(Darwin) +import Darwin +private let socketStreamType = SOCK_STREAM +private let sendFlags: Int32 = 0 +#endif + +struct StubResponse { + var status: Int = 200 + var body: String = "" + var headers: [String: String] = [:] +} + +final class Router: @unchecked Sendable { + private let lock = NSLock() + private var routes: [String: [StubResponse]] = [:] + private var handler: ((String) -> StubResponse)? + private var recorded: [String] = [] + + func respond(_ path: String, status: Int = 200, body: String, headers: [String: String] = [:]) { + lock.lock() + defer { lock.unlock() } + routes[path] = [StubResponse(status: status, body: body, headers: headers)] + } + + func respond(_ path: String, sequence: [StubResponse]) { + lock.lock() + defer { lock.unlock() } + routes[path] = sequence + } + + func setHandler(_ handler: @escaping (String) -> StubResponse) { + lock.lock() + defer { lock.unlock() } + self.handler = handler + } + + var calls: [String] { + lock.lock() + defer { lock.unlock() } + return recorded + } + + func count(_ prefix: String) -> Int { + calls.filter { $0.hasPrefix(prefix) }.count + } + + func next(_ path: String) -> StubResponse { + lock.lock() + defer { lock.unlock() } + recorded.append(path) + + if let handler { + return handler(path) + } + guard var queued = routes[path], !queued.isEmpty else { + return StubResponse(status: 404, body: #"{"erro":"cep_nao_encontrado"}"#) + } + if queued.count > 1 { + let head = queued.removeFirst() + routes[path] = queued + return head + } + return queued[0] + } +} + +/// A throwaway HTTP/1.1 server on a random loopback port, built on BSD +/// sockets because Foundation ships no server on Linux. +/// +/// Every response closes its connection, so there is no keep-alive state to +/// track and every request is a fresh accept. +final class TestHTTPServer: @unchecked Sendable { + let router = Router() + let port: UInt16 + + private let listener: Int32 + private let stopped = NSLock() + private var isStopped = false + + init() throws { + signal(SIGPIPE, SIG_IGN) + + let opened = try TestHTTPServer.openListener() + listener = opened.descriptor + port = opened.port + + let thread = Thread { [weak self] in self?.acceptLoop() } + thread.stackSize = 1 << 20 + thread.start() + } + + private static func openListener() throws -> (descriptor: Int32, port: UInt16) { + let descriptor = socket(AF_INET, socketStreamType, 0) + guard descriptor >= 0 else { + throw TestServerError.failed("socket() failed with errno \(errno)") + } + + var reuse: Int32 = 1 + setsockopt( + descriptor, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size)) + + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(UInt16(0).bigEndian) + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bound = withUnsafePointer(to: &address) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in + bind(descriptor, generic, socklen_t(MemoryLayout<sockaddr_in>.size)) + } + } + guard bound == 0 else { + close(descriptor) + throw TestServerError.failed("bind() failed with errno \(errno)") + } + guard listen(descriptor, 32) == 0 else { + close(descriptor) + throw TestServerError.failed("listen() failed with errno \(errno)") + } + + var actual = sockaddr_in() + var length = socklen_t(MemoryLayout<sockaddr_in>.size) + let named = withUnsafeMutablePointer(to: &actual) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in + getsockname(descriptor, generic, &length) + } + } + guard named == 0 else { + close(descriptor) + throw TestServerError.failed("getsockname() failed with errno \(errno)") + } + return (descriptor, UInt16(bigEndian: actual.sin_port)) + } + + var baseURL: URL { + URL(string: "http://127.0.0.1:\(port)")! + } + + func stop() { + stopped.lock() + if isStopped { + stopped.unlock() + return + } + isStopped = true + stopped.unlock() + + shutdown(listener, Int32(SHUT_RDWR)) + close(listener) + } + + private var shuttingDown: Bool { + stopped.lock() + defer { stopped.unlock() } + return isStopped + } + + private func acceptLoop() { + while !shuttingDown { + let client = accept(listener, nil, nil) + if client < 0 { + if errno == EINTR { continue } + return + } + let thread = Thread { [weak self] in self?.handle(client) } + thread.stackSize = 1 << 20 + thread.start() + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + + #if canImport(Darwin) + var noSigPipe: Int32 = 1 + setsockopt( + client, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size)) + #endif + + guard let path = readRequestPath(client) else { return } + let stub = router.next(path) + write(client, response(for: stub)) + } + + private func readRequestPath(_ client: Int32) -> String? { + var received: [UInt8] = [] + var chunk = [UInt8](repeating: 0, count: 4096) + + while headerEnd(of: received) == nil { + let read = recv(client, &chunk, chunk.count, 0) + if read <= 0 { break } + received.append(contentsOf: chunk[0..<read]) + if received.count > 1 << 20 { break } + } + guard headerEnd(of: received) != nil else { return nil } + + var lineEnd = received.count + for index in 0..<max(0, received.count - 1) where received[index] == 0x0D { + lineEnd = index + break + } + let line = String(decoding: received[0..<lineEnd], as: UTF8.self) + let parts = line.split(separator: " ") + guard parts.count >= 2 else { return nil } + return String(parts[1]) + } + + private func headerEnd(of bytes: [UInt8]) -> Int? { + guard bytes.count >= 4 else { return nil } + for index in 0...(bytes.count - 4) { + if bytes[index] == 0x0D, bytes[index + 1] == 0x0A, bytes[index + 2] == 0x0D, + bytes[index + 3] == 0x0A + { + return index + } + } + return nil + } + + private func response(for stub: StubResponse) -> [UInt8] { + let body = Array(stub.body.utf8) + var head = "HTTP/1.1 \(stub.status) \(reason(stub.status))\r\n" + for (name, value) in stub.headers { + head += "\(name): \(value)\r\n" + } + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n\r\n" + return Array(head.utf8) + body + } + + private func reason(_ status: Int) -> String { + switch status { + case 200: return "OK" + case 400: return "Bad Request" + case 404: return "Not Found" + case 429: return "Too Many Requests" + case 500: return "Internal Server Error" + case 502: return "Bad Gateway" + case 503: return "Service Unavailable" + case 504: return "Gateway Timeout" + default: return "Status" + } + } + + private func write(_ client: Int32, _ bytes: [UInt8]) { + var offset = 0 + while offset < bytes.count { + let written = bytes.withUnsafeBufferPointer { buffer -> Int in + guard let base = buffer.baseAddress else { return -1 } + return send(client, base + offset, bytes.count - offset, sendFlags) + } + if written <= 0 { return } + offset += written + } + } +} + +enum TestServerError: Error { + case failed(String) +} |