summaryrefslogtreecommitdiffstats
path: root/Tests/CepisTests/TestHTTPServer.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Tests/CepisTests/TestHTTPServer.swift')
-rw-r--r--Tests/CepisTests/TestHTTPServer.swift266
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)
+}