/* * ROKI Connect - Kotlin client for Android and the JVM. * * Generated from openapi.yaml v2.0.0. Do not edit by hand: run sdk/generate.mjs. * * Kotlin 1.8+, JDK 8+ or Android minSdk 21, and exactly one library: * org.jetbrains.kotlinx:kotlinx-coroutines-core (plus kotlinx-coroutines-android on Android). * Everything else is the platform - HttpURLConnection for HTTP, javax.crypto for the webhook * signature, and the small JSON reader at the bottom of this file. Coroutines are the one * exception on purpose: suspend is a language feature but Dispatchers.IO is not, and a suspend * function that blocked its caller's thread would freeze an Android main thread. Every Android or * Ktor project already has that artifact on the classpath. * * On Android you also need the INTERNET permission in the manifest. * * val roki = RokiConnect(System.getenv("ROKI_SECRET_KEY")) * * val payment = roki.createPayment( * "amount" to BigDecimal("1500.00"), // decimal units - L 1,500.00, never cents * "external_reference" to order.id.toString(), * "name" to "Order #" + order.id, * ) * call.respondRedirect(payment.string("checkout_url")!!) * * SECURITY, before this file goes into an Android module: it is a client of a secret-key API, not * a card terminal. It never touches a card number - card entry always happens on ROKI's own * surfaces - and it charges nothing on the device. An sk_live_ key inside an APK is a published * secret, because anyone can unzip an APK and read it. On a phone, call your own backend and let * the backend hold the key and this file; the constructor refuses a live key on a device for that * reason. * * The environment is the key: sk_test_ is sandbox, sk_live_ is production. Same routes. */ package la.roki.connect import java.io.IOException import java.math.BigDecimal import java.net.HttpURLConnection import java.net.URI import java.net.URLEncoder import java.security.MessageDigest import java.util.UUID import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import kotlin.coroutines.cancellation.CancellationException import kotlin.math.abs import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext /** * Every failure this client raises: a request ROKI rejected, a check that ran before the request * left, or a network that never answered. * * @param status the HTTP status, or 0 when there was no answer to read a status from. * @param response the decoded body, when the failure came back with one. */ class RokiConnectException( message: String, val status: Int = 0, val response: RokiJson? = null, cause: Throwable? = null, ) : Exception(message, cause) { /** * Field-level validation errors, when the API sent them. The keys are stable and are what * integration logic should branch on; the message text is localised by Accept-Language. */ val validationErrors: Map> get() { val errors = response?.get("errors") as? Map<*, *> ?: return emptyMap() return errors.entries.associate { (key, value) -> key.toString() to when (value) { null -> emptyList() is List<*> -> value.map { it.toString() } else -> listOf(value.toString()) } } } /** * True when the request never reached ROKI. This is not the same as a failed payment: the * payment may well exist. Retry with the SAME Idempotency-Key, or look it up by * external_reference before creating another one. */ val isNetworkError: Boolean get() = status == 0 && cause != null } /** * A decoded JSON object. * * It IS a Map - index it, iterate it, destructure it - with typed accessors on top. * There is no generated data class per endpoint on purpose: this API adds fields without warning * (the warnings array itself arrived that way) and a strict model would drop them in silence. * * Values inside are String, Long, Double, Boolean, null, List or Map. */ class RokiJson internal constructor(private val fields: Map) : Map by fields { fun string(key: String): String? = fields[key]?.toString() fun long(key: String): Long? = when (val value = fields[key]) { is Number -> value.toLong() is String -> value.toLongOrNull() else -> null } /** Money. BigDecimal and not Double, because this is an amount somebody is charged. */ fun decimal(key: String): BigDecimal? = when (val value = fields[key]) { is BigDecimal -> value is Number -> BigDecimal(value.toString()) is String -> value.toBigDecimalOrNull() else -> null } fun boolean(key: String): Boolean? = when (val value = fields[key]) { is Boolean -> value is String -> value.toBooleanStrictOrNull() else -> null } fun obj(key: String): RokiJson? = asJson(fields[key]) fun list(key: String): List = fields[key] as? List<*> ?: emptyList() /** The objects of an array field - data on the list endpoints. */ fun objects(key: String): List = (fields[key] as? List<*>)?.mapNotNull { asJson(it) } ?: emptyList() /** * The fields ROKI ignored, one string per field, empty when the request was clean. * * The text is localised by Accept-Language, so branch on this being non-empty and never on * its wording. */ val warnings: List get() = (fields["warnings"] as? List<*>)?.mapNotNull { it?.toString() } ?: emptyList() override fun toString(): String = Json.encode(fields) } /** * ROKI Connect. * * One instance per process is enough: it keeps no connection state, and every call is a suspend * function that confines its blocking IO to Dispatchers.IO, so calling from the main dispatcher * is safe. * * @param secretKey sk_test_ for sandbox, sk_live_ for production, over identical routes. Read it * from the environment or a secrets manager - a key in source control is a key that has leaked. * @param baseUrl only for a proxy or a mock; the environment comes from the key, not from here. * @param timeout applied to the connect phase (capped at 10s) and to the read phase. * @param strict reject unknown fields before the request leaves this process. Leave it on: ROKI * answers 201 to a misspelled field and ignores it, so a typo becomes a silent production bug * instead of a local error. * @param language Accept-Language, es or en. It localises message text only, never the error keys * you branch on. * @param allowSecretKeyOnDevice let a live key run inside an Android process. It is refused by * default because an sk_live_ key shipped in an APK is a key you have published. * @param onWarnings called with (warnings, method, path) whenever ROKI reports it ignored a field. * Defaults to System.err. With strict on, this firing means the spec this file was generated * from is behind the live API - regenerate. */ class RokiConnect( private val secretKey: String, baseUrl: String = DEFAULT_BASE_URL, private val timeout: Duration = 30.seconds, private val strict: Boolean = true, private val language: String = "en", allowSecretKeyOnDevice: Boolean = false, private val onWarnings: (warnings: List, method: String, path: String) -> Unit = { warnings, method, path -> System.err.println("ROKI " + method + " " + path + " ignored fields: " + warnings.joinToString(" | ")) }, ) { private val base: String = baseUrl.trimEnd('/') init { if (secretKey.isBlank()) { throw RokiConnectException("Missing secret key. Read it from the environment, never hard-code it.") } if (secretKey.startsWith("pk_")) { throw RokiConnectException("That is a publishable key. Server calls need the sk_ secret key.") } if (ON_DEVICE && secretKey.startsWith("sk_live_") && !allowSecretKeyOnDevice) { throw RokiConnectException( "A live secret key is being used inside an Android process. Anyone can unzip an APK " + "and read it, so that key is public the day you ship. Call your own backend from " + "the app and keep sk_live_ there. Pass allowSecretKeyOnDevice = true only for a " + "throwaway build you will never publish." ) } } /** True when this client is talking to the sandbox. */ val isSandbox: Boolean get() = secretKey.startsWith("sk_test_") /** * Which merchant does this key belong to */ suspend fun getMerchant(): RokiJson { return request( method = "GET", path = "/me", body = null, query = emptyMap(), idempotencyKey = null, baseOverride = null, ) } /** * List payments * * Query keys accepted here: per_page, page, status, external_reference, from, to. Empty and null * values are dropped. * * @param query filters, urlencoded in the order given. */ suspend fun listPayments(query: Map = emptyMap()): RokiJson { return request( method = "GET", path = "/payments", body = null, query = query, idempotencyKey = null, baseOverride = null, ) } /** * The same call spelled with pairs, which is how it reads in Kotlin: * * roki.listPayments("per_page" to 20) * * @see listPayments */ suspend fun listPayments( filter: Pair, vararg more: Pair, ): RokiJson = listPayments(mapOf(filter, *more)) /** * Create a payment * * Amounts are in DECIMAL UNITS: 150.50 means L 150.50. This API has no cents field anywhere, so * the conversion a Stripe integration does out of habit - multiplying by 100 - charges the * customer one hundred times too much, and ROKI accepts it without complaining. Send a BigDecimal * when the money matters: it is encoded exactly, a Double is not. * * An Idempotency-Key is generated for you when you pass none, so a retried request cannot become a * second payment. After a timeout, retry with the SAME key. * * Verified deviation from the industry standard: replaying a key with a DIFFERENT body returns the * original payment and no error. Derive the key from what the order contains, not only from its * id. * * Fields accepted here: amount, external_reference, name, currency_code, description, metadata, * success_url, cancel_url, expires_at, customer, lock_customer_fields, reusable, sales_tax_type, * sales_tax_value, tip_enabled, tip_type, tip_value, tip_customer_selectable, * tip_preset_percentages, tip_allow_custom, tip_min_amount, tip_max_amount, service_fee_enabled. * Required: amount, external_reference, name. Anything else is refused before the request leaves * this process, because ROKI would answer 201 and drop it in silence. * * @param body the request body, checked against the fields listed above. * @param idempotencyKey your own key, or null to have one generated. */ suspend fun createPayment( body: Map, idempotencyKey: String? = null, ): RokiJson { guard(body, CREATE_PAYMENT_FIELDS) return request( method = "POST", path = "/payments", body = body, query = emptyMap(), idempotencyKey = idempotencyKey ?: newIdempotencyKey(), baseOverride = null, ) } /** * The same call spelled with pairs, which is how it reads in Kotlin: * * roki.createPayment( * "amount" to 1500.00, * "external_reference" to "order-1001", * "name" to "Order #1001", * ) * * Anything after the vararg has to be named, so pass idempotencyKey = "..." when you bring your * own. * * @see createPayment */ suspend fun createPayment( field: Pair, vararg more: Pair, idempotencyKey: String? = null, ): RokiJson = createPayment(mapOf(field, *more), idempotencyKey = idempotencyKey) /** * Retrieve a payment * * @param id the numeric payment id returned when the payment was created. Void, refund and * receipts do not take this one - they take transaction_id. */ suspend fun getPayment(id: Long): RokiJson { val idPath = encodeSegment(id.toString()) return request( method = "GET", path = "/payments/$idPath", body = null, query = emptyMap(), idempotencyKey = null, baseOverride = null, ) } /** * Void a transaction * * Fields accepted here: reason. Anything else is refused before the request leaves this process, * because ROKI would answer 201 and drop it in silence. * * @param transactionId the transaction UUID carried by the payment (transaction_id, null until * the payment is actually charged) - NOT the numeric payment id. The numeric id answers 404 * with code="transaction_id_expected": the endpoint exists and rejected the identifier. Branch * on `code`, never on the message text, which is localized by Accept-Language. * @param body the request body, checked against the fields listed above. */ suspend fun voidTransaction( transactionId: String, body: Map = emptyMap(), ): RokiJson { val transactionIdPath = encodeSegment(transactionId) guard(body, VOID_TRANSACTION_FIELDS) return request( method = "POST", path = "/payments/$transactionIdPath/void", body = body, query = emptyMap(), idempotencyKey = null, baseOverride = null, ) } /** * The same call spelled with pairs, which is how it reads in Kotlin: * * roki.voidTransaction(transactionId, "reason" to "Customer cancelled") * * @see voidTransaction */ suspend fun voidTransaction( transactionId: String, field: Pair, vararg more: Pair, ): RokiJson = voidTransaction(transactionId, mapOf(field, *more)) /** * Refund a transaction * * Amounts are in DECIMAL UNITS: 150.50 means L 150.50. This API has no cents field anywhere, so * the conversion a Stripe integration does out of habit - multiplying by 100 - charges the * customer one hundred times too much, and ROKI accepts it without complaining. Send a BigDecimal * when the money matters: it is encoded exactly, a Double is not. * * Fields accepted here: amount, reason. Required: amount. Anything else is refused before the * request leaves this process, because ROKI would answer 201 and drop it in silence. * * @param transactionId the transaction UUID carried by the payment (transaction_id, null until * the payment is actually charged) - NOT the numeric payment id. The numeric id answers 404 * with code="transaction_id_expected": the endpoint exists and rejected the identifier. Branch * on `code`, never on the message text, which is localized by Accept-Language. * @param body the request body, checked against the fields listed above. */ suspend fun refundTransaction( transactionId: String, body: Map, ): RokiJson { val transactionIdPath = encodeSegment(transactionId) guard(body, REFUND_TRANSACTION_FIELDS) return request( method = "POST", path = "/payments/$transactionIdPath/refund", body = body, query = emptyMap(), idempotencyKey = null, baseOverride = null, ) } /** * The same call spelled with pairs, which is how it reads in Kotlin: * * roki.refundTransaction(transactionId, "amount" to 1500.00) * * @see refundTransaction */ suspend fun refundTransaction( transactionId: String, field: Pair, vararg more: Pair, ): RokiJson = refundTransaction(transactionId, mapOf(field, *more)) /** * Get receipt metadata * * @param transactionId the transaction UUID carried by the payment (transaction_id, null until * the payment is actually charged) - NOT the numeric payment id. The numeric id answers 404 * with code="transaction_id_expected": the endpoint exists and rejected the identifier. Branch * on `code`, never on the message text, which is localized by Accept-Language. */ suspend fun getReceipt(transactionId: String): RokiJson { val transactionIdPath = encodeSegment(transactionId) return request( method = "GET", path = "/payments/$transactionIdPath/receipt", body = null, query = emptyMap(), idempotencyKey = null, baseOverride = null, ) } /** * Download the receipt PDF * * This route answers with application/pdf, not JSON, so it hands back the bytes. Write them with * File.writeBytes, or show them in a PdfRenderer. The human-readable page is receipt_url, from * getReceipt. * * @param transactionId the transaction UUID carried by the payment (transaction_id, null until * the payment is actually charged) - NOT the numeric payment id. The numeric id answers 404 * with code="transaction_id_expected": the endpoint exists and rejected the identifier. Branch * on `code`, never on the message text, which is localized by Accept-Language. */ suspend fun downloadReceipt(transactionId: String): ByteArray { val transactionIdPath = encodeSegment(transactionId) return requestBytes( method = "GET", path = "/payments/$transactionIdPath/receipt/download", body = null, query = emptyMap(), idempotencyKey = null, baseOverride = null, ) } /** * List a customer's saved cards * * Query keys accepted here: customer[identity_number], customer[email]. Empty and null values are * dropped. * * @param query filters, urlencoded in the order given. */ suspend fun listPaymentMethods(query: Map = emptyMap()): RokiJson { return request( method = "GET", path = "/payment-methods", body = null, query = query, idempotencyKey = null, baseOverride = null, ) } /** * The same call spelled with pairs, which is how it reads in Kotlin: * * roki.listPaymentMethods("customer[identity_number]" to "0801199012345") * * @see listPaymentMethods */ suspend fun listPaymentMethods( filter: Pair, vararg more: Pair, ): RokiJson = listPaymentMethods(mapOf(filter, *more)) /** * Revoke a saved card * * @param paymentMethodId the opaque saved-card reference, prefixed pm_. It is not a card brand * and never a card number. */ suspend fun revokePaymentMethod(paymentMethodId: String): RokiJson { val paymentMethodIdPath = encodeSegment(paymentMethodId) return request( method = "DELETE", path = "/payment-methods/$paymentMethodIdPath", body = null, query = emptyMap(), idempotencyKey = null, baseOverride = null, ) } /** * Charge a saved card * * Amounts are in DECIMAL UNITS: 150.50 means L 150.50. This API has no cents field anywhere, so * the conversion a Stripe integration does out of habit - multiplying by 100 - charges the * customer one hundred times too much, and ROKI accepts it without complaining. Send a BigDecimal * when the money matters: it is encoded exactly, a Double is not. * * Idempotency-Key is MANDATORY on this endpoint; one is generated for you when you pass none. * After a timeout, retry with the SAME key - that is the whole reason a retry cannot charge twice. * * Verified deviation from the industry standard: replaying a key with a DIFFERENT body returns the * original payment and no error. Derive the key from what the order contains, not only from its * id. * * Fields accepted here: amount, currency_code, external_reference, metadata. Required: amount. * Anything else is refused before the request leaves this process, because ROKI would answer 201 * and drop it in silence. * * @param paymentMethodId the opaque saved-card reference, prefixed pm_. It is not a card brand * and never a card number. * @param body the request body, checked against the fields listed above. * @param idempotencyKey your own key, or null to have one generated. */ suspend fun chargeSavedCard( paymentMethodId: String, body: Map, idempotencyKey: String? = null, ): RokiJson { val paymentMethodIdPath = encodeSegment(paymentMethodId) guard(body, CHARGE_SAVED_CARD_FIELDS) return request( method = "POST", path = "/payment-methods/$paymentMethodIdPath/charge", body = body, query = emptyMap(), idempotencyKey = idempotencyKey ?: newIdempotencyKey(), baseOverride = null, ) } /** * The same call spelled with pairs, which is how it reads in Kotlin: * * roki.chargeSavedCard(paymentMethodId, "amount" to 1500.00) * * Anything after the vararg has to be named, so pass idempotencyKey = "..." when you bring your * own. * * @see chargeSavedCard */ suspend fun chargeSavedCard( paymentMethodId: String, field: Pair, vararg more: Pair, idempotencyKey: String? = null, ): RokiJson = chargeSavedCard(paymentMethodId, mapOf(field, *more), idempotencyKey = idempotencyKey) /** * Charge a saved card (alias) * * Amounts are in DECIMAL UNITS: 150.50 means L 150.50. This API has no cents field anywhere, so * the conversion a Stripe integration does out of habit - multiplying by 100 - charges the * customer one hundred times too much, and ROKI accepts it without complaining. Send a BigDecimal * when the money matters: it is encoded exactly, a Double is not. * * Idempotency-Key is MANDATORY on this endpoint; one is generated for you when you pass none. * After a timeout, retry with the SAME key - that is the whole reason a retry cannot charge twice. * * Verified deviation from the industry standard: replaying a key with a DIFFERENT body returns the * original payment and no error. Derive the key from what the order contains, not only from its * id. * * Fields accepted here: payment_token, amount, currency_code, external_reference, metadata. * Required: payment_token, amount. Anything else is refused before the request leaves this * process, because ROKI would answer 201 and drop it in silence. * * @param body the request body, checked against the fields listed above. * @param idempotencyKey your own key, or null to have one generated. */ suspend fun tokenCharge( body: Map, idempotencyKey: String? = null, ): RokiJson { guard(body, TOKEN_CHARGE_FIELDS) return request( method = "POST", path = "/payments/token-charge", body = body, query = emptyMap(), idempotencyKey = idempotencyKey ?: newIdempotencyKey(), baseOverride = null, ) } /** * The same call spelled with pairs, which is how it reads in Kotlin: * * roki.tokenCharge( * "payment_token" to "pm_7k2n9xqf31ab", * "amount" to 1500.00, * ) * * Anything after the vararg has to be named, so pass idempotencyKey = "..." when you bring your * own. * * @see tokenCharge */ suspend fun tokenCharge( field: Pair, vararg more: Pair, idempotencyKey: String? = null, ): RokiJson = tokenCharge(mapOf(field, *more), idempotencyKey = idempotencyKey) /** * Confirm an embedded-components payment * * Amounts are in DECIMAL UNITS: 150.50 means L 150.50. This API has no cents field anywhere, so * the conversion a Stripe integration does out of habit - multiplying by 100 - charges the * customer one hundred times too much, and ROKI accepts it without complaining. Send a BigDecimal * when the money matters: it is encoded exactly, a Double is not. * * Fields accepted here: amount, currency_code, payment_token, publishable_key, external_reference, * success_redirect_url, failed_redirect_url, description, metadata. Required: amount, * currency_code, payment_token, publishable_key. Anything else is refused before the request * leaves this process, because ROKI would answer 201 and drop it in silence. * * @param body the request body, checked against the fields listed above. */ suspend fun confirmEmbeddedPayment(body: Map): RokiJson { guard(body, CONFIRM_EMBEDDED_PAYMENT_FIELDS) return request( method = "POST", path = "/confirm", body = body, query = emptyMap(), idempotencyKey = null, baseOverride = "https://aura.roki.systems/api/connect/embed", ) } /** * The same call spelled with pairs, which is how it reads in Kotlin: * * roki.confirmEmbeddedPayment( * "amount" to 1500.00, * "currency_code" to "HNL", * "payment_token" to "tok_xxxx", * "publishable_key" to "pk_test_xxxxxxxx", * ) * * @see confirmEmbeddedPayment */ suspend fun confirmEmbeddedPayment( field: Pair, vararg more: Pair, ): RokiJson = confirmEmbeddedPayment(mapOf(field, *more)) /** A key stable enough that a retry is the same payment, unique enough that two are not. */ fun newIdempotencyKey(): String = UUID.randomUUID().toString() // ---------------------------------------------------------------- internals private fun guard(body: Map, fields: Fields) { val operation = fields.operation val missing = fields.required.filterNot { it in body } if (missing.isNotEmpty()) { throw RokiConnectException(operation + ": missing required field(s): " + missing.joinToString(", ")) } if (!strict || fields.known.isEmpty()) return for (field in body.keys) { if (field in fields.known) continue val alias = FIELD_ALIASES[field] val hint = closest(field, fields.known) val message = StringBuilder( "$operation: \"$field\" is not a field of this API, and ROKI would accept the request and ignore it." ) if (alias != null) message.append(" Coming from another gateway? ").append(alias) else if (hint != null) message.append(" Did you mean \"").append(hint).append("\"?") throw RokiConnectException(message.toString()) } } /** * The transport, with no opinion about what came back. * * Split out of request() because not every route answers JSON: the receipt download answers * application/pdf, and this used to turn the bytes into a String and refuse them, on a route * that was working perfectly. */ private suspend fun perform( method: String, path: String, body: Map?, query: Map, idempotencyKey: String?, baseOverride: String?, ): Pair = withContext(Dispatchers.IO) { val url = buildUrl(baseOverride?.trimEnd('/') ?: base, path, query) val payload = body?.let { try { Json.encode(it).toByteArray(Charsets.UTF_8) } catch (e: IllegalArgumentException) { throw RokiConnectException(method + " " + path + ": " + e.message, cause = e) } } // URI(...).toURL() y no URL(String): ese constructor esta deprecado desde Java 20 y en un // proyecto con -Werror - que es lo normal en Android - el SDK no compilaria. val connection = URI(url).toURL().openConnection() as HttpURLConnection // A blocked socket never hears that the coroutine died. Without this, a cancelled scope - // a closed screen, a client that hung up - leaves an IO thread parked until the read // timeout expires. val onCancel = coroutineContext[Job]?.invokeOnCompletion { cause -> if (cause is CancellationException) runCatching { connection.disconnect() } } try { connection.requestMethod = method connection.connectTimeout = minOf(10.seconds, timeout).inWholeMilliseconds.toInt() connection.readTimeout = timeout.inWholeMilliseconds.toInt() // A 301 on a POST is replayed by HttpURLConnection as a GET with no body, which would // look like a silent no-op. Better to fail where it can be seen. connection.instanceFollowRedirects = false connection.setRequestProperty("Authorization", "Bearer $secretKey") connection.setRequestProperty("Accept", "application/json") connection.setRequestProperty("Accept-Language", language) connection.setRequestProperty("User-Agent", USER_AGENT) if (idempotencyKey != null) connection.setRequestProperty("Idempotency-Key", idempotencyKey) if (payload != null) { connection.setRequestProperty("Content-Type", "application/json; charset=utf-8") connection.doOutput = true connection.setFixedLengthStreamingMode(payload.size) } try { if (payload != null) connection.outputStream.use { it.write(payload) } val code = connection.responseCode val stream = if (code >= 400) connection.errorStream else connection.inputStream code to (stream?.use { it.readBytes() } ?: ByteArray(0)) } catch (e: IOException) { // Release the socket: nothing was read, so nothing returns it to the pool. connection.disconnect() // A timeout is not a failed payment - the payment may exist. Retry with the SAME // Idempotency-Key, or look it up by external_reference before creating another. throw RokiConnectException( "Network error calling " + method + " " + path + ": " + e.message, cause = e, ) } } finally { onCancel?.dispose() } } private suspend fun request( method: String, path: String, body: Map?, query: Map, idempotencyKey: String?, baseOverride: String?, ): RokiJson { val (status, bytes) = perform(method, path, body, query, idempotencyKey, baseOverride) val text = bytes.toString(Charsets.UTF_8) if (text.startsWith("%PDF")) { // Not the receipt: that route declares application/pdf and has its own method that // returns a ByteArray. A PDF here means the contract this file was generated from is // behind the live API, and saying so beats a generic parse failure. throw RokiConnectException( method + " " + path + " answered with a PDF on a route the contract says is JSON. " + "Regenerate this client from the current openapi.yaml.", status, ) } // Revoking a payment method answers 200 with no body at all. if (text.isBlank()) { if (status >= 400) { throw RokiConnectException( "ROKI " + method + " " + path + " failed with " + status + " and an empty body", status, ) } return RokiJson(emptyMap()) } val decoded = asJson(runCatching { Json.parse(text) }.getOrNull()) ?: throw RokiConnectException( "Non-JSON response from " + method + " " + path + " (HTTP " + status + ")", status, ) if (status >= 400) throw errorFor(status, decoded, method, path) // The API names the fields it ignored. Absent when the request was clean, so a quiet // log is the proof your field names are right. val warnings = decoded.warnings if (warnings.isNotEmpty()) onWarnings(warnings, method, path) return decoded } /** * The exact bytes of a route the contract declares is not JSON. * * Failures on those routes still arrive as JSON - a 404 for passing the numeric payment id * instead of the transaction_id UUID is the usual one - so they get decoded and reported like * every other call. Only the success path hands back bytes. */ private suspend fun requestBytes( method: String, path: String, body: Map?, query: Map, idempotencyKey: String?, baseOverride: String?, ): ByteArray { val (status, bytes) = perform(method, path, body, query, idempotencyKey, baseOverride) if (status < 400) return bytes val decoded = asJson(runCatching { Json.parse(bytes.toString(Charsets.UTF_8)) }.getOrNull()) ?: throw RokiConnectException( "ROKI " + method + " " + path + " failed with " + status + " and a body that is not JSON", status, ) throw errorFor(status, decoded, method, path) } private fun buildUrl(base: String, path: String, query: Map): String { val pairs = query.entries .filter { (_, value) -> value != null && value.toString().isNotEmpty() } .joinToString("&") { (key, value) -> encodeQuery(key) + "=" + encodeQuery(value.toString()) } return if (pairs.isEmpty()) base + path else base + path + "?" + pairs } private fun errorFor(status: Int, json: RokiJson, method: String, path: String): RokiConnectException { val detail = StringBuilder( json.string("message") ?: json.string("error") ?: "request failed" ) val errors = json["errors"] as? Map<*, *> if (status == 422 && !errors.isNullOrEmpty()) { detail.append( errors.entries.joinToString("; ", prefix = " (", postfix = ")") { (field, messages) -> val text = if (messages is List<*>) messages.joinToString(" ") else messages.toString() field.toString() + ": " + text } ) } if (status == 404) { detail.append( ". A 404 here usually means the identifier is of the wrong kind: void, refund and " + "receipts take the transaction_id UUID, not the numeric payment id." ) } if (status == 401) { detail.append( ". Check the key prefix: sk_test_ only works against sandbox data, sk_live_ only " + "against production." ) } val message = "ROKI " + method + " " + path + " failed with " + status + ": " + detail return RokiConnectException(message, status, json) } companion object { /** The openapi.yaml version this file was generated from. */ const val SPEC_VERSION: String = "2.0.0" /** * This client's own version. Plain semver, so Gradle and any range check order it. * * The build fingerprint is kept out of it on purpose: glued to the version it reads as an * unknown prerelease to a semver comparator - lower than the bare version - which is how * the PHP twin of this file started reporting itself as out of date while it was current. */ const val SDK_VERSION: String = "2.0.0" /** * Fingerprint of the contract, the alias map and the templates this file came out of. Not a * version and never compared as one: it only tells two copies apart. */ const val BUILD: String = "12e0e82" const val DEFAULT_BASE_URL: String = "https://aura.roki.systems/api/connect/v1" private const val USER_AGENT: String = "roki-connect-kotlin/" + SDK_VERSION + "+build." + BUILD /** ART still reports the Dalvik VM name, so this holds from Android 5 to Android 15. */ private val ON_DEVICE: Boolean = System.getProperty("java.vm.name")?.contains("Dalvik", ignoreCase = true) == true private val SIGNATURE = Regex("""t=(\d+)\s*,\s*v1=([a-f0-9]{64})""", RegexOption.IGNORE_CASE) /** Field names from other gateways, mapped to what ROKI actually calls them. */ val FIELD_ALIASES: Map = mapOf( "amount" to "In Stripe this is \"amount\"; in ROKI it is \"amount\". Same name, different unit: ROKI's amount is decimal units (1500.00 = L 1,500.00, minimum 0.01), so divide Stripe's integer by 100 before sending, and enforce your own ceiling because ROKI enforces no maximum and would silently create a...", "amount_cents" to "In Stripe this is \"amount_cents\"; in ROKI it is \"amount\". ROKI has no cents field anywhere: convert to decimal units and send amount; this is one of the few wrong names that fails loudly rather than silently, returning 422 'El campo amount es obligatorio.' because amount is required.", "currency" to "In Stripe this is \"currency\"; in ROKI it is \"currency_code\". POST /payments wants the ISO 4217 *numeric* code as a string ('340' = HNL), not the alphabetic one; the alphabetic form ('HNL') is accepted only on /payments/token-charge, /payment-methods/{id}/charge and /confirm, and the response echoes...", "payment_method_types" to "Stripe has \"payment_method_types\", ROKI has no equivalent. ROKI is card-only and the integration mode is chosen by which endpoint you call - POST /payments for hosted checkout, POST /confirm for embedded card fields, POST /payment-methods/{id}/charge for a saved card - so drop the field entirely,...", "success_url" to "In Stripe this is \"success_url\"; in ROKI it is \"success_url\". Identical name on POST /payments, but the embedded-components endpoint POST /confirm calls it success_redirect_url; only http/https is accepted (custom app schemes like myapp:// return 422), and a customer landing there is never proof of...", "cancel_url" to "In Stripe this is \"cancel_url\"; in ROKI it is \"cancel_url\". Same name on POST /payments; the embedded-components equivalent is failed_redirect_url on POST /confirm, which means 'the charge failed', not 'the customer backed out', so the two are not interchangeable.", "customer_email" to "In Stripe this is \"customer_email\"; in ROKI it is \"customer.email\". In ROKI it lives inside the optional customer prefill object (customer.email, alongside name, phone, identity_number), it creates no customer record, it stays editable unless you also send lock_customer_fields: true, and it is echoed back...", "metadata" to "In Stripe and Mercado Pago this is \"metadata\"; in ROKI it is \"metadata\". Same name and same purpose - returned untouched on retrieval and in every webhook, never shown to the customer - but it must be a JSON object or you get 422 ('El campo metadata debe ser un array.'), and when you send none ROKI may return...", "client_reference_id" to "In Stripe this is \"client_reference_id\"; in ROKI it is \"external_reference\". ROKI's external_reference is required (max 191) and, despite the name, is NOT unique - two creates with the same value produce two separately payable links - so duplicate protection has to come from Idempotency-Key; you can filter by it...", "capture_method" to "Stripe has \"capture_method\", ROKI has no equivalent. ROKI has no authorize/capture split and no capture endpoint - every approved charge is captured immediately - so express reversal instead: try POST /payments/{transaction_id}/void (full amount only, before settlement) and fall back to POST...", "receipt_email" to "Stripe has \"receipt_email\", ROKI has no equivalent. ROKI never emails a receipt from the API: fetch GET /payments/{transaction_id}/receipt for the public receipt_url (or /receipt/download for the PDF) and deliver it yourself - and be aware customer.email only prefills the checkout form, it...", "setup_future_usage" to "Stripe has \"setup_future_usage\", ROKI has no equivalent. You cannot request card saving from the ROKI API at all - the customer must tick 'save my card' on ROKI's own checkout, after which the payment_method.saved webhook delivers an opaque pm_* that you charge later via POST...", "payment_intent" to "In Stripe this is \"payment_intent\"; in ROKI it is \"transaction_id\". ROKI splits this into two handles: the numeric id for GET /payments/{id}, and the UUID transaction_id - null until the payment is actually charged - which is what void, refund and receipts require; passing the numeric id to those routes...", "customer" to "Stripe has \"customer\", ROKI has no equivalent. ROKI has no Customer resource and no customer ids, so there is nothing to pass here - identify people by the data itself: send the inline customer prefill object (name, email, phone, identity_number) on POST /payments, and look up saved...", "description" to "In Stripe and Mercado Pago this is \"description\"; in ROKI it is \"description\". Same name but capped at 120 characters in ROKI, and it is not the label the customer sees at checkout - that is the required name field (max 191), which Stripe has no equivalent for, so a straight port leaves your checkout page unlabelled.", "statement_descriptor" to "Stripe and Mercado Pago have \"statement_descriptor\", ROKI has no equivalent. ROKI exposes no per-payment statement descriptor - it comes from the merchant's terminal configuration - so remove the field rather than sending it, because it would be silently ignored with a 201; name and description only affect what the...", "line_items" to "Stripe has \"line_items\", ROKI has no equivalent. ROKI takes one pre-computed total: sum the cart in your own backend and send amount in decimal units plus a single name label, keep the per-line breakdown in metadata for reconciliation, and use sales_tax_type/sales_tax_value when you need...", "mode" to "Stripe has \"mode\", ROKI has no equivalent. ROKI has no mode parameter and no subscription engine - POST /payments is always a one-time hosted checkout - so drive recurring billing from your own scheduler by calling POST /payments/token-charge with a saved payment_token each cycle,...", "url" to "In Stripe this is \"url\"; in ROKI it is \"checkout_url\". ROKI returns checkout_url on the 201 from POST /payments - redirect to the exact string returned and never hardcode, allow-list or pattern-match the domain or slug, because the live value (aura.roki.systems/pay/link/{12-char lowercase...", "amount_total" to "In Stripe this is \"amount_total\"; in ROKI it is \"total\". ROKI's total is authoritative and in decimal units (subtotal + sales_tax_amount + service_fee_amount) - read it back from the response instead of recomputing it, and remember a customer-selected tip is chosen at checkout, so the amount...", "expires_at" to "In Stripe this is \"expires_at\"; in ROKI it is \"expires_at\". Same name, completely different format: ROKI wants a string like '2026-08-13 18:00:00' (or ISO 8601) in Honduras local time UTC-6 with no offset marker and it must be in the future or you get 422 - passing a Unix timestamp, or parsing the...", "payment_method" to "In Stripe this is \"payment_method\"; in ROKI it is \"payment_token\". ROKI's saved cards carry the same pm_ prefix, which makes this the most convincing wrong guess in the migration: the field is payment_token in the body of POST /payments/token-charge (or the {payment_method_id} path segment of POST...", "application_fee_amount" to "Stripe has \"application_fee_amount\", ROKI has no equivalent. ROKI has no platform or marketplace fee field; its only fee flag is the boolean service_fee_enabled, which means something different - it passes ROKI's own processing costs to the customer by reverse calculation so the merchant nets amount...", "locale" to "In Stripe this is \"locale\"; in ROKI it is \"Accept-Language\". ROKI has no body field for this - send the Accept-Language: es|en header instead (Spanish is the default for a missing, empty or unsupported value) - but note its narrower reach: it localizes API and validation messages only, routing...", "items" to "Mercado Pago has \"items\", ROKI has no equivalent. ROKI has no line items at all - sum the cart in your own backend and send one amount, and if you need the breakdown preserved put it in metadata, which ROKI returns untouched on retrieval and in every webhook.", "unit_price" to "Mercado Pago has \"unit_price\", ROKI has no equivalent. Nothing per-unit exists in ROKI - multiply by quantity, sum every item, and send the result as amount (decimal units, minimum 0.01, no maximum enforced by the API, so cap it on your side).", "quantity" to "Mercado Pago has \"quantity\", ROKI has no equivalent. Fold quantity into the total before calling ROKI, and never leave the key in the body as a hopeful extra: ROKI silently ignores unknown fields and still returns 201, so the mistake is invisible.", "transaction_amount" to "In Mercado Pago this is \"transaction_amount\"; in ROKI it is \"amount\". The closest 1:1 match in the whole migration - also decimal units and not cents (150.50 = L 150.50), also required, minimum 0.01 - but ROKI may add sales tax, tip and service fee on top, so the authoritative charged figure is total in the...", "payer" to "In Mercado Pago this is \"payer\"; in ROKI it is \"customer\". ROKI customer accepts only name, email, phone and identity_number (no surname, no address), and it is only a PREFILL of the hosted checkout that the customer can still edit unless you also send lock_customer_fields: true.", "external_reference" to "In Mercado Pago this is \"external_reference\"; in ROKI it is \"external_reference\". Identical name but REQUIRED in ROKI (max 191) and explicitly NOT unique - sending it twice without an Idempotency-Key creates two separate payable payments - and it is filterable via GET /payments?external_reference=.", "notification_url" to "Mercado Pago has \"notification_url\", ROKI has no equivalent. ROKI webhooks are not per payment - register the endpoint URL and copy its signing secret once per environment at https://aura.roki.systems/merchant/connect/webhooks, then verify the ROKI-Signature HMAC-SHA256 over timestamp + \".\" +...", "back_urls" to "Mercado Pago has \"back_urls\", ROKI has no equivalent. There is no nested object in ROKI - the two flat top-level fields success_url and cancel_url replace it, and both accept only http/https (a custom app scheme like myapp://done returns 422, so mobile return trips need Universal Links / App...", "auto_return" to "Mercado Pago has \"auto_return\", ROKI has no equivalent. There is no switch to set - the ROKI hosted checkout always offers the return to success_url - and as in Mercado Pago the redirect is never proof of a charge, so treat it purely as UX and confirm server-side.", "init_point" to "In Mercado Pago this is \"init_point\"; in ROKI it is \"checkout_url\". The clean equivalent and the heart of the migration - ROKI returns it straight from POST /payments with no preference step, there is exactly one URL for both environments, and the slug is a bare 12-character lowercase string with no plink_...", "sandbox_init_point" to "Mercado Pago has \"sandbox_init_point\", ROKI has no equivalent. ROKI has no second URL - the environment is decided solely by the API key prefix (sk_test_ = sandbox, sk_live_ = production) over identical routes, so use checkout_url in both and just swap the key.", "preference_id" to "In Mercado Pago this is \"preference_id\"; in ROKI it is \"id\". ROKI has no preference object - POST /payments creates the payment itself - and the returned numeric id serves only GET /payments/{id}, so persist it, because the only other way back to a payment is paging GET /payments.", "payment_method_id" to "Mercado Pago has \"payment_method_id\", ROKI has no equivalent. The most dangerous false friend in this table - ROKI does have a payment_method_id, but it is a URL PATH PARAMETER carrying an opaque saved-card reference (pm_7k2n9xqf31ab), not a brand selector, and ROKI Connect offers no method selection...", "token" to "In Mercado Pago this is \"token\"; in ROKI it is \"payment_token\". Same architecture, but one ROKI name covers two non-interchangeable values: mode 2 sends the iframe SDK's tok_* to POST https://aura.roki.systems/api/connect/embed/confirm alongside publishable_key (note the base is /api/connect/embed, not...", "installments" to "Mercado Pago has \"installments\", ROKI has no equivalent. ROKI Connect exposes no installment plans whatsoever and there is no workaround - and because unknown fields are silently dropped, leaving installments in the body returns 201 while the customer is charged in full, so strip it out...", "currency_id" to "In Mercado Pago this is \"currency_id\"; in ROKI it is \"currency_code\". Top-level rather than per-item, and inconsistent inside ROKI itself: POST /payments requires the NUMERIC ISO 4217 code (\"340\" = HNL) while POST /payments/token-charge and the embed confirm accept the alphabetic \"HNL\" - and only currencies...", "expires" to "Mercado Pago has \"expires\", ROKI has no equivalent. ROKI has no on/off switch - simply send expires_at to set an expiry, or omit it entirely for a link with no expiry.", "expiration_date_to" to "In Mercado Pago this is \"expiration_date_to\"; in ROKI it is \"expires_at\". Must be in the future and is interpreted in Honduras time (UTC-6) with NO offset in the string in either direction, so parsing expires_at, created_at or paid_at as UTC shifts every value by six hours - enough to make a live link look...", "capture" to "Mercado Pago has \"capture\", ROKI has no equivalent. ROKI has no auth/capture split - every approved charge captures - so the reversal path replaces it: try POST /payments/{transaction_id}/void (full amount only, pre-settlement) and fall back to POST /payments/{transaction_id}/refund (full...", "binary_mode" to "Mercado Pago has \"binary_mode\", ROKI has no equivalent. Not configurable in ROKI - a payment is pending until it is charged, and in mode 2 a 3-D Secure challenge returns status: \"pending\" with an authentication_url the customer must complete, the final outcome arriving by webhook.", "status_detail" to "Mercado Pago has \"status_detail\", ROKI has no equivalent. ROKI returns only status (pending, paid, partially_refunded, refunded, voided, expired, disabled); decline detail arrives in a different shape entirely, as processor-passthrough IsoResponseCode plus a capitalized Errors[] array of {Code,...", ) private val CREATE_PAYMENT_FIELDS = Fields( operation = "createPayment", known = listOf( "amount", "external_reference", "name", "currency_code", "description", "metadata", "success_url", "cancel_url", "expires_at", "customer", "lock_customer_fields", "reusable", "sales_tax_type", "sales_tax_value", "tip_enabled", "tip_type", "tip_value", "tip_customer_selectable", "tip_preset_percentages", "tip_allow_custom", "tip_min_amount", "tip_max_amount", "service_fee_enabled", ), required = listOf("amount", "external_reference", "name"), ) private val VOID_TRANSACTION_FIELDS = Fields( operation = "voidTransaction", known = listOf("reason"), required = emptyList(), ) private val REFUND_TRANSACTION_FIELDS = Fields( operation = "refundTransaction", known = listOf("amount", "reason"), required = listOf("amount"), ) private val CHARGE_SAVED_CARD_FIELDS = Fields( operation = "chargeSavedCard", known = listOf("amount", "currency_code", "external_reference", "metadata"), required = listOf("amount"), ) private val TOKEN_CHARGE_FIELDS = Fields( operation = "tokenCharge", known = listOf("payment_token", "amount", "currency_code", "external_reference", "metadata"), required = listOf("payment_token", "amount"), ) private val CONFIRM_EMBEDDED_PAYMENT_FIELDS = Fields( operation = "confirmEmbeddedPayment", known = listOf( "amount", "currency_code", "payment_token", "publishable_key", "external_reference", "success_redirect_url", "failed_redirect_url", "description", "metadata", ), required = listOf("amount", "currency_code", "payment_token", "publishable_key"), ) /** * Verify a webhook signature. * * rawBody must be the exact bytes received. In Ktor read them with call.receive() * and in Spring take the byte[] body: re-encoding the parsed JSON changes key order, spacing * and number formatting, and the HMAC will never match. * * Header: ROKI-Signature: t={timestamp},v1={hex} * * @param toleranceSeconds how old a signed event may be. 0 disables the check, which is * only reasonable in a test. * @param nowEpochSeconds injectable clock. System.currentTimeMillis and not java.time, * which needs desugaring below Android 8. */ @JvmStatic fun verifyWebhook( rawBody: ByteArray, signatureHeader: String?, signingSecret: String, toleranceSeconds: Long = 300, nowEpochSeconds: Long = System.currentTimeMillis() / 1000, ): Boolean { val match = SIGNATURE.find(signatureHeader ?: "") ?: return false val (timestamp, received) = match.destructured val signedAt = timestamp.toLongOrNull() ?: return false if (toleranceSeconds > 0 && abs(nowEpochSeconds - signedAt) > toleranceSeconds) { return false // replay of an old, genuinely signed event } val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(signingSecret.toByteArray(Charsets.UTF_8), "HmacSHA256")) mac.update((timestamp + ".").toByteArray(Charsets.UTF_8)) val expected = hex(mac.doFinal(rawBody)) // MessageDigest.isEqual, not ==: string equality returns on the first byte that differs // and leaks the signature through timing. return MessageDigest.isEqual( expected.toByteArray(Charsets.UTF_8), received.lowercase().toByteArray(Charsets.UTF_8), ) } /** Same check for a body you already hold as text. UTF-8, as it arrived. */ @JvmStatic fun verifyWebhook( rawBody: String, signatureHeader: String?, signingSecret: String, toleranceSeconds: Long = 300, nowEpochSeconds: Long = System.currentTimeMillis() / 1000, ): Boolean = verifyWebhook( rawBody.toByteArray(Charsets.UTF_8), signatureHeader, signingSecret, toleranceSeconds, nowEpochSeconds, ) } } /** The fields one operation accepts, straight from openapi.yaml. */ private class Fields( val operation: String, val known: List, val required: List, ) internal fun asJson(value: Any?): RokiJson? = (value as? Map<*, *>)?.let { raw -> RokiJson(raw.entries.associate { (key, item) -> key.toString() to item }) } /** * Percent-encoding for one path segment. * * URLEncoder is built for form bodies and turns a space into +, which inside a path is the * literal character +, not a space. The Charset overload would be cleaner but it needs API 33. */ private fun encodeSegment(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") private fun encodeQuery(value: String): String = URLEncoder.encode(value, "UTF-8") private const val HEX_DIGITS = "0123456789abcdef" private fun hex(bytes: ByteArray): String { val out = StringBuilder(bytes.size * 2) for (byte in bytes) { val value = byte.toInt() and 0xFF out.append(HEX_DIGITS[value shr 4]).append(HEX_DIGITS[value and 0x0F]) } return out.toString() } /** * The closest real field name. * * Edit distance alone is not enough here: service_fee is 8 edits away from service_fee_enabled and * is by far the most common mistake against this API. A shared prefix outranks distance. */ private fun closest(needle: String, candidates: List): String? { candidates.firstOrNull { it.startsWith(needle) || needle.startsWith(it) }?.let { return it } var best: String? = null var bestScore = Int.MAX_VALUE for (candidate in candidates) { val distance = levenshtein(needle, candidate) if (distance < bestScore) { bestScore = distance best = candidate } } return if (bestScore <= maxOf(2, needle.length / 3)) best else null } private fun levenshtein(a: String, b: String): Int { val previous = IntArray(b.length + 1) { it } for (i in 1..a.length) { var last = previous[0] previous[0] = i for (j in 1..b.length) { val current = previous[j] previous[j] = minOf( previous[j] + 1, previous[j - 1] + 1, last + if (a[i - 1] == b[j - 1]) 0 else 1, ) last = current } } return previous[b.length] } /** * Just enough JSON. * * The JVM ships no parser, org.json exists only on Android, and kotlinx.serialization would mean a * Gradle compiler plugin for a file whose whole point is that you drop it into a project and call * it. So: a reader and a writer, in one screen each. * * Numbers come back as Long when they have no fraction and Double when they do; amounts are * written from BigDecimal in plain notation, because 1.5E3 is valid JSON and an unreadable price. */ internal object Json { fun parse(text: String): Any? { val reader = Reader(text) val value = reader.value() reader.skipSpace() if (!reader.done) error("trailing content at " + reader.position) return value } fun encode(value: Any?): String = StringBuilder().also { write(value, it) }.toString() private fun write(value: Any?, out: StringBuilder) { when (value) { null -> out.append("null") is Boolean -> out.append(value) is String -> quote(value, out) is BigDecimal -> out.append(value.toPlainString()) is Double -> { require(value.isFinite()) { "NaN and Infinity are not JSON numbers" } out.append(BigDecimal.valueOf(value).toPlainString()) } is Float -> write(value.toDouble(), out) is Number -> out.append(value.toString()) is Enum<*> -> quote(value.name, out) is Map<*, *> -> { out.append('{') var first = true for ((key, item) in value) { if (!first) out.append(',') first = false quote(key.toString(), out) out.append(':') write(item, out) } out.append('}') } is Iterable<*> -> { out.append('[') var first = true for (item in value) { if (!first) out.append(',') first = false write(item, out) } out.append(']') } is Array<*> -> write(value.asList(), out) else -> throw IllegalArgumentException( "a " + value.javaClass.simpleName + " cannot go in a JSON body: send a String, a " + "number, a Boolean, a Map or a List" ) } } private fun quote(text: String, out: StringBuilder) { out.append('"') for (c in text) { when { c == '"' -> out.append("\\\"") c == '\\' -> out.append("\\\\") c == '\n' -> out.append("\\n") c == '\r' -> out.append("\\r") c == '\t' -> out.append("\\t") c < ' ' -> out.append("\\u").append(HEX_DIGITS[(c.code shr 12) and 0xF]) .append(HEX_DIGITS[(c.code shr 8) and 0xF]) .append(HEX_DIGITS[(c.code shr 4) and 0xF]) .append(HEX_DIGITS[c.code and 0xF]) else -> out.append(c) } } out.append('"') } private class Reader(private val src: String) { private var at = 0 val done: Boolean get() = at >= src.length val position: Int get() = at fun skipSpace() { while (at < src.length && src[at].isWhitespace()) at++ } fun value(): Any? { skipSpace() if (done) error("unexpected end of input") return when (src[at]) { '{' -> obj() '[' -> array() '"' -> text() 't' -> keyword("true", true) 'f' -> keyword("false", false) 'n' -> keyword("null", null) else -> number() } } private fun obj(): Map { val out = LinkedHashMap() at++ skipSpace() if (!done && src[at] == '}') { at++ return out } while (true) { skipSpace() if (done || src[at] != '"') error("expected a key at " + at) val key = text() skipSpace() if (done || src[at] != ':') error("expected : at " + at) at++ out[key] = value() skipSpace() if (done) error("unterminated object") when (src[at++]) { ',' -> Unit '}' -> return out else -> error("expected , or } at " + at) } } } private fun array(): List { val out = ArrayList() at++ skipSpace() if (!done && src[at] == ']') { at++ return out } while (true) { out.add(value()) skipSpace() if (done) error("unterminated array") when (src[at++]) { ',' -> Unit ']' -> return out else -> error("expected , or ] at " + at) } } } private fun text(): String { at++ val out = StringBuilder() while (true) { if (done) error("unterminated string") when (val c = src[at++]) { '"' -> return out.toString() '\\' -> out.append(escape()) else -> out.append(c) } } } private fun escape(): Char { if (done) error("unterminated escape") return when (val c = src[at++]) { '"', '\\', '/' -> c 'b' -> '\b' 'f' -> 12.toChar() // form feed: Kotlin has no \f escape 'n' -> '\n' 'r' -> '\r' 't' -> '\t' 'u' -> { if (at + 4 > src.length) error("truncated unicode escape") val code = src.substring(at, at + 4).toInt(16) at += 4 code.toChar() } else -> error("unknown escape at " + at) } } private fun number(): Any { val start = at while (at < src.length && (src[at].isDigit() || src[at] in "-+.eE")) at++ val raw = src.substring(start, at) return raw.toLongOrNull() ?: raw.toDoubleOrNull() ?: error("not a number: " + raw) } private fun keyword(word: String, value: Any?): Any? { if (!src.startsWith(word, at)) error("unexpected character at " + at) at += word.length return value } } }