import Foundation
/// URL and header construction, byte-compatible with the sibling clients.
enum RequestBuilder {
private static let unreserved: Set<UInt8> = {
var s = Set<UInt8>()
for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~".utf8 { s.insert(c) }
return s
}()
/// Percent-encodes one component like `encodeURIComponent` plus `!'()*`: `%20` for
/// spaces (never `+`), uppercase hex, `/` encoded so a value stays one path segment.
static func encode(_ value: String) -> String {
var out = ""
out.reserveCapacity(value.utf8.count)
let hex = Array("0123456789ABCDEF")
for byte in value.utf8 {
if unreserved.contains(byte) {
out.append(Character(UnicodeScalar(byte)))
} else {
out.append("%")
out.append(hex[Int(byte >> 4)])
out.append(hex[Int(byte & 0x0F)])
}
}
return out
}
/// `?a=b&c=d` in the given order, or "" when there are no items.
static func queryString(_ items: [QueryItem]) -> String {
if items.isEmpty { return "" }
return "?" + items.map { "\(encode($0.name))=\(encode($0.value))" }.joined(separator: "&")
}
/// Fills `{name}` placeholders from `params`, each value encoded as a single segment.
static func fillPath(_ template: String, params: [String: String]) throws -> String {
var out = ""
var rest = Substring(template)
while let open = rest.firstIndex(of: "{") {
out += rest[..<open]
guard let close = rest[open...].firstIndex(of: "}") else { throw IrdError.config("Unbalanced path template \(template)") }
let name = String(rest[rest.index(after: open)..<close])
guard let value = params[name] else {
throw IrdError.config("Missing path parameter {\(name)} for \(template)")
}
out += encode(value)
rest = rest[rest.index(after: close)...]
}
out += rest
return out
}
/// The base URL with trailing slashes stripped.
static func base(_ url: URL) -> String {
var s = url.absoluteString
while s.hasSuffix("/") { s.removeLast() }
return s
}
/// Assembles the request: `Accept: application/json`, the bearer when a non-blank token
/// is given, `Content-Type: application/json` only when a body exists, then caller
/// headers on top.
static func build(url: URL, method: HTTPMethod, token: String?, body: Data?, contentType: String = "application/json",
headers: [String: String], timeout: TimeInterval?) -> URLRequest {
var r = timeout.map { URLRequest(url: url, timeoutInterval: $0) } ?? URLRequest(url: url)
r.httpMethod = method.rawValue
r.setValue("application/json", forHTTPHeaderField: "Accept")
if let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
r.setValue(contentType, forHTTPHeaderField: "Content-Type")
r.httpBody = body
}
for (k, v) in headers { r.setValue(v, forHTTPHeaderField: k) }
return r
}
}
/// Wraps an existential so `JSONEncoder` can encode it.
struct AnyEncodable: Encodable {
let value: any Encodable
func encode(to encoder: any Encoder) throws { try value.encode(to: encoder) }
}