import Foundation
/// The entry point. Immutable and `Sendable`; module accessors (`client.vps`,
/// `client.registrations`, ...) are generated from the spec, `client.raw` is the
/// untyped escape hatch.
public final class IrdClient: Sendable {
public let configuration: Configuration
/// The domain gate, present only on clients built with `gated`.
public let gate: DomainGate?
public init(configuration: Configuration = Configuration()) {
self.configuration = configuration
self.gate = nil
}
init(configuration: Configuration, gate: DomainGate?) {
self.configuration = configuration
self.gate = gate
}
public convenience init(apiKey: String) {
self.init(configuration: Configuration(apiKey: apiKey))
}
public convenience init(credential: any CredentialProvider) {
var c = Configuration()
c.credential = credential
self.init(configuration: c)
}
/// A client whose domain-touching mutations are checked against `rules`. Builds the
/// registration index up front, so the credential must resolve to a token.
public static func gated(_ configuration: Configuration, rules: GateRules) async throws -> IrdClient {
let plain = IrdClient(configuration: configuration)
guard let token = try await configuration.credential.token(),
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw IrdError.config("Domain gate options require an authenticated client (apiKey)")
}
let index = try await plain.fetchRegistrationIndex()
return IrdClient(configuration: configuration, gate: DomainGate(rules: rules, index: index))
}
public var isGated: Bool { gate != nil }
/// Re-reads the registration index (after registering a domain, say); no-op when ungated.
public func refreshGate() async throws {
guard let gate else { return }
await gate.replace(try await fetchRegistrationIndex())
}
public var raw: RawAPI { RawAPI(client: self) }
/// The one call every generated operation makes: fills the path, checks the gate,
/// attaches the credential, sends, applies the decode table, and decodes `Response`
/// from the original bytes.
public func request<Response: Decodable & Sendable>(
_ op: IrdOperation, params: [String: String] = [:], query: [QueryItem] = [],
body: (any Encodable & Sendable)? = nil
) async throws -> Response {
let (data, _) = try await perform(op, params: params, query: query, body: body, gated: true)
do {
return try JSONDecoder().decode(Response.self, from: data)
} catch {
throw IrdError.transport("decoding \(Response.self): \(error)")
}
}
/// Same pipeline for operations whose success carries no body.
public func requestNoContent(
_ op: IrdOperation, params: [String: String] = [:], query: [QueryItem] = [],
body: (any Encodable & Sendable)? = nil
) async throws {
_ = try await perform(op, params: params, query: query, body: body, gated: true)
}
func perform(_ op: IrdOperation, params: [String: String], query: [QueryItem],
body: (any Encodable & Sendable)?, gated: Bool) async throws -> (data: Data, json: JSONValue) {
var bodyData: Data? = nil
if let body {
do { bodyData = try JSONEncoder().encode(AnyEncodable(value: body)) } catch {
throw IrdError.config("encoding body for \(op.key): \(error)")
}
}
if gated, gate != nil, let source = GatedOperations.table[op.key] {
let bodyJSON = try bodyData.map { try JSONValue.parse($0) }
let target = try await gateTarget(for: source, params: params, body: bodyJSON)
try await checkGate(op: op.key, target: target)
}
let token: String? = op.security == .anonymous ? nil : try await configuration.credential.token()
let path = try RequestBuilder.fillPath(op.pathTemplate, params: params)
guard let url = URL(string: RequestBuilder.base(configuration.apiURL) + path + RequestBuilder.queryString(query)) else {
throw IrdError.config("invalid URL for \(op.key)")
}
let request = RequestBuilder.build(url: url, method: op.method, token: token, body: bodyData,
headers: configuration.headers, timeout: configuration.timeout)
let response = try await configuration.transport.send(request)
return try ResponseDecoder.decode(status: response.status, body: response.body)
}
}