ird-api-swift
Native Swift client for the whole ird API: iOS 16+ and macOS 13+, one library product, IrdAPI. Every one of the API's 161 operations is a typed async throws method — domains, DNS, servers, IPFS, tunnels, p2p apps, credits, support, sharing, transfers, jobs and the account's API keys — with models generated from the OpenAPI spec, so a native app reads instance.state and balance.total, never a dictionary.
It is the control-plane companion of ird-swift (IrdP2P, the p2p SDK), and behaves exactly like the TypeScript, ClojureScript and Clojure clients: same encoding rules, same error table, same client-side domain gate, same flows.
Install
There is no registry; the package is a git dependency served straight from this domain:
// Package.swift
dependencies: [
.package(url: "https://ird.cod-sursa.ro/api-swift.git", from: "0.1.0"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "IrdAPI", package: "api-swift")]),
]
In Xcode: File ▸ Add Package Dependencies…, paste https://ird.cod-sursa.ro/api-swift.git, choose "Up to Next Major Version" from 0.1.0, and add the IrdAPI product to your app target. The package has no dependencies.
Or pinned to an exact commit:
git ls-remote https://ird.cod-sursa.ro/api-swift.git
.package(url: "https://ird.cod-sursa.ro/api-swift.git", revision: "…")
The landing page at https://ird.cod-sursa.ro/api-swift/ shows the current release coordinates ready to paste, and https://ird.cod-sursa.ro/releases.json carries them for scripts and agents (head, tag, tag_sha per repo).
Quickstart
import IrdAPI
let client = IrdClient(apiKey: "ird_...") // or a Keychain-backed credential, see below
let balance = try await client.credits.balance()
print(balance.total) // "509.72" — money is always a decimal String
let check = try await IrdClient().domains.check(domain: "numele-meu.ro") // anonymous is fine here
if check.status == .available {
let queued = try await client.registrations.register(body: RegisterBody(domain: check.domain, years: 1, useOurDns: true))
let action = try await client.registrations.waitForAction(id: queued.action.id) // polls until the registry answers
print(action.state) // .succeeded / .failed / .cancelled
}
for instance in try await client.vps.listInstances().instances where instance.state == .running {
print(instance.name, instance.ipv4 ?? "-")
}
let upload = try await client.ipfs.addFile(url: fileURL, name: "site.tar") // grant → stream the bytes → poll
print(upload.cid ?? "")
let result = try await client.jobs.run(.llmChat(messages: [.user("Salut!")], maxTokens: 256))
print(result.result) // [String: JSONValue]
Module accessors mirror the API's areas: client.auth, .credits, .support, .domains, .registrations, .dns, .sharing, .vps, .transfers, .tunnels, .ipfs, .pins, .p2p, .jobs. Path parameters are labeled arguments, an optional query: struct comes next, and the JSON body (a generated struct) comes last. Listings with pages have an All companion (registrations.listAll, ipfs.listAllPins, …).
Authentication
One credential slot takes either an API key (ird_..., created in the account panel) or an account session (a JWT from the device login). Account-only operations (API keys, sharing, VPS transfers, sensitive domains) answer 401 to an API key; the generated doc comment on each method says which.
// Fixed key
let client = IrdClient(apiKey: "ird_...")
// Keychain-backed: consulted on every request, so a token stored later is picked up
let store = KeychainCredentialStore(service: "com.example.myapp")
let client = IrdClient(credential: store)
// Device login (`ird login`) in three phases a SwiftUI app can drive
let start = try await client.auth.startDeviceLogin()
open(URL(string: start.verificationUriComplete)!) // show start.userCode too
try await client.auth.waitForDeviceApproval(start) // 400 "pending" and 429 "slow down" are just "not yet"
let session = try await client.auth.completeDeviceLogin(start, confirmationCode: codeFromTheBrowser)
try store.save(session.accessToken)
CredentialProvider is a one-method protocol, so any token source works; InMemoryCredentialStore is for tests and previews. The device-login operations are anonymous and never send a bearer, whatever the client holds.
Errors
Everything throws IrdError:
| case |
when |
.api(status:code:message:body:) |
the API answered non-2xx; code and message come from the {error:{code,message}} envelope, body is the parsed JSON |
.transport(_) |
DNS, TLS, socket, timeout, or a 2xx body that is not JSON / does not decode into the typed model |
.config(_) |
a mistake caught before any request (missing path parameter, gate without a credential) |
.domainDenied(domain:pattern:op:) |
the domain gate refused a mutation |
.timeout(message:last:) |
a poll loop (jobs.run, addFile, waitForAction, device login) passed its deadline; last is the last response |
.jobFailed(_), .uploadFailed(_) |
the job or upload settled in a failed state; the value carries the server's reason |
Helpers read the common cases: error.isAuth (401), .isInsufficientCredits (402), .isRateLimited (429), .isNotFound, .needsTOTP (totp-required / invalid-totp: repeat the call with a fresh authenticator code in totpCode), .isAPIKeyBlocked (sensitive-domain-api-key-blocked: the operation needs an account session). There is no HTTP-level retry: a failed request is reported at once.
Domain gate
For agents and automation, a client can carry a domain allow/deny list that refuses domain-touching mutations (POST/PUT/PATCH/DELETE) before any request is sent. Reads are never gated.
let client = try await IrdClient.gated(Configuration(apiKey: "ird_..."),
rules: GateRules(deniedDomains: ["*.prod.ro"], allowedDomains: ["sandbox.ro"]))
try await client.tunnels.create(body: CreateTunnelBody(hostname: "dev.prod.ro"))
// throws IrdError.domainDenied(domain: "dev.prod.ro", pattern: "*.prod.ro", op: "POST /tunnels")
Names are compared lower-case without trailing dots; *.x.ro also matches x.ro; deny wins over allow; a nil allow-list means "no allow-list" while [] allows nothing. Mutations that address a domain by registration id resolve it through an index built when the client is created (refreshGate() rebuilds it); a few resolve it with one read first (a tunnel, a claim, a gateway domain). An unresolvable domain passes under a pure deny-list and fails closed under an allow-list. The 43 gated operations are the same as in the sibling clients (Sources/IrdAPI/Gate/GatedOperations.swift).
Modules
| Module |
Endpoints |
Covers |
domains |
9 |
availability check/search, utilities, .ro web directory |
registrations |
27 |
register, renew, transfer, trade, hosts, DS data, registrant |
dns |
18 |
hosted DNS zones, records, DNSSEC, nameserver sets, bring-your-own-domain claims |
sharing |
7 |
delegated-access grants, both directions |
vps |
34 |
tiers, instances, one-click apps, volumes, snapshots |
transfers |
9 |
cross-account VPS resource handover |
tunnels |
6 |
HTTPS tunnels on your own domain |
ipfs |
18 |
pinning, direct uploads, IPNS, gateway domains and the pins they serve |
pins |
5 |
standard IPFS Pinning Service API at /pins |
p2p |
12 |
p2p apps, TURN and offline-mailbox credentials, custom domains |
jobs |
3 |
async jobs: LLM chat/completion, embeddings, NLP, bulk checks |
credits |
3 |
balance, ledger, prices |
support |
4 |
support threads |
auth |
6 |
device login, API keys |
The p2p offline mailbox itself (deposit/replay on the app's own node) is not an api.ird.ro operation; it ships natively in IrdP2P. Minting its credentials (p2p.mintMailboxCredentials) is here.
Conventions
- Models are generated from
spec/openapi.json by scripts/gen-api.py (committed under Sources/IrdAPI/Generated/): Codable, Sendable, Hashable structs with the wire's snake_case mapped through CodingKeys, required fields non-optional, nullable ones optional. Fields the server adds later are ignored; string enums are open (InstanceState.unrecognized("…") decodes anything new, .known lists the spec's values).
- Money is a decimal
String, never a Double — balances, prices, costs.
- Every operation has exactly one success status in the spec (202 = queued action to poll), so methods return the parsed body directly; the doc comment names the status.
IrdClient is an immutable, Sendable class; the transport, clock and credential are injectable through Configuration, which is how the offline suite runs and how you add instrumentation.
client.raw.request(method:path:…) is the untyped escape hatch (JSONValue in and out) for any spec path, with the same base URL, credential and gate; the absolute-URL variant talks to node-hosted endpoints.
Async patterns
- Jobs —
jobs.run(_:) enqueues, polls GET /jobs/{id} (1 s doubling to 8 s, 120 s deadline) and fetches the result. Builders: .domainAvailabilityBulk(domains:), .embedding(input:), .llmChat(messages:), .llmRawCompletion(prompt:), .nlpAnalyze(input:), .nlpDetectLanguage(input:).
- Registry / VPS actions — 202 responses return an action;
registrations.waitForAction(id:) / vps.waitForAction(id:) poll until succeeded, failed or cancelled and return it.
- IPFS uploads —
ipfs.addFile(data:) or addFile(url:) (streamed from disk): upload grant → raw application/octet-stream POST to the node with the grant's own bearer → poll until completed (300 s deadline). format: .car for a client-packed CAR archive.
- Device login — three phases, see Authentication;
deviceLogin(prompt:confirmationCode:) composes them.
- Pagination —
listAll companions follow next_cursor or walk limit/offset until a short page: registrations.listAll, listAllActions, credits.ledgerAll, domains.directoryAll, support.listAllThreads, ipfs.listAllPins, listAllDomainPins(id:). PollOptions tunes every poll loop.
Example app
Examples/IrdManager is a SwiftUI account manager for macOS 14+ built on the package: sign in with an API key or the device login (stored in the Keychain), then credits and ledger, domains (register with an availability check, renew, auto-renew, nameservers, glue hosts), hosted DNS zones with a record editor, servers (create, power actions, destroy), IPFS pins (pin, unpin, upload a file), tunnels, p2p apps, support threads with replies, and API keys. Sensitive-domain step-ups open an authenticator prompt and retry with the code. Open Examples/IrdManager/IrdManager.xcodeproj and run it.
Tests
swift test # offline: request building, decode table, gate, flows, and the generated inventory
IRD_API_KEY=... swift test --filter Live # read-only smoke tests and the spec conformance sweep against the live API
The generated inventory pins the client to the spec three ways: every spec operation has a method and nothing else does (OperationTableTests), every method issues exactly one request with the exact method and URL and decodes a synthesised response (CoverageTests, 161 cases), and every body and response type decodes and re-encodes the synthesised JSON losslessly (SynthRoundTripTests). The live sweep then pins the spec to the API: each read endpoint is fetched raw, checked against its declared schema — types, required, enum, and fields the server returns that the schema never mentions — and decoded through the typed method. Drift is printed and fails the run.
Build
swift build
swift test
scripts/fetch-spec.sh # refresh spec/openapi.json from https://api.ird.ro/openapi.json
python3 scripts/gen-api.py # regenerate the models, operations and test tables (--check verifies, --report inventories)
scripts/release-check.sh # manifest, generated code up to date, build, offline tests, clean tree (MACAPP=1 builds the example)
SwiftPM versions come from git tags (vX.Y.Z); there is no version field to bump. The generator is Python 3 standard library only; its operation-name table and naming overrides at the top of scripts/gen-api.py are the only hand-edited part, and it refuses to run when the spec gains an operation the table does not know.
License
MIT.