diff --git a/sdk/kotlin/src/main/kotlin/io/synor/bridge/SynorBridge.kt b/sdk/kotlin/src/main/kotlin/io/synor/bridge/SynorBridge.kt new file mode 100644 index 0000000..1fd4391 --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/bridge/SynorBridge.kt @@ -0,0 +1,469 @@ +package io.synor.bridge + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Synor Bridge SDK for Kotlin. + * Cross-chain asset transfers with lock-mint and burn-unlock patterns. + */ +class SynorBridge(private val config: BridgeConfig) : AutoCloseable { + + companion object { + private val gson: Gson = GsonBuilder().create() + private val FINAL_STATUSES = setOf(TransferStatus.completed, TransferStatus.failed, TransferStatus.refunded) + } + + private val httpClient = HttpClient(CIO) { + engine { requestTimeout = config.timeoutSecs * 1000 } + } + private val closed = AtomicBoolean(false) + + constructor(apiKey: String) : this(BridgeConfig(apiKey)) + + // ==================== Chain Operations ==================== + + suspend fun getSupportedChains(): List { + val resp = request("GET", "/chains") + val result = gson.fromJson(resp, ChainsResponse::class.java) + return result.chains ?: emptyList() + } + + suspend fun getChain(chainId: ChainId): Chain { + val resp = request("GET", "/chains/${chainId.name.lowercase()}") + return gson.fromJson(resp, Chain::class.java) + } + + suspend fun isChainSupported(chainId: ChainId): Boolean = try { + getChain(chainId).supported + } catch (e: Exception) { false } + + // ==================== Asset Operations ==================== + + suspend fun getSupportedAssets(chainId: ChainId): List { + val resp = request("GET", "/chains/${chainId.name.lowercase()}/assets") + val result = gson.fromJson(resp, AssetsResponse::class.java) + return result.assets ?: emptyList() + } + + suspend fun getAsset(assetId: String): Asset { + val resp = request("GET", "/assets/${assetId.urlEncode()}") + return gson.fromJson(resp, Asset::class.java) + } + + suspend fun getWrappedAsset(originalAssetId: String, targetChain: ChainId): WrappedAsset { + val path = "/assets/${originalAssetId.urlEncode()}/wrapped/${targetChain.name.lowercase()}" + val resp = request("GET", path) + return gson.fromJson(resp, WrappedAsset::class.java) + } + + // ==================== Fee & Rate Operations ==================== + + suspend fun estimateFee(asset: String, amount: String, sourceChain: ChainId, targetChain: ChainId): FeeEstimate { + val body = mapOf( + "asset" to asset, + "amount" to amount, + "sourceChain" to sourceChain.name.lowercase(), + "targetChain" to targetChain.name.lowercase() + ) + val resp = request("POST", "/fees/estimate", body) + return gson.fromJson(resp, FeeEstimate::class.java) + } + + suspend fun getExchangeRate(fromAsset: String, toAsset: String): ExchangeRate { + val resp = request("GET", "/rates/${fromAsset.urlEncode()}/${toAsset.urlEncode()}") + return gson.fromJson(resp, ExchangeRate::class.java) + } + + // ==================== Lock-Mint Flow ==================== + + suspend fun lock(asset: String, amount: String, targetChain: ChainId, options: LockOptions? = null): LockReceipt { + val body = mutableMapOf( + "asset" to asset, + "amount" to amount, + "targetChain" to targetChain.name.lowercase() + ) + options?.recipient?.let { body["recipient"] = it } + options?.deadline?.let { body["deadline"] = it } + options?.slippage?.let { body["slippage"] = it } + val resp = request("POST", "/transfers/lock", body) + return gson.fromJson(resp, LockReceipt::class.java) + } + + suspend fun getLockProof(lockReceiptId: String): LockProof { + val resp = request("GET", "/transfers/lock/${lockReceiptId.urlEncode()}/proof") + return gson.fromJson(resp, LockProof::class.java) + } + + suspend fun waitForLockProof(lockReceiptId: String, pollIntervalMs: Long = 5000, maxWaitMs: Long = 600000): LockProof { + val deadline = System.currentTimeMillis() + maxWaitMs + while (System.currentTimeMillis() < deadline) { + try { + return getLockProof(lockReceiptId) + } catch (e: BridgeException) { + if (e.code == "CONFIRMATIONS_PENDING") { + delay(pollIntervalMs) + continue + } + throw e + } + } + throw BridgeException("Timeout waiting for lock proof", "CONFIRMATIONS_PENDING") + } + + suspend fun mint(proof: LockProof, targetAddress: String, options: MintOptions? = null): SignedTransaction { + val body = mutableMapOf("proof" to proof, "targetAddress" to targetAddress) + options?.gasLimit?.let { body["gasLimit"] = it } + options?.maxFeePerGas?.let { body["maxFeePerGas"] = it } + options?.maxPriorityFeePerGas?.let { body["maxPriorityFeePerGas"] = it } + val resp = request("POST", "/transfers/mint", body) + return gson.fromJson(resp, SignedTransaction::class.java) + } + + // ==================== Burn-Unlock Flow ==================== + + suspend fun burn(wrappedAsset: String, amount: String, options: BurnOptions? = null): BurnReceipt { + val body = mutableMapOf("wrappedAsset" to wrappedAsset, "amount" to amount) + options?.recipient?.let { body["recipient"] = it } + options?.deadline?.let { body["deadline"] = it } + val resp = request("POST", "/transfers/burn", body) + return gson.fromJson(resp, BurnReceipt::class.java) + } + + suspend fun getBurnProof(burnReceiptId: String): BurnProof { + val resp = request("GET", "/transfers/burn/${burnReceiptId.urlEncode()}/proof") + return gson.fromJson(resp, BurnProof::class.java) + } + + suspend fun waitForBurnProof(burnReceiptId: String, pollIntervalMs: Long = 5000, maxWaitMs: Long = 600000): BurnProof { + val deadline = System.currentTimeMillis() + maxWaitMs + while (System.currentTimeMillis() < deadline) { + try { + return getBurnProof(burnReceiptId) + } catch (e: BridgeException) { + if (e.code == "CONFIRMATIONS_PENDING") { + delay(pollIntervalMs) + continue + } + throw e + } + } + throw BridgeException("Timeout waiting for burn proof", "CONFIRMATIONS_PENDING") + } + + suspend fun unlock(proof: BurnProof, options: UnlockOptions? = null): SignedTransaction { + val body = mutableMapOf("proof" to proof) + options?.gasLimit?.let { body["gasLimit"] = it } + options?.gasPrice?.let { body["gasPrice"] = it } + val resp = request("POST", "/transfers/unlock", body) + return gson.fromJson(resp, SignedTransaction::class.java) + } + + // ==================== Transfer Management ==================== + + suspend fun getTransfer(transferId: String): Transfer { + val resp = request("GET", "/transfers/${transferId.urlEncode()}") + return gson.fromJson(resp, Transfer::class.java) + } + + suspend fun getTransferStatus(transferId: String): TransferStatus = getTransfer(transferId).status + + suspend fun listTransfers(filter: TransferFilter? = null): List { + val params = mutableListOf() + filter?.status?.let { params.add("status=${it.name.lowercase()}") } + filter?.sourceChain?.let { params.add("sourceChain=${it.name.lowercase()}") } + filter?.targetChain?.let { params.add("targetChain=${it.name.lowercase()}") } + filter?.limit?.let { params.add("limit=$it") } + filter?.offset?.let { params.add("offset=$it") } + + val path = if (params.isNotEmpty()) "/transfers?${params.joinToString("&")}" else "/transfers" + val resp = request("GET", path) + val result = gson.fromJson(resp, TransfersResponse::class.java) + return result.transfers ?: emptyList() + } + + suspend fun waitForTransfer(transferId: String, pollIntervalMs: Long = 10000, maxWaitMs: Long = 1800000): Transfer { + val deadline = System.currentTimeMillis() + maxWaitMs + while (System.currentTimeMillis() < deadline) { + val transfer = getTransfer(transferId) + if (transfer.status in FINAL_STATUSES) return transfer + delay(pollIntervalMs) + } + throw BridgeException("Timeout waiting for transfer completion") + } + + // ==================== Convenience Methods ==================== + + suspend fun bridgeTo( + asset: String, + amount: String, + targetChain: ChainId, + targetAddress: String, + lockOptions: LockOptions? = null, + mintOptions: MintOptions? = null + ): Transfer { + val lockReceipt = lock(asset, amount, targetChain, lockOptions) + if (config.debug) println("Locked: ${lockReceipt.id}, waiting for confirmations...") + + val proof = waitForLockProof(lockReceipt.id) + if (config.debug) println("Proof ready, minting on ${targetChain.name}...") + + mint(proof, targetAddress, mintOptions) + return waitForTransfer(lockReceipt.id) + } + + suspend fun bridgeBack( + wrappedAsset: String, + amount: String, + burnOptions: BurnOptions? = null, + unlockOptions: UnlockOptions? = null + ): Transfer { + val burnReceipt = burn(wrappedAsset, amount, burnOptions) + if (config.debug) println("Burned: ${burnReceipt.id}, waiting for confirmations...") + + val proof = waitForBurnProof(burnReceipt.id) + if (config.debug) println("Proof ready, unlocking on ${burnReceipt.targetChain}...") + + unlock(proof, unlockOptions) + return waitForTransfer(burnReceipt.id) + } + + // ==================== Lifecycle ==================== + + override fun close() { + closed.set(true) + httpClient.close() + } + + fun isClosed(): Boolean = closed.get() + + suspend fun healthCheck(): Boolean = try { + val resp = request("GET", "/health") + val map = gson.fromJson>(resp, object : TypeToken>() {}.type) + map["status"] == "healthy" + } catch (e: Exception) { false } + + // ==================== Private Methods ==================== + + private suspend fun request(method: String, path: String, body: Any? = null): String { + if (closed.get()) throw BridgeException("Client has been closed") + + var lastError: Exception? = null + repeat(config.retries) { attempt -> + try { + return doRequest(method, path, body) + } catch (e: Exception) { + lastError = e + if (attempt < config.retries - 1) delay((1L shl attempt) * 1000) + } + } + throw lastError ?: BridgeException("Unknown error") + } + + private suspend fun doRequest(method: String, path: String, body: Any?): String { + val url = "${config.endpoint}$path" + val response: HttpResponse = httpClient.request(url) { + this.method = HttpMethod.parse(method) + header("Authorization", "Bearer ${config.apiKey}") + header("Content-Type", "application/json") + header("X-SDK-Version", "kotlin/0.1.0") + body?.let { setBody(gson.toJson(it)) } + } + + val responseBody = response.bodyAsText() + if (response.status.value >= 400) { + val errorMap = try { + gson.fromJson>(responseBody, object : TypeToken>() {}.type) + } catch (e: Exception) { emptyMap() } + throw BridgeException( + errorMap["message"] as? String ?: "HTTP ${response.status.value}", + errorMap["code"] as? String, + response.status.value + ) + } + return responseBody + } + + private fun String.urlEncode(): String = java.net.URLEncoder.encode(this, "UTF-8") +} + +// ==================== Types ==================== + +data class BridgeConfig( + val apiKey: String, + val endpoint: String = "https://bridge.synor.io/v1", + val timeoutSecs: Long = 60, + val retries: Int = 3, + val debug: Boolean = false +) + +enum class ChainId { synor, ethereum, polygon, arbitrum, optimism, bsc, avalanche, solana, cosmos } +enum class AssetType { native, erc20, erc721, erc1155 } +enum class TransferStatus { pending, locked, confirming, minting, completed, failed, refunded } +enum class TransferDirection { lock_mint, burn_unlock } + +data class NativeCurrency(val name: String, val symbol: String, val decimals: Int) + +data class Chain( + val id: ChainId, + val name: String, + val chainId: Long, + val rpcUrl: String, + val explorerUrl: String, + val nativeCurrency: NativeCurrency, + val confirmations: Int, + val estimatedBlockTime: Int, + val supported: Boolean +) + +data class Asset( + val id: String, + val symbol: String, + val name: String, + val type: AssetType, + val chain: ChainId, + val contractAddress: String? = null, + val decimals: Int, + val logoUrl: String? = null, + val verified: Boolean = false +) + +data class WrappedAsset( + val originalAsset: Asset, + val wrappedAsset: Asset, + val chain: ChainId, + val bridgeContract: String +) + +data class ValidatorSignature(val validator: String, val signature: String, val timestamp: Long) + +data class LockReceipt( + val id: String, + val txHash: String, + val sourceChain: ChainId, + val targetChain: ChainId, + val asset: Asset, + val amount: String, + val sender: String, + val recipient: String, + val lockTimestamp: Long, + val confirmations: Int, + val requiredConfirmations: Int +) + +data class LockProof( + val lockReceipt: LockReceipt, + val merkleProof: List, + val blockHeader: String, + val signatures: List +) + +data class BurnReceipt( + val id: String, + val txHash: String, + val sourceChain: ChainId, + val targetChain: ChainId, + val wrappedAsset: Asset, + val originalAsset: Asset, + val amount: String, + val sender: String, + val recipient: String, + val burnTimestamp: Long, + val confirmations: Int, + val requiredConfirmations: Int +) + +data class BurnProof( + val burnReceipt: BurnReceipt, + val merkleProof: List, + val blockHeader: String, + val signatures: List +) + +data class Transfer( + val id: String, + val direction: TransferDirection, + val status: TransferStatus, + val sourceChain: ChainId, + val targetChain: ChainId, + val asset: Asset, + val amount: String, + val sender: String, + val recipient: String, + val sourceTxHash: String? = null, + val targetTxHash: String? = null, + val fee: String, + val feeAsset: Asset, + val createdAt: Long, + val updatedAt: Long, + val completedAt: Long? = null, + val errorMessage: String? = null +) + +data class FeeEstimate( + val bridgeFee: String, + val gasFeeSource: String, + val gasFeeTarget: String, + val totalFee: String, + val feeAsset: Asset, + val estimatedTime: Int, + val exchangeRate: String? = null +) + +data class ExchangeRate( + val fromAsset: Asset, + val toAsset: Asset, + val rate: String, + val inverseRate: String, + val lastUpdated: Long, + val source: String +) + +data class SignedTransaction( + val txHash: String, + val chain: ChainId, + val from: String, + val to: String, + val value: String, + val data: String, + val gasLimit: String, + val gasPrice: String? = null, + val maxFeePerGas: String? = null, + val maxPriorityFeePerGas: String? = null, + val nonce: Int, + val signature: String +) + +data class LockOptions(val recipient: String? = null, val deadline: Long? = null, val slippage: Double? = null) +data class MintOptions(val gasLimit: String? = null, val maxFeePerGas: String? = null, val maxPriorityFeePerGas: String? = null) +data class BurnOptions(val recipient: String? = null, val deadline: Long? = null) +data class UnlockOptions(val gasLimit: String? = null, val gasPrice: String? = null) + +data class TransferFilter( + val status: TransferStatus? = null, + val sourceChain: ChainId? = null, + val targetChain: ChainId? = null, + val asset: String? = null, + val sender: String? = null, + val recipient: String? = null, + val limit: Int? = null, + val offset: Int? = null +) + +internal data class ChainsResponse(val chains: List?) +internal data class AssetsResponse(val assets: List?) +internal data class TransfersResponse(val transfers: List?) + +class BridgeException( + message: String, + val code: String? = null, + val statusCode: Int = 0 +) : RuntimeException(message) diff --git a/sdk/kotlin/src/main/kotlin/io/synor/database/SynorDatabase.kt b/sdk/kotlin/src/main/kotlin/io/synor/database/SynorDatabase.kt new file mode 100644 index 0000000..d77232f --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/database/SynorDatabase.kt @@ -0,0 +1,271 @@ +package io.synor.database + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Synor Database SDK for Kotlin. + * Multi-model database with Key-Value, Document, Vector, and Time Series stores. + */ +class SynorDatabase(private val config: DatabaseConfig) : AutoCloseable { + + companion object { + private const val DEFAULT_ENDPOINT = "https://db.synor.io/v1" + private val gson: Gson = GsonBuilder().create() + } + + private val httpClient = HttpClient(CIO) { + engine { + requestTimeout = config.timeoutSecs * 1000 + } + } + private val closed = AtomicBoolean(false) + + val kv = KeyValueStore(this) + val documents = DocumentStore(this) + val vectors = VectorStore(this) + val timeseries = TimeSeriesStore(this) + + constructor(apiKey: String) : this(DatabaseConfig(apiKey)) + + // ==================== Key-Value Store ==================== + + class KeyValueStore internal constructor(private val db: SynorDatabase) { + + suspend fun get(key: String): Any? { + val resp = db.request("GET", "/kv/${key.urlEncode()}") + val map = gson.fromJson>(resp, object : TypeToken>() {}.type) + return map["value"] + } + + suspend fun set(key: String, value: Any, ttl: Int? = null) { + val body = mutableMapOf("key" to key, "value" to value) + ttl?.let { body["ttl"] = it } + db.request("PUT", "/kv/${key.urlEncode()}", body) + } + + suspend fun delete(key: String) { + db.request("DELETE", "/kv/${key.urlEncode()}") + } + + suspend fun list(prefix: String): List { + val resp = db.request("GET", "/kv?prefix=${prefix.urlEncode()}") + val result = gson.fromJson>(resp, object : TypeToken>() {}.type) + return result.items ?: emptyList() + } + } + + // ==================== Document Store ==================== + + class DocumentStore internal constructor(private val db: SynorDatabase) { + + suspend fun create(collection: String, document: Map): String { + val resp = db.request("POST", "/collections/${collection.urlEncode()}/documents", document) + val map = gson.fromJson>(resp, object : TypeToken>() {}.type) + return map["id"] as String + } + + suspend fun get(collection: String, id: String): Document { + val resp = db.request("GET", "/collections/${collection.urlEncode()}/documents/${id.urlEncode()}") + return gson.fromJson(resp, Document::class.java) + } + + suspend fun update(collection: String, id: String, update: Map) { + db.request("PATCH", "/collections/${collection.urlEncode()}/documents/${id.urlEncode()}", update) + } + + suspend fun delete(collection: String, id: String) { + db.request("DELETE", "/collections/${collection.urlEncode()}/documents/${id.urlEncode()}") + } + + suspend fun query(collection: String, query: Query): List { + val resp = db.request("POST", "/collections/${collection.urlEncode()}/query", query) + val result = gson.fromJson(resp, DocumentListResponse::class.java) + return result.documents ?: emptyList() + } + } + + // ==================== Vector Store ==================== + + class VectorStore internal constructor(private val db: SynorDatabase) { + + suspend fun upsert(collection: String, vectors: List) { + db.request("POST", "/vectors/${collection.urlEncode()}/upsert", mapOf("vectors" to vectors)) + } + + suspend fun search(collection: String, vector: DoubleArray, k: Int): List { + val resp = db.request("POST", "/vectors/${collection.urlEncode()}/search", mapOf("vector" to vector, "k" to k)) + val result = gson.fromJson(resp, SearchListResponse::class.java) + return result.results ?: emptyList() + } + + suspend fun delete(collection: String, ids: List) { + db.request("DELETE", "/vectors/${collection.urlEncode()}", mapOf("ids" to ids)) + } + } + + // ==================== Time Series Store ==================== + + class TimeSeriesStore internal constructor(private val db: SynorDatabase) { + + suspend fun write(series: String, points: List) { + db.request("POST", "/timeseries/${series.urlEncode()}/write", mapOf("points" to points)) + } + + suspend fun query(series: String, range: TimeRange, aggregation: Aggregation? = null): List { + val body = mutableMapOf("range" to range) + aggregation?.let { body["aggregation"] = it } + val resp = db.request("POST", "/timeseries/${series.urlEncode()}/query", body) + val result = gson.fromJson(resp, DataPointListResponse::class.java) + return result.points ?: emptyList() + } + } + + // ==================== Lifecycle ==================== + + override fun close() { + closed.set(true) + httpClient.close() + } + + fun isClosed(): Boolean = closed.get() + + suspend fun healthCheck(): Boolean { + return try { + val resp = request("GET", "/health") + val map = gson.fromJson>(resp, object : TypeToken>() {}.type) + map["status"] == "healthy" + } catch (e: Exception) { + false + } + } + + // ==================== Private Methods ==================== + + internal suspend fun request(method: String, path: String, body: Any? = null): String { + if (closed.get()) throw DatabaseException("Client has been closed") + + var lastError: Exception? = null + repeat(config.retries) { attempt -> + try { + return doRequest(method, path, body) + } catch (e: Exception) { + lastError = e + if (attempt < config.retries - 1) { + delay((1L shl attempt) * 1000) + } + } + } + throw lastError ?: DatabaseException("Unknown error") + } + + private suspend fun doRequest(method: String, path: String, body: Any?): String { + val url = "${config.endpoint}$path" + + val response: HttpResponse = httpClient.request(url) { + this.method = HttpMethod.parse(method) + header("Authorization", "Bearer ${config.apiKey}") + header("Content-Type", "application/json") + header("X-SDK-Version", "kotlin/0.1.0") + body?.let { setBody(gson.toJson(it)) } + } + + val responseBody = response.bodyAsText() + + if (response.status.value >= 400) { + val errorMap = try { + gson.fromJson>(responseBody, object : TypeToken>() {}.type) + } catch (e: Exception) { emptyMap() } + + val message = errorMap["message"] as? String ?: "HTTP ${response.status.value}" + throw DatabaseException(message, errorMap["code"] as? String, response.status.value) + } + + return responseBody + } + + private fun String.urlEncode(): String = java.net.URLEncoder.encode(this, "UTF-8") +} + +// ==================== Types ==================== + +data class DatabaseConfig( + val apiKey: String, + val endpoint: String = "https://db.synor.io/v1", + val timeoutSecs: Long = 60, + val retries: Int = 3, + val debug: Boolean = false +) + +data class KeyValue( + val key: String, + val value: Any?, + val ttl: Int? = null, + val createdAt: Long? = null, + val updatedAt: Long? = null +) + +data class Document( + val id: String, + val collection: String, + val data: Map, + val createdAt: Long, + val updatedAt: Long +) + +data class Query( + val filter: Map? = null, + val sort: Map? = null, + val limit: Int? = null, + val offset: Int? = null, + val projection: List? = null +) + +data class VectorEntry( + val id: String, + val vector: DoubleArray, + val metadata: Map? = null +) + +data class SearchResult( + val id: String, + val score: Double, + val vector: DoubleArray? = null, + val metadata: Map? = null +) + +data class DataPoint( + val timestamp: Long, + val value: Double, + val tags: Map? = null +) + +data class TimeRange( + val start: Long, + val end: Long +) + +data class Aggregation( + val function: String, + val interval: String +) + +// Response types +internal data class ListResponse(val items: List?) +internal data class DocumentListResponse(val documents: List?) +internal data class SearchListResponse(val results: List?) +internal data class DataPointListResponse(val points: List?) + +class DatabaseException( + message: String, + val code: String? = null, + val statusCode: Int = 0 +) : RuntimeException(message) diff --git a/sdk/kotlin/src/main/kotlin/io/synor/hosting/SynorHosting.kt b/sdk/kotlin/src/main/kotlin/io/synor/hosting/SynorHosting.kt new file mode 100644 index 0000000..35485c1 --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/hosting/SynorHosting.kt @@ -0,0 +1,384 @@ +package io.synor.hosting + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Synor Hosting SDK for Kotlin. + * Decentralized web hosting with domain management, DNS, and deployments. + */ +class SynorHosting(private val config: HostingConfig) : AutoCloseable { + + companion object { + private val gson: Gson = GsonBuilder().create() + } + + private val httpClient = HttpClient(CIO) { + engine { requestTimeout = config.timeoutSecs * 1000 } + } + private val closed = AtomicBoolean(false) + + constructor(apiKey: String) : this(HostingConfig(apiKey)) + + // ==================== Domain Operations ==================== + + suspend fun checkAvailability(name: String): DomainAvailability { + val resp = request("GET", "/domains/check/${name.urlEncode()}") + return gson.fromJson(resp, DomainAvailability::class.java) + } + + suspend fun registerDomain(name: String, options: RegisterDomainOptions? = null): Domain { + val body = mutableMapOf("name" to name) + options?.years?.let { body["years"] = it } + options?.autoRenew?.let { body["autoRenew"] = it } + val resp = request("POST", "/domains", body) + return gson.fromJson(resp, Domain::class.java) + } + + suspend fun getDomain(name: String): Domain { + val resp = request("GET", "/domains/${name.urlEncode()}") + return gson.fromJson(resp, Domain::class.java) + } + + suspend fun listDomains(): List { + val resp = request("GET", "/domains") + val result = gson.fromJson(resp, DomainsResponse::class.java) + return result.domains ?: emptyList() + } + + suspend fun updateDomainRecord(name: String, record: DomainRecord): Domain { + val resp = request("PUT", "/domains/${name.urlEncode()}/record", record) + return gson.fromJson(resp, Domain::class.java) + } + + suspend fun resolveDomain(name: String): DomainRecord { + val resp = request("GET", "/domains/${name.urlEncode()}/resolve") + return gson.fromJson(resp, DomainRecord::class.java) + } + + suspend fun renewDomain(name: String, years: Int): Domain { + val resp = request("POST", "/domains/${name.urlEncode()}/renew", mapOf("years" to years)) + return gson.fromJson(resp, Domain::class.java) + } + + // ==================== DNS Operations ==================== + + suspend fun getDnsZone(domain: String): DnsZone { + val resp = request("GET", "/dns/${domain.urlEncode()}") + return gson.fromJson(resp, DnsZone::class.java) + } + + suspend fun setDnsRecords(domain: String, records: List): DnsZone { + val resp = request("PUT", "/dns/${domain.urlEncode()}", mapOf("records" to records)) + return gson.fromJson(resp, DnsZone::class.java) + } + + suspend fun addDnsRecord(domain: String, record: DnsRecord): DnsZone { + val resp = request("POST", "/dns/${domain.urlEncode()}/records", record) + return gson.fromJson(resp, DnsZone::class.java) + } + + suspend fun deleteDnsRecord(domain: String, recordType: String, name: String): DnsZone { + val path = "/dns/${domain.urlEncode()}/records/$recordType/${name.urlEncode()}" + val resp = request("DELETE", path) + return gson.fromJson(resp, DnsZone::class.java) + } + + // ==================== Deployment Operations ==================== + + suspend fun deploy(cid: String, options: DeployOptions? = null): Deployment { + val body = mutableMapOf("cid" to cid) + options?.domain?.let { body["domain"] = it } + options?.subdomain?.let { body["subdomain"] = it } + options?.spa?.let { body["spa"] = it } + options?.cleanUrls?.let { body["cleanUrls"] = it } + val resp = request("POST", "/deployments", body) + return gson.fromJson(resp, Deployment::class.java) + } + + suspend fun getDeployment(id: String): Deployment { + val resp = request("GET", "/deployments/${id.urlEncode()}") + return gson.fromJson(resp, Deployment::class.java) + } + + suspend fun listDeployments(domain: String? = null): List { + val path = domain?.let { "/deployments?domain=${it.urlEncode()}" } ?: "/deployments" + val resp = request("GET", path) + val result = gson.fromJson(resp, DeploymentsResponse::class.java) + return result.deployments ?: emptyList() + } + + suspend fun rollback(domain: String, deploymentId: String): Deployment { + val resp = request("POST", "/deployments/${deploymentId.urlEncode()}/rollback", mapOf("domain" to domain)) + return gson.fromJson(resp, Deployment::class.java) + } + + suspend fun deleteDeployment(id: String) { + request("DELETE", "/deployments/${id.urlEncode()}") + } + + // ==================== SSL Operations ==================== + + suspend fun provisionSsl(domain: String, options: ProvisionSslOptions? = null): Certificate { + val resp = request("POST", "/ssl/${domain.urlEncode()}", options) + return gson.fromJson(resp, Certificate::class.java) + } + + suspend fun getCertificate(domain: String): Certificate { + val resp = request("GET", "/ssl/${domain.urlEncode()}") + return gson.fromJson(resp, Certificate::class.java) + } + + suspend fun renewCertificate(domain: String): Certificate { + val resp = request("POST", "/ssl/${domain.urlEncode()}/renew") + return gson.fromJson(resp, Certificate::class.java) + } + + suspend fun deleteCertificate(domain: String) { + request("DELETE", "/ssl/${domain.urlEncode()}") + } + + // ==================== Site Configuration ==================== + + suspend fun getSiteConfig(domain: String): SiteConfig { + val resp = request("GET", "/sites/${domain.urlEncode()}/config") + return gson.fromJson(resp, SiteConfig::class.java) + } + + suspend fun updateSiteConfig(domain: String, config: Map): SiteConfig { + val resp = request("PATCH", "/sites/${domain.urlEncode()}/config", config) + return gson.fromJson(resp, SiteConfig::class.java) + } + + suspend fun purgeCache(domain: String, paths: List? = null): Long { + val body = paths?.let { mapOf("paths" to it) } + val resp = request("DELETE", "/sites/${domain.urlEncode()}/cache", body) + val map = gson.fromJson>(resp, object : TypeToken>() {}.type) + return (map["purged"] as Number).toLong() + } + + // ==================== Analytics ==================== + + suspend fun getAnalytics(domain: String, options: AnalyticsOptions? = null): AnalyticsData { + val params = mutableListOf() + options?.period?.let { params.add("period=${it.urlEncode()}") } + options?.start?.let { params.add("start=${it.urlEncode()}") } + options?.end?.let { params.add("end=${it.urlEncode()}") } + val path = if (params.isNotEmpty()) { + "/sites/${domain.urlEncode()}/analytics?${params.joinToString("&")}" + } else { + "/sites/${domain.urlEncode()}/analytics" + } + val resp = request("GET", path) + return gson.fromJson(resp, AnalyticsData::class.java) + } + + // ==================== Lifecycle ==================== + + override fun close() { + closed.set(true) + httpClient.close() + } + + fun isClosed(): Boolean = closed.get() + + suspend fun healthCheck(): Boolean = try { + val resp = request("GET", "/health") + val map = gson.fromJson>(resp, object : TypeToken>() {}.type) + map["status"] == "healthy" + } catch (e: Exception) { false } + + // ==================== Private Methods ==================== + + private suspend fun request(method: String, path: String, body: Any? = null): String { + if (closed.get()) throw HostingException("Client has been closed") + + var lastError: Exception? = null + repeat(config.retries) { attempt -> + try { + return doRequest(method, path, body) + } catch (e: Exception) { + lastError = e + if (attempt < config.retries - 1) delay((1L shl attempt) * 1000) + } + } + throw lastError ?: HostingException("Unknown error") + } + + private suspend fun doRequest(method: String, path: String, body: Any?): String { + val url = "${config.endpoint}$path" + val response: HttpResponse = httpClient.request(url) { + this.method = HttpMethod.parse(method) + header("Authorization", "Bearer ${config.apiKey}") + header("Content-Type", "application/json") + header("X-SDK-Version", "kotlin/0.1.0") + body?.let { setBody(gson.toJson(it)) } + } + + val responseBody = response.bodyAsText() + if (response.status.value >= 400) { + val errorMap = try { + gson.fromJson>(responseBody, object : TypeToken>() {}.type) + } catch (e: Exception) { emptyMap() } + throw HostingException( + errorMap["message"] as? String ?: "HTTP ${response.status.value}", + errorMap["code"] as? String, + response.status.value + ) + } + return responseBody + } + + private fun String.urlEncode(): String = java.net.URLEncoder.encode(this, "UTF-8") +} + +// ==================== Types ==================== + +data class HostingConfig( + val apiKey: String, + val endpoint: String = "https://hosting.synor.io/v1", + val timeoutSecs: Long = 60, + val retries: Int = 3, + val debug: Boolean = false +) + +enum class DomainStatus { pending, active, expired, suspended } +enum class DeploymentStatus { pending, building, deploying, active, failed, inactive } +enum class CertificateStatus { pending, issued, expired, revoked } +enum class DnsRecordType { A, AAAA, CNAME, TXT, MX, NS, SRV, CAA } + +data class Domain( + val name: String, + val status: DomainStatus, + val owner: String, + val registeredAt: Long, + val expiresAt: Long, + val autoRenew: Boolean, + val records: DomainRecord? = null +) + +data class DomainRecord( + val cid: String? = null, + val ipv4: List? = null, + val ipv6: List? = null, + val cname: String? = null, + val txt: List? = null, + val metadata: Map? = null +) + +data class DomainAvailability( + val name: String, + val available: Boolean, + val price: Double? = null, + val premium: Boolean = false +) + +data class RegisterDomainOptions( + val years: Int? = null, + val autoRenew: Boolean? = null +) + +data class DnsRecord( + val type: DnsRecordType, + val name: String, + val value: String, + val ttl: Int = 3600, + val priority: Int? = null +) + +data class DnsZone( + val domain: String, + val records: List, + val updatedAt: Long +) + +data class Deployment( + val id: String, + val domain: String, + val cid: String, + val status: DeploymentStatus, + val url: String, + val createdAt: Long, + val updatedAt: Long, + val buildLogs: String? = null, + val errorMessage: String? = null +) + +data class DeployOptions( + val domain: String? = null, + val subdomain: String? = null, + val spa: Boolean? = null, + val cleanUrls: Boolean? = null, + val trailingSlash: Boolean? = null +) + +data class Certificate( + val domain: String, + val status: CertificateStatus, + val autoRenew: Boolean, + val issuer: String, + val issuedAt: Long? = null, + val expiresAt: Long? = null, + val fingerprint: String? = null +) + +data class ProvisionSslOptions( + val includeWww: Boolean? = null, + val autoRenew: Boolean? = null +) + +data class SiteConfig( + val domain: String, + val cid: String? = null, + val headers: Map? = null, + val redirects: List? = null, + val errorPages: Map? = null, + val spa: Boolean = false, + val cleanUrls: Boolean = true, + val trailingSlash: Boolean = false +) + +data class RedirectRule( + val source: String, + val destination: String, + val statusCode: Int = 301, + val permanent: Boolean = true +) + +data class AnalyticsData( + val domain: String, + val period: String, + val pageViews: Long, + val uniqueVisitors: Long, + val bandwidth: Long, + val topPages: List, + val topReferrers: List, + val topCountries: List +) + +data class PageView(val path: String, val views: Long) +data class Referrer(val referrer: String, val count: Long) +data class Country(val country: String, val count: Long) + +data class AnalyticsOptions( + val period: String? = null, + val start: String? = null, + val end: String? = null +) + +internal data class DomainsResponse(val domains: List?) +internal data class DeploymentsResponse(val deployments: List?) + +class HostingException( + message: String, + val code: String? = null, + val statusCode: Int = 0 +) : RuntimeException(message)