232 lines
8.4 KiB
Swift
232 lines
8.4 KiB
Swift
import Foundation
|
|
import Darwin
|
|
|
|
struct HTTPRequest {
|
|
let method: String
|
|
let path: String
|
|
let headers: [String: String]
|
|
let body: Data
|
|
}
|
|
|
|
struct HTTPResponse {
|
|
var status: Int = 200
|
|
var headers: [String: String] = ["Content-Type": "application/json"]
|
|
var body: String = ""
|
|
|
|
init(status: Int = 200, body: String = "") {
|
|
self.status = status
|
|
self.body = body
|
|
}
|
|
}
|
|
|
|
enum HTTPServerError: Error, CustomStringConvertible {
|
|
case socketFailed
|
|
case bindFailed(port: UInt16)
|
|
case listenFailed
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .socketFailed: return "socket() failed"
|
|
case .bindFailed(let port): return "bind() failed on 127.0.0.1:\(port) (already running?)"
|
|
case .listenFailed: return "listen() failed"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Minimal single-threaded-per-connection HTTP/1.1 server bound to a specific
|
|
/// local address. Uses BSD sockets + GCD so there are no third-party deps and
|
|
/// the bind address can be locked to the loopback interface.
|
|
final class HTTPServer {
|
|
private let host: String
|
|
private let port: UInt16
|
|
private let handler: (HTTPRequest) -> HTTPResponse
|
|
private var listenFd: Int32 = -1
|
|
private var listenSource: DispatchSourceRead?
|
|
private let queue = DispatchQueue(label: "notification-service.http.listener")
|
|
|
|
init(host: String, port: UInt16, handler: @escaping (HTTPRequest) -> HTTPResponse) {
|
|
self.host = host
|
|
self.port = port
|
|
self.handler = handler
|
|
}
|
|
|
|
func start() throws {
|
|
// Writing to a socket whose peer has closed should not kill us.
|
|
signal(SIGPIPE, SIG_IGN)
|
|
|
|
listenFd = socket(AF_INET, SOCK_STREAM, 0)
|
|
if listenFd < 0 { throw HTTPServerError.socketFailed }
|
|
|
|
var reuse: Int32 = 1
|
|
setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size(ofValue: reuse)))
|
|
|
|
var addr = sockaddr_in()
|
|
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
addr.sin_family = sa_family_t(AF_INET)
|
|
addr.sin_port = port.bigEndian // htons
|
|
addr.sin_addr.s_addr = inet_addr(host) // 127.0.0.1 -> INADDR_LOOPBACK (network order)
|
|
|
|
let bindResult = withUnsafePointer(to: &addr) { ptr -> Int32 in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
|
bind(listenFd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
}
|
|
}
|
|
if bindResult < 0 { throw HTTPServerError.bindFailed(port: port) }
|
|
|
|
if listen(listenFd, 16) < 0 { throw HTTPServerError.listenFailed }
|
|
|
|
// Non-blocking listener driven by a GCD read source.
|
|
let flags = fcntl(listenFd, F_GETFL, 0)
|
|
_ = fcntl(listenFd, F_SETFL, flags | O_NONBLOCK)
|
|
|
|
let fd = listenFd
|
|
let source = DispatchSource.makeReadSource(fileDescriptor: listenFd, queue: queue)
|
|
source.setEventHandler { [weak self] in self?.acceptConnections() }
|
|
source.setCancelHandler { close(fd) }
|
|
source.resume()
|
|
listenSource = source
|
|
}
|
|
|
|
func stop() {
|
|
listenSource?.cancel()
|
|
listenSource = nil
|
|
}
|
|
|
|
// MARK: - Accept loop
|
|
|
|
private func acceptConnections() {
|
|
while true {
|
|
let clientFd = accept(listenFd, nil, nil)
|
|
if clientFd < 0 {
|
|
// EWOULDBLOCK/EAGAIN means we've drained the backlog.
|
|
if errno == EWOULDBLOCK || errno == EAGAIN { return }
|
|
return
|
|
}
|
|
let ownFd = clientFd
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
self?.handleConnection(fd: ownFd)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleConnection(fd: Int32) {
|
|
defer { close(fd) }
|
|
guard let request = readRequest(fd: fd) else {
|
|
sendResponse(fd: fd, response: HTTPResponse(status: 400, body: "{\"error\":\"bad request\"}"))
|
|
return
|
|
}
|
|
let response = handler(request)
|
|
sendResponse(fd: fd, response: response)
|
|
}
|
|
|
|
// MARK: - Request parsing
|
|
|
|
private func readRequest(fd: Int32) -> HTTPRequest? {
|
|
var buffer = Data()
|
|
var tmp = [UInt8](repeating: 0, count: 4096)
|
|
let separator = Data([0x0D, 0x0A, 0x0D, 0x0A]) // \r\n\r\n
|
|
|
|
while true {
|
|
let n = tmp.withUnsafeMutableBufferPointer { buf -> Int in
|
|
guard let base = buf.baseAddress else { return -1 }
|
|
return recv(fd, UnsafeMutableRawPointer(base), Int(buf.count), 0)
|
|
}
|
|
if n < 0 {
|
|
if errno == EINTR { continue }
|
|
return nil
|
|
}
|
|
if n == 0 { break } // peer closed
|
|
buffer.append(contentsOf: tmp[0..<n])
|
|
|
|
if let sepRange = buffer.range(of: separator) {
|
|
let headerEnd = sepRange.lowerBound
|
|
let bodyStart = sepRange.upperBound
|
|
let headerStr = String(data: buffer.subdata(in: 0..<headerEnd), encoding: .utf8) ?? ""
|
|
let headers = Self.parseHeaders(headerStr)
|
|
let contentLength = Int(headers["content-length"] ?? "0") ?? 0
|
|
|
|
// Keep reading until the full body is in the buffer.
|
|
while buffer.count - bodyStart < contentLength {
|
|
let m = tmp.withUnsafeMutableBufferPointer { buf -> Int in
|
|
guard let base = buf.baseAddress else { return -1 }
|
|
return recv(fd, UnsafeMutableRawPointer(base), Int(buf.count), 0)
|
|
}
|
|
if m < 0 {
|
|
if errno == EINTR { continue }
|
|
return nil
|
|
}
|
|
if m == 0 { break }
|
|
buffer.append(contentsOf: tmp[0..<m])
|
|
}
|
|
|
|
let bodyEnd = min(bodyStart + contentLength, buffer.count)
|
|
let body = buffer.subdata(in: bodyStart..<bodyEnd)
|
|
return Self.parseRequest(header: headerStr, body: body)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func parseRequest(header: String, body: Data) -> HTTPRequest? {
|
|
let lines = header.components(separatedBy: "\r\n")
|
|
guard let firstLine = lines.first else { return nil }
|
|
let parts = firstLine.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
|
|
guard parts.count >= 2 else { return nil }
|
|
let method = String(parts[0])
|
|
let path = String(parts[1])
|
|
let headers = parseHeaders(header)
|
|
return HTTPRequest(method: method, path: path, headers: headers, body: body)
|
|
}
|
|
|
|
private static func parseHeaders(_ header: String) -> [String: String] {
|
|
var headers: [String: String] = [:]
|
|
let lines = header.components(separatedBy: "\r\n")
|
|
for line in lines.dropFirst() {
|
|
guard let colonIdx = line.firstIndex(of: ":") else { continue }
|
|
let key = String(line[..<colonIdx]).trimmingCharacters(in: .whitespaces).lowercased()
|
|
let val = String(line[line.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
|
headers[key] = val
|
|
}
|
|
return headers
|
|
}
|
|
|
|
// MARK: - Response
|
|
|
|
private func sendResponse(fd: Int32, response: HTTPResponse) {
|
|
let bodyData = response.body.data(using: .utf8) ?? Data()
|
|
var header = "HTTP/1.1 \(response.status) \(Self.statusText(response.status))\r\n"
|
|
for (k, v) in response.headers { header += "\(k): \(v)\r\n" }
|
|
header += "Content-Length: \(bodyData.count)\r\n"
|
|
header += "Connection: close\r\n"
|
|
header += "\r\n"
|
|
|
|
var payload = header.data(using: .utf8) ?? Data()
|
|
payload.append(bodyData)
|
|
|
|
payload.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in
|
|
guard let base = ptr.baseAddress else { return }
|
|
var sent = 0
|
|
while sent < payload.count {
|
|
let n = send(fd, base.advanced(by: sent), payload.count - sent, 0)
|
|
if n <= 0 {
|
|
if n < 0 && errno == EINTR { continue }
|
|
break
|
|
}
|
|
sent += n
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func statusText(_ status: Int) -> String {
|
|
switch status {
|
|
case 200: return "OK"
|
|
case 201: return "Created"
|
|
case 400: return "Bad Request"
|
|
case 404: return "Not Found"
|
|
case 405: return "Method Not Allowed"
|
|
case 500: return "Internal Server Error"
|
|
default: return "Status"
|
|
}
|
|
}
|
|
}
|