feat(sdk/kotlin): add Database, Hosting, and Bridge SDKs
- SynorDatabase: Multi-model database with KV, Document, Vector, TimeSeries stores - SynorHosting: Domain management, DNS, deployments, SSL, analytics - SynorBridge: Cross-chain transfers with lock-mint and burn-unlock patterns
This commit is contained in:
parent
dd01a06116
commit
14cd439552
3 changed files with 1124 additions and 0 deletions
469
sdk/kotlin/src/main/kotlin/io/synor/bridge/SynorBridge.kt
Normal file
469
sdk/kotlin/src/main/kotlin/io/synor/bridge/SynorBridge.kt
Normal file
|
|
@ -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<Chain> {
|
||||||
|
val resp = request("GET", "/chains")
|
||||||
|
val result = gson.fromJson<ChainsResponse>(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<Asset> {
|
||||||
|
val resp = request("GET", "/chains/${chainId.name.lowercase()}/assets")
|
||||||
|
val result = gson.fromJson<AssetsResponse>(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<String, Any>(
|
||||||
|
"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<String, Any>("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<String, Any>("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<String, Any>("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<Transfer> {
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
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<TransfersResponse>(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<Map<String, Any>>(resp, object : TypeToken<Map<String, Any>>() {}.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<Map<String, Any>>(responseBody, object : TypeToken<Map<String, Any>>() {}.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<String>,
|
||||||
|
val blockHeader: String,
|
||||||
|
val signatures: List<ValidatorSignature>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
val blockHeader: String,
|
||||||
|
val signatures: List<ValidatorSignature>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<Chain>?)
|
||||||
|
internal data class AssetsResponse(val assets: List<Asset>?)
|
||||||
|
internal data class TransfersResponse(val transfers: List<Transfer>?)
|
||||||
|
|
||||||
|
class BridgeException(
|
||||||
|
message: String,
|
||||||
|
val code: String? = null,
|
||||||
|
val statusCode: Int = 0
|
||||||
|
) : RuntimeException(message)
|
||||||
271
sdk/kotlin/src/main/kotlin/io/synor/database/SynorDatabase.kt
Normal file
271
sdk/kotlin/src/main/kotlin/io/synor/database/SynorDatabase.kt
Normal file
|
|
@ -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<Map<String, Any>>(resp, object : TypeToken<Map<String, Any>>() {}.type)
|
||||||
|
return map["value"]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun set(key: String, value: Any, ttl: Int? = null) {
|
||||||
|
val body = mutableMapOf<String, Any>("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<KeyValue> {
|
||||||
|
val resp = db.request("GET", "/kv?prefix=${prefix.urlEncode()}")
|
||||||
|
val result = gson.fromJson<ListResponse<KeyValue>>(resp, object : TypeToken<ListResponse<KeyValue>>() {}.type)
|
||||||
|
return result.items ?: emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Document Store ====================
|
||||||
|
|
||||||
|
class DocumentStore internal constructor(private val db: SynorDatabase) {
|
||||||
|
|
||||||
|
suspend fun create(collection: String, document: Map<String, Any>): String {
|
||||||
|
val resp = db.request("POST", "/collections/${collection.urlEncode()}/documents", document)
|
||||||
|
val map = gson.fromJson<Map<String, Any>>(resp, object : TypeToken<Map<String, Any>>() {}.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<String, Any>) {
|
||||||
|
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<Document> {
|
||||||
|
val resp = db.request("POST", "/collections/${collection.urlEncode()}/query", query)
|
||||||
|
val result = gson.fromJson<DocumentListResponse>(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<VectorEntry>) {
|
||||||
|
db.request("POST", "/vectors/${collection.urlEncode()}/upsert", mapOf("vectors" to vectors))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun search(collection: String, vector: DoubleArray, k: Int): List<SearchResult> {
|
||||||
|
val resp = db.request("POST", "/vectors/${collection.urlEncode()}/search", mapOf("vector" to vector, "k" to k))
|
||||||
|
val result = gson.fromJson<SearchListResponse>(resp, SearchListResponse::class.java)
|
||||||
|
return result.results ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(collection: String, ids: List<String>) {
|
||||||
|
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<DataPoint>) {
|
||||||
|
db.request("POST", "/timeseries/${series.urlEncode()}/write", mapOf("points" to points))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun query(series: String, range: TimeRange, aggregation: Aggregation? = null): List<DataPoint> {
|
||||||
|
val body = mutableMapOf<String, Any>("range" to range)
|
||||||
|
aggregation?.let { body["aggregation"] = it }
|
||||||
|
val resp = db.request("POST", "/timeseries/${series.urlEncode()}/query", body)
|
||||||
|
val result = gson.fromJson<DataPointListResponse>(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<Map<String, Any>>(resp, object : TypeToken<Map<String, Any>>() {}.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<Map<String, Any>>(responseBody, object : TypeToken<Map<String, Any>>() {}.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<String, Any>,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Query(
|
||||||
|
val filter: Map<String, Any>? = null,
|
||||||
|
val sort: Map<String, Int>? = null,
|
||||||
|
val limit: Int? = null,
|
||||||
|
val offset: Int? = null,
|
||||||
|
val projection: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class VectorEntry(
|
||||||
|
val id: String,
|
||||||
|
val vector: DoubleArray,
|
||||||
|
val metadata: Map<String, Any>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchResult(
|
||||||
|
val id: String,
|
||||||
|
val score: Double,
|
||||||
|
val vector: DoubleArray? = null,
|
||||||
|
val metadata: Map<String, Any>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DataPoint(
|
||||||
|
val timestamp: Long,
|
||||||
|
val value: Double,
|
||||||
|
val tags: Map<String, String>? = 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<T>(val items: List<T>?)
|
||||||
|
internal data class DocumentListResponse(val documents: List<Document>?)
|
||||||
|
internal data class SearchListResponse(val results: List<SearchResult>?)
|
||||||
|
internal data class DataPointListResponse(val points: List<DataPoint>?)
|
||||||
|
|
||||||
|
class DatabaseException(
|
||||||
|
message: String,
|
||||||
|
val code: String? = null,
|
||||||
|
val statusCode: Int = 0
|
||||||
|
) : RuntimeException(message)
|
||||||
384
sdk/kotlin/src/main/kotlin/io/synor/hosting/SynorHosting.kt
Normal file
384
sdk/kotlin/src/main/kotlin/io/synor/hosting/SynorHosting.kt
Normal file
|
|
@ -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<String, Any>("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<Domain> {
|
||||||
|
val resp = request("GET", "/domains")
|
||||||
|
val result = gson.fromJson<DomainsResponse>(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<DnsRecord>): 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<String, Any>("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<Deployment> {
|
||||||
|
val path = domain?.let { "/deployments?domain=${it.urlEncode()}" } ?: "/deployments"
|
||||||
|
val resp = request("GET", path)
|
||||||
|
val result = gson.fromJson<DeploymentsResponse>(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<String, Any>): SiteConfig {
|
||||||
|
val resp = request("PATCH", "/sites/${domain.urlEncode()}/config", config)
|
||||||
|
return gson.fromJson(resp, SiteConfig::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun purgeCache(domain: String, paths: List<String>? = null): Long {
|
||||||
|
val body = paths?.let { mapOf("paths" to it) }
|
||||||
|
val resp = request("DELETE", "/sites/${domain.urlEncode()}/cache", body)
|
||||||
|
val map = gson.fromJson<Map<String, Any>>(resp, object : TypeToken<Map<String, Any>>() {}.type)
|
||||||
|
return (map["purged"] as Number).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics ====================
|
||||||
|
|
||||||
|
suspend fun getAnalytics(domain: String, options: AnalyticsOptions? = null): AnalyticsData {
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
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<Map<String, Any>>(resp, object : TypeToken<Map<String, Any>>() {}.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<Map<String, Any>>(responseBody, object : TypeToken<Map<String, Any>>() {}.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<String>? = null,
|
||||||
|
val ipv6: List<String>? = null,
|
||||||
|
val cname: String? = null,
|
||||||
|
val txt: List<String>? = null,
|
||||||
|
val metadata: Map<String, String>? = 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<DnsRecord>,
|
||||||
|
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<String, String>? = null,
|
||||||
|
val redirects: List<RedirectRule>? = null,
|
||||||
|
val errorPages: Map<String, String>? = 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<PageView>,
|
||||||
|
val topReferrers: List<Referrer>,
|
||||||
|
val topCountries: List<Country>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<Domain>?)
|
||||||
|
internal data class DeploymentsResponse(val deployments: List<Deployment>?)
|
||||||
|
|
||||||
|
class HostingException(
|
||||||
|
message: String,
|
||||||
|
val code: String? = null,
|
||||||
|
val statusCode: Int = 0
|
||||||
|
) : RuntimeException(message)
|
||||||
Loading…
Add table
Reference in a new issue