Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-20 16:25:40 +04:00
parent 4d1902af26
commit bf4a7a9e4e
7 changed files with 891 additions and 5 deletions

View file

@ -0,0 +1,165 @@
package com.tangem.plugin.configuration.configurations
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import kotlinx.serialization.json.*
import java.io.File
/**
* Generator for environment configuration Kotlin object from JSON file.
* Automatically parses JSON structure and generates corresponding Kotlin code.
*
[REDACTED_AUTHOR]
*/
object EnvironmentConfigGenerator {
private const val PACKAGE_NAME = "com.tangem.datasource.local.config.environment.generated"
private const val CLASS_NAME = "GeneratedEnvironmentConfig"
/**
* Generates GeneratedEnvironmentConfig object from JSON file.
*
* @param inputFile JSON configuration file
* @param outputDir Output directory for generated Kotlin file
*/
fun generate(inputFile: File, outputDir: File) {
val jsonText = inputFile.readText()
val json = Json.parseToJsonElement(jsonText).jsonObject
val objectBuilder = TypeSpec.objectBuilder(CLASS_NAME)
.addKdoc("Generated from ${inputFile.name}\nAuto-generated - do not edit manually.")
// Iterate over all JSON keys and generate properties
json.entries.forEach { (key, value) ->
addPropertyFromJsonValue(objectBuilder, key, value)
}
val fileSpec = FileSpec.builder(PACKAGE_NAME, CLASS_NAME)
.indent(" ") // Use 4 spaces for indentation
.addType(objectBuilder.build())
.build()
outputDir.mkdirs()
fileSpec.writeTo(outputDir)
// Post-process generated file
val generatedFile = File(outputDir, PACKAGE_NAME.replace('.', '/') + "/$CLASS_NAME.kt")
if (generatedFile.exists()) {
val content = generatedFile.readText()
val fixedContent = content
// Add suppress annotation at file level
.replaceFirst(
"package $PACKAGE_NAME",
"@file:Suppress(\n" +
" \"MaximumLineLength\",\n" +
" \"MaxLineLength\",\n" +
" \"Indentation\",\n" +
")\n\npackage $PACKAGE_NAME"
)
// Remove redundant public modifiers
.replace("public object ", "object ")
.replace("public val ", "val ")
.replace("public const val ", "const val ")
generatedFile.writeText(fixedContent)
}
}
/**
* Adds a property to the TypeSpec based on the JSON value type
*/
private fun addPropertyFromJsonValue(builder: TypeSpec.Builder, name: String, value: JsonElement) {
when (value) {
is JsonPrimitive -> {
when {
value.isString -> {
val stringValue = value.content
val isNullable = stringValue.isEmpty()
val propertySpec = PropertySpec.builder(name, STRING.copy(nullable = isNullable))
.initializer(if (isNullable) "null" else "%S", stringValue)
// Add const modifier for non-nullable strings
if (!isNullable) {
propertySpec.addModifiers(KModifier.CONST)
}
builder.addProperty(propertySpec.build())
}
value.booleanOrNull != null -> {
builder.addProperty(
PropertySpec.builder(name, BOOLEAN)
.addModifiers(KModifier.CONST)
.initializer("%L", value.boolean)
.build()
)
}
value.longOrNull != null -> {
builder.addProperty(
PropertySpec.builder(name, LONG)
.addModifiers(KModifier.CONST)
.initializer("%L", value.long)
.build()
)
}
value.doubleOrNull != null -> {
builder.addProperty(
PropertySpec.builder(name, DOUBLE)
.addModifiers(KModifier.CONST)
.initializer("%L", value.double)
.build()
)
}
else -> {
// Null value
builder.addProperty(
PropertySpec.builder(name, STRING.copy(nullable = true))
.initializer("null")
.build()
)
}
}
}
is JsonArray -> {
val listType = LIST.parameterizedBy(STRING)
val values = value.map { it.jsonPrimitive.content }
builder.addProperty(
PropertySpec.builder(name, listType)
.initializer(
CodeBlock.builder()
.add("listOf(\n")
.apply {
values.forEach { v ->
add(" %S,\n", v)
}
}
.add(")")
.build()
)
.build()
)
}
is JsonObject -> {
// Generate nested object with proper naming (convert dashes to camelCase)
val nestedClassName = name.toPascalCase()
val nestedObjectBuilder = TypeSpec.objectBuilder(nestedClassName)
value.entries.forEach { (nestedKey, nestedValue) ->
addPropertyFromJsonValue(nestedObjectBuilder, nestedKey, nestedValue)
}
builder.addType(nestedObjectBuilder.build())
}
}
}
/**
* Converts a string to PascalCase, handling dashes and underscores.
* Examples: "cosmos-hub" -> "CosmosHub", "polygon-zkevm" -> "PolygonZkevm"
*/
private fun String.toPascalCase(): String {
return this.split("-", "_")
.filter { it.isNotEmpty() }
.joinToString("") { part ->
part.replaceFirstChar { it.uppercase() }
}
}
}

View file

@ -1,11 +1,11 @@
package com.tangem.plugin.configuration.model
internal enum class BuildType(
enum class BuildType(
val id: String,
val appIdSuffix: String? = null,
val versionSuffix: String? = null,
val obfuscating: Boolean = false,
val configFields: List<BuildConfigField>,
internal val appIdSuffix: String? = null,
internal val versionSuffix: String? = null,
internal val obfuscating: Boolean = false,
internal val configFields: List<BuildConfigField>,
) {
/**
@ -117,4 +117,19 @@ internal enum class BuildType(
BuildConfigField.ABTestsEnabled(isEnabled = false),
),
),
;
/** Returns the environment value (dev/prod) for this build type */
val environment: String
get() {
val environmentField = configFields
.filterIsInstance<BuildConfigField.Environment>()
.firstOrNull()
requireNotNull(environmentField) {
"BuildType '$id' must have a BuildConfigField.Environment in configFields"
}
return environmentField.value.removeSurrounding("\"")
}
}

View file

@ -0,0 +1,473 @@
package com.tangem.plugin.configuration.configurations
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
/**
* Tests for [EnvironmentConfigGenerator] covering JSON parsing edge cases.
*/
class EnvironmentConfigGeneratorTest {
@TempDir
lateinit var tempDir: File
private lateinit var outputDir: File
@BeforeEach
fun setup() {
outputDir = File(tempDir, "output")
}
@Test
fun `generate handles string values correctly`() {
// Arrange
val json = """
{
"apiKey": "test-api-key",
"baseUrl": "https://example.com"
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("""const val apiKey: String = "test-api-key"""")
assertThat(generatedCode).contains("""const val baseUrl: String = "https://example.com"""")
}
@Test
fun `generate handles empty string as nullable`() {
// Arrange
val json = """
{
"emptyValue": ""
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("val emptyValue: String? = null")
}
@Test
fun `generate handles null values`() {
// Arrange
val json = """
{
"nullValue": null
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("val nullValue: String? = null")
}
@Test
fun `generate handles boolean values`() {
// Arrange
val json = """
{
"isEnabled": true,
"isDisabled": false
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("const val isEnabled: Boolean = true")
assertThat(generatedCode).contains("const val isDisabled: Boolean = false")
}
@Test
fun `generate handles integer values as Long`() {
// Arrange
val json = """
{
"count": 42,
"negativeNumber": -100,
"largeNumber": 9223372036854775807
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("const val count: Long = 42")
assertThat(generatedCode).contains("const val negativeNumber: Long = -100")
// KotlinPoet formats large numbers with underscores
assertThat(generatedCode).contains("const val largeNumber: Long = 9_223_372_036_854_775_807")
}
@Test
fun `generate handles double values`() {
// Arrange
val json = """
{
"ratio": 3.14,
"negativeDouble": -2.5
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("const val ratio: Double = 3.14")
assertThat(generatedCode).contains("const val negativeDouble: Double = -2.5")
}
@Test
fun `generate handles string arrays`() {
// Arrange
val json = """
{
"items": ["one", "two", "three"]
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("val items: List<String> = listOf(")
assertThat(generatedCode).contains(""""one",""")
assertThat(generatedCode).contains(""""two",""")
assertThat(generatedCode).contains(""""three",""")
}
@Test
fun `generate handles empty arrays`() {
// Arrange
val json = """
{
"emptyList": []
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("val emptyList: List<String> = listOf(")
}
@Test
fun `generate handles nested objects`() {
// Arrange
val json = """
{
"database": {
"host": "localhost",
"port": 5432
}
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("object Database {")
assertThat(generatedCode).contains("""const val host: String = "localhost"""")
// KotlinPoet formats numbers >= 1000 with underscores
assertThat(generatedCode).contains("const val port: Long = 5_432")
}
@Test
fun `generate handles deeply nested objects`() {
// Arrange
val json = """
{
"level1": {
"level2": {
"level3": {
"deepValue": "deep"
}
}
}
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("object Level1 {")
assertThat(generatedCode).contains("object Level2 {")
assertThat(generatedCode).contains("object Level3 {")
assertThat(generatedCode).contains("""const val deepValue: String = "deep"""")
}
@Test
fun `generate converts dash-separated names to PascalCase`() {
// Arrange
val json = """
{
"cosmos-hub": {
"chainId": "cosmoshub-4"
}
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("object CosmosHub {")
}
@Test
fun `generate converts underscore-separated names to PascalCase`() {
// Arrange
val json = """
{
"api_config": {
"timeout": 30
}
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("object ApiConfig {")
}
@Test
fun `generate handles consecutive dashes in names`() {
// Arrange
val json = """
{
"cosmos--hub": {
"testValue": "test"
}
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("object CosmosHub {")
}
@Test
fun `generate handles trailing dash in names`() {
// Arrange
val json = """
{
"config-": {
"testValue": "test"
}
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("object Config {")
}
@Test
fun `generate handles special characters in string values`() {
// Arrange
val json = """
{
"query": "SELECT * FROM users WHERE name = 'John'",
"path": "C:\\Users\\test",
"newline": "line1\nline2",
"unicode": "Hello 世界"
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("const val query: String")
assertThat(generatedCode).contains("const val path: String")
assertThat(generatedCode).contains("const val newline: String")
assertThat(generatedCode).contains("const val unicode: String")
}
@Test
fun `generate handles arrays with special characters`() {
// Arrange
val json = """
{
"urls": [
"https://api.example.com/v1",
"https://api.example.com/v2?key=value&other=1"
]
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("val urls: List<String> = listOf(")
assertThat(generatedCode).contains(""""https://api.example.com/v1",""")
}
@Test
fun `generate adds file suppress annotations`() {
// Arrange
val json = """
{
"key": "value"
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("@file:Suppress(")
assertThat(generatedCode).contains(""""MaximumLineLength"""")
assertThat(generatedCode).contains(""""MaxLineLength"""")
assertThat(generatedCode).contains(""""Indentation"""")
}
@Test
fun `generate creates proper package declaration`() {
// Arrange
val json = """
{
"key": "value"
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("package com.tangem.datasource.local.config.environment.generated")
}
@Test
fun `generate creates object with correct name`() {
// Arrange
val json = """
{
"key": "value"
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("object GeneratedEnvironmentConfig {")
}
@Test
fun `generate adds kdoc with source file reference`() {
// Arrange
val json = """
{
"key": "value"
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).contains("Generated from")
assertThat(generatedCode).contains("Auto-generated - do not edit manually")
}
@Test
fun `generate removes public modifiers`() {
// Arrange
val json = """
{
"key": "value"
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
assertThat(generatedCode).doesNotContain("public object")
assertThat(generatedCode).doesNotContain("public val")
assertThat(generatedCode).doesNotContain("public const val")
}
@Test
fun `generate handles complex real-world config`() {
// Arrange
val json = """
{
"tangemComApiKey": "api-key-123",
"moonPayApiKey": "moon-pay-key",
"moonPayApiSecretKey": "secret-key",
"mercuryoWidgetId": "",
"blockchainSdkConfig": {
"blockchairApiKey": "blockchair-key",
"blockcypherTokens": ["token1", "token2"],
"quickNodeSolanaCredentials": {
"apiKey": "solana-key",
"subdomain": "solana-node"
}
},
"isFeatureEnabled": true,
"maxRetryCount": 3
}
""".trimIndent()
// Act
val generatedCode = generateAndReadOutput(json)
// Assert
// Top-level properties
assertThat(generatedCode).contains("""const val tangemComApiKey: String = "api-key-123"""")
assertThat(generatedCode).contains("val mercuryoWidgetId: String? = null")
assertThat(generatedCode).contains("const val isFeatureEnabled: Boolean = true")
assertThat(generatedCode).contains("const val maxRetryCount: Long = 3")
// Nested object
assertThat(generatedCode).contains("object BlockchainSdkConfig {")
assertThat(generatedCode).contains("""const val blockchairApiKey: String = "blockchair-key"""")
assertThat(generatedCode).contains("val blockcypherTokens: List<String>")
// Deeply nested object
assertThat(generatedCode).contains("object QuickNodeSolanaCredentials {")
}
private fun generateAndReadOutput(jsonContent: String): String {
val inputFile = File(tempDir, "config.json").apply {
writeText(jsonContent)
}
EnvironmentConfigGenerator.generate(inputFile, outputDir)
val generatedFile = File(
outputDir,
"com/tangem/datasource/local/config/environment/generated/GeneratedEnvironmentConfig.kt"
)
assertThat(generatedFile.exists()).isTrue()
return generatedFile.readText()
}
}