@tinkerlandai/indiana-react-native 0.9.2 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IndianaReactNative.podspec +19 -0
- package/README.md +62 -5
- package/THIRD_PARTY_NOTICES.md +74 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
- package/dist/index.cjs +17 -1
- package/dist/index.d.cts +96889 -6007
- package/dist/index.d.ts +96889 -6007
- package/dist/index.js +17 -1
- package/ios/IndianaBridge.m +11 -0
- package/ios/IndianaBridge.swift +275 -0
- package/native-sdk/android/assets/indiana/index.html +268 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +197 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/MerchantCommerceSemantics.kt +38 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
- package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
- package/native-sdk/android/resources/renderer-data-url.txt +1 -0
- package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
- package/native-sdk/ios/AppleStorefront.swift +218 -0
- package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
- package/native-sdk/ios/BundledWebView.swift +87 -0
- package/native-sdk/ios/ContentSession.swift +48 -0
- package/native-sdk/ios/ContentView.swift +51 -0
- package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
- package/native-sdk/ios/CustomerResponseSemantics.swift +236 -0
- package/native-sdk/ios/CustomerSemantics.swift +145 -0
- package/native-sdk/ios/CustomerSession.swift +129 -0
- package/native-sdk/ios/FeatureClient.swift +86 -0
- package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
- package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
- package/native-sdk/ios/FunnelSession.swift +232 -0
- package/native-sdk/ios/FunnelView.swift +125 -0
- package/native-sdk/ios/GeneratedProtocol.swift +16 -0
- package/native-sdk/ios/HTTPTransport.swift +74 -0
- package/native-sdk/ios/IndianaClient.swift +601 -0
- package/native-sdk/ios/MerchantCommerceSemantics.swift +51 -0
- package/native-sdk/ios/NativePendingKeychain.swift +52 -0
- package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
- package/native-sdk/ios/NativeStoreOffer.swift +34 -0
- package/native-sdk/ios/PaywallContent.swift +75 -0
- package/native-sdk/ios/PaywallSession.swift +124 -0
- package/native-sdk/ios/PaywallView.swift +87 -0
- package/native-sdk/ios/Protocol.swift +49 -0
- package/native-sdk/ios/PushInstallation.swift +154 -0
- package/native-sdk/ios/RendererNavigation.swift +8 -0
- package/native-sdk/ios/Resources.swift +15 -0
- package/native-sdk/ios/SDKVersion.swift +4 -0
- package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
- package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
- package/native-sdk/ios/WebView/index.html +268 -0
- package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
- package/native-sdk/ios/WireSchema.swift +107 -0
- package/native-sdk/ios/funnel-wire-schemas.json +1 -0
- package/package.json +8 -3
- package/react-native.config.cjs +12 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
5
|
+
import android.security.keystore.KeyProperties
|
|
6
|
+
import android.util.AtomicFile
|
|
7
|
+
import android.util.Base64
|
|
8
|
+
import org.json.JSONArray
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
import java.io.File
|
|
11
|
+
import java.security.KeyPairGenerator
|
|
12
|
+
import java.security.KeyStore
|
|
13
|
+
import java.security.MessageDigest
|
|
14
|
+
import java.security.Signature
|
|
15
|
+
import java.security.spec.ECGenParameterSpec
|
|
16
|
+
import java.text.SimpleDateFormat
|
|
17
|
+
import java.util.Date
|
|
18
|
+
import java.util.Locale
|
|
19
|
+
import java.util.TimeZone
|
|
20
|
+
import java.util.UUID
|
|
21
|
+
|
|
22
|
+
enum class IndianaPushPermission(val value: String) { UNKNOWN("unknown"), DENIED("denied"), PROVISIONAL("provisional"), GRANTED("granted") }
|
|
23
|
+
enum class IndianaPushAddressKind(val value: String) { APNS_TOKEN("apns_token"), FCM_TOKEN("fcm_token") }
|
|
24
|
+
data class IndianaPushObservation(val connectionId: UUID?, val kind: IndianaPushAddressKind?, val token: String?, val permission: IndianaPushPermission) {
|
|
25
|
+
init {
|
|
26
|
+
require((connectionId == null && kind == null && token == null) || (connectionId != null && kind != null && token != null && token.length in 16..4096 && token.all { it.code in 33..126 }))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
class IndianaPushObservationTicket internal constructor(internal val client: UUID, internal val identity: UUID, internal val observation: UUID)
|
|
30
|
+
internal const val PUSH_MAX_SEQUENCE = 9_007_199_254_740_991L
|
|
31
|
+
internal fun pushSigningBytes(fields: JSONArray): ByteArray {
|
|
32
|
+
fun quoted(value: String) = buildString {
|
|
33
|
+
append('"')
|
|
34
|
+
for (character in value) when (character) {
|
|
35
|
+
'"' -> append("\\\"")
|
|
36
|
+
'\\' -> append("\\\\")
|
|
37
|
+
else -> { require(character.code in 32..126); append(character) }
|
|
38
|
+
}
|
|
39
|
+
append('"')
|
|
40
|
+
}
|
|
41
|
+
return (0 until fields.length()).joinToString(",", "[", "]") { index ->
|
|
42
|
+
when (val value = fields.get(index)) {
|
|
43
|
+
JSONObject.NULL -> "null"
|
|
44
|
+
is String -> quoted(value)
|
|
45
|
+
is Long -> value.toString()
|
|
46
|
+
is Int -> value.toString()
|
|
47
|
+
else -> throw IndianaClientException("invalid_configuration")
|
|
48
|
+
}
|
|
49
|
+
}.toByteArray(Charsets.UTF_8)
|
|
50
|
+
}
|
|
51
|
+
internal data class IndianaPushJournal(val installationId: UUID, val publicKey: String, val identitySeq: Long = 0, val addressSeq: Long = 0, val detachSeq: Long? = null) {
|
|
52
|
+
fun validate() {
|
|
53
|
+
if (identitySeq !in 0..PUSH_MAX_SEQUENCE || addressSeq !in 0..PUSH_MAX_SEQUENCE || (detachSeq != null && detachSeq !in 1..identitySeq) || !publicKey.matches(Regex("[A-Za-z0-9_-]{80,200}"))) throw IndianaClientException("protected_storage_unavailable")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
internal interface IndianaPushStorage {
|
|
57
|
+
/** Commits before returning; no Customer credential or provider token is stored. */
|
|
58
|
+
fun transaction(update: (IndianaPushJournal) -> IndianaPushJournal): IndianaPushJournal
|
|
59
|
+
fun sign(payload: ByteArray): String
|
|
60
|
+
}
|
|
61
|
+
internal fun pushP1363(der: ByteArray): ByteArray {
|
|
62
|
+
if (der.size !in 8..72 || der[0].toInt() != 0x30 || (der[1].toInt() and 255) != der.size - 2) throw IndianaClientException("protected_storage_unavailable")
|
|
63
|
+
var offset = 2
|
|
64
|
+
val result = ByteArray(64)
|
|
65
|
+
for (part in 0..1) {
|
|
66
|
+
if (offset + 2 > der.size || der[offset++].toInt() != 2) throw IndianaClientException("protected_storage_unavailable")
|
|
67
|
+
val size = der[offset++].toInt() and 255
|
|
68
|
+
if (size !in 1..33 || offset + size > der.size || der[offset].toInt() < 0) throw IndianaClientException("protected_storage_unavailable")
|
|
69
|
+
val leading = if (size > 1 && der[offset].toInt() == 0) 1 else 0
|
|
70
|
+
if (size - leading > 32) throw IndianaClientException("protected_storage_unavailable")
|
|
71
|
+
der.copyInto(result, part * 32 + 32 - size + leading, offset + leading, offset + size); offset += size
|
|
72
|
+
}
|
|
73
|
+
if (offset != der.size) throw IndianaClientException("protected_storage_unavailable")
|
|
74
|
+
return result
|
|
75
|
+
}
|
|
76
|
+
/** Key stays in Android Keystore; the atomic journal stays in App-private no-backup storage. */
|
|
77
|
+
internal class IndianaPushKeystore(context: Context, scope: IndianaScope): IndianaPushStorage {
|
|
78
|
+
private val namespace = "${context.packageName}:${scope.projectId}:${scope.environment.value}"
|
|
79
|
+
private val hash = MessageDigest.getInstance("SHA-256").digest(namespace.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
80
|
+
private val alias = "indiana.push.v1.$hash"
|
|
81
|
+
private val file = AtomicFile(File(context.noBackupFilesDir, "indiana-push-$hash.json"))
|
|
82
|
+
companion object { private val lock = Any() }
|
|
83
|
+
private fun store(): KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
|
84
|
+
private fun base64(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
|
85
|
+
override fun transaction(update: (IndianaPushJournal) -> IndianaPushJournal): IndianaPushJournal = synchronized(lock) {
|
|
86
|
+
try {
|
|
87
|
+
val keystore = store()
|
|
88
|
+
val current = if (!file.baseFile.exists()) {
|
|
89
|
+
if (keystore.containsAlias(alias)) throw IndianaClientException("protected_storage_unavailable")
|
|
90
|
+
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY).setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")).setDigests(KeyProperties.DIGEST_SHA256).build()
|
|
91
|
+
val key = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").apply { initialize(spec) }.generateKeyPair()
|
|
92
|
+
IndianaPushJournal(UUID.randomUUID(), base64(key.public.encoded))
|
|
93
|
+
} else {
|
|
94
|
+
val bytes = file.openRead().use { input ->
|
|
95
|
+
val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(1024)
|
|
96
|
+
while (true) { val count = input.read(buffer); if (count < 0) break; if (output.size() + count > 4096) throw IndianaClientException("protected_storage_unavailable"); output.write(buffer, 0, count) }
|
|
97
|
+
output.toByteArray()
|
|
98
|
+
}
|
|
99
|
+
if (bytes.size > 4096) throw IndianaClientException("protected_storage_unavailable")
|
|
100
|
+
val json = JSONObject(bytes.toString(Charsets.UTF_8))
|
|
101
|
+
if (json.keys().asSequence().toSet() != setOf("installationId", "publicKey", "identitySeq", "addressSeq", "detachSeq")) throw IndianaClientException("protected_storage_unavailable")
|
|
102
|
+
IndianaPushJournal(UUID.fromString(json.getString("installationId")), json.getString("publicKey"), json.getLong("identitySeq"), json.getLong("addressSeq"), if (json.isNull("detachSeq")) null else json.getLong("detachSeq"))
|
|
103
|
+
}
|
|
104
|
+
current.validate()
|
|
105
|
+
if (base64(keystore.getCertificate(alias)?.publicKey?.encoded ?: throw IndianaClientException("protected_storage_unavailable")) != current.publicKey) throw IndianaClientException("protected_storage_unavailable")
|
|
106
|
+
val next = update(current); next.validate()
|
|
107
|
+
if (next.installationId != current.installationId || next.publicKey != current.publicKey || next.identitySeq < current.identitySeq || next.addressSeq < current.addressSeq) throw IndianaClientException("protected_storage_unavailable")
|
|
108
|
+
if (!file.baseFile.exists() || next != current) {
|
|
109
|
+
val data = JSONObject().put("installationId", next.installationId.toString()).put("publicKey", next.publicKey).put("identitySeq", next.identitySeq).put("addressSeq", next.addressSeq).put("detachSeq", next.detachSeq ?: JSONObject.NULL).toString().toByteArray()
|
|
110
|
+
val stream = file.startWrite()
|
|
111
|
+
try { stream.write(data); file.finishWrite(stream) } catch (error: Exception) { file.failWrite(stream); throw error }
|
|
112
|
+
}
|
|
113
|
+
next
|
|
114
|
+
} catch (_: Exception) { throw IndianaClientException("protected_storage_unavailable") }
|
|
115
|
+
}
|
|
116
|
+
override fun sign(payload: ByteArray): String = synchronized(lock) {
|
|
117
|
+
try {
|
|
118
|
+
val key = store().getKey(alias, null) as? java.security.PrivateKey ?: throw IndianaClientException("protected_storage_unavailable")
|
|
119
|
+
val signature = Signature.getInstance("SHA256withECDSA").apply { initSign(key); update(payload) }.sign()
|
|
120
|
+
base64(pushP1363(signature))
|
|
121
|
+
} catch (_: Exception) { throw IndianaClientException("protected_storage_unavailable") }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
internal class IndianaPushIdentity(val storage: IndianaPushStorage) {
|
|
125
|
+
init { state() }
|
|
126
|
+
fun state() = storage.transaction { it }
|
|
127
|
+
fun reserve(detach: Boolean = false): Long = storage.transaction { current ->
|
|
128
|
+
if (current.identitySeq >= PUSH_MAX_SEQUENCE) throw IndianaClientException("protected_storage_unavailable")
|
|
129
|
+
val next = current.identitySeq + 1
|
|
130
|
+
current.copy(identitySeq = next, detachSeq = if (detach) next else current.detachSeq)
|
|
131
|
+
}.identitySeq
|
|
132
|
+
fun reserveAddress(): Long = storage.transaction { current ->
|
|
133
|
+
if (current.addressSeq >= PUSH_MAX_SEQUENCE) throw IndianaClientException("protected_storage_unavailable")
|
|
134
|
+
current.copy(addressSeq = current.addressSeq + 1)
|
|
135
|
+
}.addressSeq
|
|
136
|
+
fun clearDetach(sequence: Long) { storage.transaction { if (it.detachSeq == sequence) it.copy(detachSeq = null) else it } }
|
|
137
|
+
fun recover(remote: JSONObject, expected: Long): Long = storage.transaction { current ->
|
|
138
|
+
if (current.identitySeq != expected) throw IndianaClientException("identity_changed")
|
|
139
|
+
val floor = maxOf(current.identitySeq, remote.getLong("identitySeq"))
|
|
140
|
+
if (floor >= PUSH_MAX_SEQUENCE) throw IndianaClientException("protected_storage_unavailable")
|
|
141
|
+
current.copy(identitySeq = floor + 1, addressSeq = maxOf(current.addressSeq, remote.getLong("addressSeq")))
|
|
142
|
+
}.identitySeq
|
|
143
|
+
fun command(operation: String, scope: IndianaScope, sequence: Long? = null, credential: String? = null, observation: IndianaPushObservation? = null, challenge: String? = null): JSONObject {
|
|
144
|
+
val state = state(); val issued = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date())
|
|
145
|
+
val fields = JSONArray().put("indiana_push_v1").put(operation).put(scope.projectId.toString()).put(scope.environment.value).put(state.installationId.toString()).put(state.publicKey).put(issued)
|
|
146
|
+
val input = JSONObject().put("installationId", state.installationId.toString()).put("publicKey", state.publicKey).put("issuedAt", issued)
|
|
147
|
+
fun digest(): String = MessageDigest.getInstance("SHA-256").digest((credential ?: throw IndianaClientException("unauthenticated")).toByteArray()).joinToString("") { "%02x".format(it) }
|
|
148
|
+
when (operation) {
|
|
149
|
+
"bind", "detach" -> {
|
|
150
|
+
require(sequence != null); fields.put(sequence).put("android"); input.put("sequence", sequence).put("platform", "android")
|
|
151
|
+
if (operation == "bind") { val digest = digest(); fields.put(digest); input.put("credentialDigest", digest) }
|
|
152
|
+
}
|
|
153
|
+
"read" -> { val digest = digest(); fields.put(digest); input.put("credentialDigest", digest) }
|
|
154
|
+
"address", "recovery_request", "recovery_confirm" -> {
|
|
155
|
+
require(sequence != null && observation != null); val digest = digest()
|
|
156
|
+
val connection = observation.connectionId?.toString() ?: JSONObject.NULL; val kind = observation.kind?.value ?: JSONObject.NULL; val token = observation.token ?: JSONObject.NULL
|
|
157
|
+
fields.put(sequence).put(digest).put(connection).put(kind).put(token).put(observation.permission.value)
|
|
158
|
+
input.put("sequence", sequence).put("credentialDigest", digest).put("connectionId", connection).put("kind", kind).put("token", token).put("permission", observation.permission.value)
|
|
159
|
+
if (operation == "recovery_confirm") {
|
|
160
|
+
if (challenge == null || !challenge.matches(Regex("[A-Za-z0-9_-]{100,3500}"))) throw IndianaClientException("invalid_configuration")
|
|
161
|
+
fields.put(challenge); input.put("challenge", challenge)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else -> throw IndianaClientException("invalid_configuration")
|
|
165
|
+
}
|
|
166
|
+
input.put("signature", storage.sign(pushSigningBytes(fields)))
|
|
167
|
+
if (operation == "detach") input.put("projectId", scope.projectId.toString()).put("environment", scope.environment.value)
|
|
168
|
+
return input
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import java.net.URI
|
|
4
|
+
|
|
5
|
+
private object BundledRenderer {
|
|
6
|
+
val url: String by lazy {
|
|
7
|
+
checkNotNull(BundledRenderer::class.java.getResourceAsStream("/renderer-data-url.txt"))
|
|
8
|
+
.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Subframes can execute only the renderer bytes shipped with this SDK. */
|
|
12
|
+
internal fun allowsFunnelRendererNavigation(offered: URI, base: URI): Boolean =
|
|
13
|
+
base.toString() == "https://indiana-sdk.invalid/" && offered.toString().substringBefore('#') == BundledRenderer.url
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.net.Uri
|
|
6
|
+
|
|
7
|
+
/** Opens Play's renewal management. Returning to the App never proves an entitlement change. */
|
|
8
|
+
object IndianaStoreSubscriptions {
|
|
9
|
+
fun manage(activity: Activity, productId: String) {
|
|
10
|
+
val url = managementURL(activity.packageName, productId)
|
|
11
|
+
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
internal fun managementURL(packageName: String, productId: String): String {
|
|
15
|
+
require(packageName.matches(Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+"))) { "invalid_app_package" }
|
|
16
|
+
require(productId.length in 1..255 && productId.matches(Regex("[a-z0-9][a-z0-9._]*"))) { "invalid_store_product" }
|
|
17
|
+
return "https://play.google.com/store/account/subscriptions?sku=$productId&package=$packageName"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
import org.json.JSONObject
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import java.net.URI
|
|
5
|
+
import java.util.UUID
|
|
6
|
+
/** Generated contract interpreter for the native wire boundary; no business policy is evaluated here. */
|
|
7
|
+
internal object IndianaSchema {
|
|
8
|
+
private val schemas: JSONObject by lazy { IndianaSchema::class.java.getResourceAsStream("/funnel-wire-schemas.json")?.bufferedReader(Charsets.UTF_8)?.use { JSONObject(it.readText()) } ?: throw IndianaClientException("invalid_response") }
|
|
9
|
+
fun validate(name: String, value: JSONObject) { val root = schemas.getJSONObject(name);if (!valid(value, root, root, 0)) throw IndianaClientException("invalid_response") }
|
|
10
|
+
fun customerResponse(name: String, value: JSONObject): JSONObject { val root = schemas.getJSONObject(name); return adapt(value, root, root, listOf(name), 0) as JSONObject }
|
|
11
|
+
private fun adapt(original: Any?, schema: JSONObject, root: JSONObject, path: List<String>, depth: Int): Any? {
|
|
12
|
+
if (depth > 100) throw IndianaClientException("invalid_response")
|
|
13
|
+
if (schema.has("\$ref")) { var target: Any = root; for (part in schema.getString("\$ref").substring(2).split('/')) target = (target as JSONObject).get(part.replace("~1", "/").replace("~0", "~")); return adapt(original, target as JSONObject, root, path, depth + 1) }
|
|
14
|
+
(schema.optJSONArray("anyOf") ?: schema.optJSONArray("oneOf"))?.let { branches -> val branch = (0 until branches.length()).map { branches.getJSONObject(it) }.firstOrNull { valid(original, it, root, 0) } ?: throw IndianaClientException("invalid_response"); return adapt(original, branch, root, path, depth + 1) }
|
|
15
|
+
var value = IndianaCustomerResponseSemantics.rules(original, schema)
|
|
16
|
+
schema.optJSONArray("allOf")?.let { branches -> for (n in 0 until branches.length()) value = adapt(value, branches.getJSONObject(n), root, path, depth + 1) }
|
|
17
|
+
if (value is JSONObject) {
|
|
18
|
+
val objectValue = value as JSONObject; val properties = schema.optJSONObject("properties") ?: JSONObject()
|
|
19
|
+
for (key in properties.keys()) if (objectValue.has(key)) objectValue.put(key, adapt(objectValue.get(key), properties.getJSONObject(key), root, path + key, depth + 1))
|
|
20
|
+
schema.optJSONObject("additionalProperties")?.let { additional -> for (key in objectValue.keys()) if (!properties.has(key)) objectValue.put(key, adapt(objectValue.get(key), additional, root, path + "{}", depth + 1)) }
|
|
21
|
+
IndianaCustomerResponseSemantics.objectValue(objectValue, properties.keys().asSequence().toSet(), path); return objectValue
|
|
22
|
+
}
|
|
23
|
+
if (value is JSONArray) { val array = value as JSONArray; schema.optJSONObject("items")?.let { child -> for (n in 0 until array.length()) array.put(n, adapt(array.opt(n), child, root, path + "[]", depth + 1)) }; return array }
|
|
24
|
+
if (value is String && schema.optString("pattern") in listOf("^(0|[1-9]\\d{0,11})(\\.\\d{1,6})?$", "^-?(0|[1-9]\\d{0,11})(\\.\\d{1,6})?$")) return (value as String).toBigDecimal().stripTrailingZeros().toPlainString()
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
27
|
+
internal fun equal(a: Any?, b: Any?): Boolean = when {
|
|
28
|
+
a is JSONObject && b is JSONObject -> a.keys().asSequence().toSet() == b.keys().asSequence().toSet() && a.keys().asSequence().all { equal(a.opt(it), b.opt(it)) }
|
|
29
|
+
a is JSONArray && b is JSONArray -> a.length() == b.length() && (0 until a.length()).all { equal(a.opt(it), b.opt(it)) }
|
|
30
|
+
a is Number && b is Number -> a.toDouble() == b.toDouble()
|
|
31
|
+
else -> a == b
|
|
32
|
+
}
|
|
33
|
+
private fun valid(value: Any?, schema: JSONObject, root: JSONObject, depth: Int): Boolean {
|
|
34
|
+
if (depth > 100) return false
|
|
35
|
+
val value = try { IndianaCustomerResponseSemantics.rules(value, schema) } catch (_: Exception) { return false }
|
|
36
|
+
if (schema.has("\$ref")) { val ref = schema.getString("\$ref");if (!ref.startsWith("#/")) return false;var target: Any = root;for (part in ref.substring(2).split('/')) target = (target as? JSONObject)?.opt(part.replace("~1", "/").replace("~0", "~")) ?: return false;return target is JSONObject && valid(value, target, root, depth + 1) }
|
|
37
|
+
for (name in listOf("allOf", "anyOf", "oneOf")) schema.optJSONArray(name)?.let { choices -> val matches = (0 until choices.length()).count { valid(value, choices.getJSONObject(it), root, depth + 1) };if (name == "allOf" && matches != choices.length() || name == "anyOf" && matches == 0 || name == "oneOf" && matches != 1) return false }
|
|
38
|
+
if (schema.has("const") && !equal(value, schema.opt("const"))) return false
|
|
39
|
+
schema.optJSONArray("enum")?.let { choices -> if ((0 until choices.length()).none { equal(value, choices.opt(it)) }) return false }
|
|
40
|
+
when (schema.optString("type")) {
|
|
41
|
+
"null" -> if (value != null && value != JSONObject.NULL) return false
|
|
42
|
+
"boolean" -> if (value !is Boolean) return false
|
|
43
|
+
"string" -> if (value !is String) return false
|
|
44
|
+
"number", "integer" -> if (value !is Number || !value.toDouble().isFinite() || schema.getString("type") == "integer" && value.toDouble() != kotlin.math.floor(value.toDouble())) return false
|
|
45
|
+
"object" -> if (value !is JSONObject) return false
|
|
46
|
+
"array" -> if (value !is JSONArray) return false
|
|
47
|
+
}
|
|
48
|
+
if (value is String) {
|
|
49
|
+
if (schema.has("minLength") && value.length < schema.getInt("minLength") || schema.has("maxLength") && value.length > schema.getInt("maxLength")) return false
|
|
50
|
+
if (schema.has("pattern") && !Regex(schema.getString("pattern")).containsMatchIn(value)) return false
|
|
51
|
+
try { when (schema.optString("format")) { "uuid" -> UUID.fromString(value);"date-time" -> utcMillis(value);"uri" -> if (URI(value).scheme == null) return false } } catch (_: Exception) { return false }
|
|
52
|
+
}
|
|
53
|
+
if (value is Number) {
|
|
54
|
+
val n = value.toDouble()
|
|
55
|
+
if (schema.has("minimum") && n < schema.getDouble("minimum") || schema.has("maximum") && n > schema.getDouble("maximum") || schema.has("exclusiveMinimum") && n <= schema.getDouble("exclusiveMinimum") || schema.has("exclusiveMaximum") && n >= schema.getDouble("exclusiveMaximum")) return false
|
|
56
|
+
}
|
|
57
|
+
if (value is JSONArray) {
|
|
58
|
+
if (schema.has("minItems") && value.length() < schema.getInt("minItems") || schema.has("maxItems") && value.length() > schema.getInt("maxItems")) return false
|
|
59
|
+
if (schema.optBoolean("uniqueItems")) for (index in 0 until value.length()) if ((0 until index).any { equal(value.opt(it), value.opt(index)) }) return false
|
|
60
|
+
schema.optJSONObject("items")?.let { items -> if ((0 until value.length()).any { !valid(value.opt(it), items, root, depth + 1) }) return false }
|
|
61
|
+
schema.optJSONArray("items")?.let { tuple -> for (index in 0 until value.length()) if (index < tuple.length() && !valid(value.opt(index), tuple.getJSONObject(index), root, depth + 1)) return false;if (schema.opt("additionalItems") == false && value.length() > tuple.length()) return false }
|
|
62
|
+
}
|
|
63
|
+
if (value is JSONObject) {
|
|
64
|
+
if (schema.has("maxProperties") && value.length() > schema.getInt("maxProperties") || schema.has("minProperties") && value.length() < schema.getInt("minProperties")) return false
|
|
65
|
+
val properties = schema.optJSONObject("properties") ?: JSONObject()
|
|
66
|
+
schema.optJSONArray("required")?.let { required -> if ((0 until required.length()).any { !value.has(required.getString(it)) }) return false }
|
|
67
|
+
for (key in value.keys()) {
|
|
68
|
+
if (properties.has(key)) { if (!valid(value.opt(key), properties.getJSONObject(key), root, depth + 1)) return false }
|
|
69
|
+
else if (schema.opt("additionalProperties") == false) return false
|
|
70
|
+
else schema.optJSONObject("additionalProperties")?.let { if (!valid(value.opt(key), it, root, depth + 1)) return false }
|
|
71
|
+
schema.optJSONObject("propertyNames")?.let { if (!valid(key, it, root, depth + 1)) return false }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
}
|