Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ public class AnchorEvent {
Type type;
String id;
String sep;

@SerializedName("client_name")
String clientName;

@SerializedName("client_domain")
String clientDomain;

GetTransactionResponse transaction;
GetQuoteResponse quote;
Sep12GetCustomerResponse customer;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package org.stellar.anchor.api.platform;

import com.google.gson.annotations.SerializedName;
import java.util.Map;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.stellar.anchor.api.asset.AssetInfo;

/**
* The response body of the GET /transactions/{id} endpoint of the Platform API.
Expand All @@ -17,6 +17,7 @@
@SuperBuilder
@NoArgsConstructor
public class GetTransactionResponse extends PlatformTransactionData {
@SerializedName("funding_method")
String fundingMethod;

/**
Expand All @@ -26,13 +27,4 @@ public class GetTransactionResponse extends PlatformTransactionData {
* cross-reference {@link PlatformTransactionData#getRequiredInfoUpdates()}.
*/
Map<String, String> fields;

/**
* The real per-field metadata (description/choices/optional) backing {@link
* PlatformTransactionData#getRequiredInfoUpdates()}, keyed by field name -- populated from the
* transaction's own stored Sep31Info.Fields, so a status callback or client status API consumer
* gets the actual metadata instead of one synthesized from the field name. Currently only
* populated for SEP-31.
*/
Map<String, AssetInfo.Field> requiredInfoUpdatesFields;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.stellar.anchor.api.sep.sep10;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.annotations.SerializedName;
import lombok.Builder;
import lombok.Data;

Expand All @@ -17,8 +18,10 @@ public class ChallengeRequest {
String memo;

@JsonProperty("home_domain")
@SerializedName("home_domain")
String homeDomain;

@JsonProperty("client_domain")
@SerializedName("client_domain")
String clientDomain;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.stellar.anchor.api.sep.sep45;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.annotations.SerializedName;
import lombok.Builder;
import lombok.Data;

Expand All @@ -16,8 +17,10 @@ public class ChallengeRequest {
private String account;

@JsonProperty("home_domain")
@SerializedName("home_domain")
private String homeDomain;

@JsonProperty("client_domain")
@SerializedName("client_domain")
private String clientDomain;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.stellar.anchor.api.sep.sep45;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.annotations.SerializedName;
import lombok.Builder;
import lombok.Data;

Expand All @@ -14,8 +15,10 @@
@Data
public class ChallengeResponse {
@JsonProperty("authorization_entries")
@SerializedName("authorization_entries")
private String authorizationEntries;

@JsonProperty("network_passphrase")
@SerializedName("network_passphrase")
private String networkPassphrase;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.stellar.anchor.api.sep.sep45;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.annotations.SerializedName;
import lombok.Builder;
import lombok.Data;

Expand All @@ -14,5 +15,6 @@
@Data
public class ValidationRequest {
@JsonProperty("authorization_entries")
@SerializedName("authorization_entries")
private String authorizationEntries;
}
1 change: 1 addition & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies {
implementation(project(":lib-util"))

testImplementation(libs.coroutines.core)
testImplementation(libs.jackson.annotations)
testImplementation(libs.okhttp3.mockserver)
testImplementation(libs.servlet.api)
testImplementation(libs.slf4j.api)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ public ValidationResponse validate(ValidationRequest request) throws AnchorExcep
SorobanAuthorizationEntries authEntries;
try {
authEntries = SorobanAuthorizationEntries.fromXdrBase64(request.getAuthorizationEntries());
} catch (IOException e) {
} catch (IOException | IllegalArgumentException e) {
// a value that is not base64 at all fails with IllegalArgumentException, not IOException
throw new BadRequestException("Failed to decode auth entries");
}
SorobanAuthorizationEntry[] entries = authEntries.getSorobanAuthorizationEntries();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package org.stellar.anchor.util

import com.google.gson.annotations.SerializedName
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider

/**
* The servers serialize every body with Gson, which ignores `@JsonProperty`: a field annotated for
* Jackson only goes out under its raw Java name. This guard fails when a DTO in
* `org.stellar.anchor.api` carries a `@JsonProperty` without a `@SerializedName` of the same value.
* Only `value` counts: Gson writes a field under `value` and merely also reads `alternate`. The
* scan reads Jackson's annotation by name, while the synthetic classes of the self-test use it
* directly, hence the test-only `jackson-annotations` dependency in `core/build.gradle.kts`.
*
* The complement is a field with no annotation of either kind, which Gson writes under its Java
* name: no serialized field may have a wire key with an upper-case letter, apart from the named
* exceptions in [camelCaseWireKeys].
*/
class JacksonOnlyDtoGuardTest {
private val jsonProperty = "com.fasterxml.jackson.annotation.JsonProperty"

private fun jacksonName(field: Field): String? =
field.annotations
.firstOrNull { it.annotationClass.java.name == jsonProperty }
?.let { it.annotationClass.java.getMethod("value").invoke(it) as String }

private fun serializedNames(field: Field): Set<String> =
field.getAnnotation(SerializedName::class.java)?.let { setOf(it.value) } ?: emptySet()

private fun violations(classes: Collection<Class<*>>): List<String> =
classes.flatMap { cls ->
cls.declaredFields
.filter { !Modifier.isStatic(it.modifiers) && !Modifier.isTransient(it.modifiers) }
.mapNotNull { field ->
val name = jacksonName(field)
if (name != null && name !in serializedNames(field)) {
"${cls.name}.${field.name} has @JsonProperty(\"$name\") but no @SerializedName(\"$name\")"
} else null
}
}

// the request DTOs overwrite requestClientIpAddress from the connection and never read it from
// the body; SepDepositInfo is only handed between the platform's own classes
private val camelCaseWireKeys =
setOf(
"org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest.requestClientIpAddress",
"org.stellar.anchor.api.sep.sep6.StartDepositRequest.requestClientIpAddress",
"org.stellar.anchor.api.sep.sep6.StartDepositExchangeRequest.requestClientIpAddress",
"org.stellar.anchor.api.sep.sep6.StartWithdrawRequest.requestClientIpAddress",
"org.stellar.anchor.api.sep.sep6.StartWithdrawExchangeRequest.requestClientIpAddress",
"org.stellar.anchor.api.shared.SepDepositInfo.stellarAddress",
)

private fun scan(everyClass: Boolean): List<Class<*>> {
val scanner =
object : ClassPathScanningCandidateComponentProvider(false) {
// every class counts, not only concrete top-level ones
override fun isCandidateComponent(beanDefinition: AnnotatedBeanDefinition) =
everyClass || super.isCandidateComponent(beanDefinition)
}
scanner.addIncludeFilter { _, _ -> true }
return scanner.findCandidateComponents("org.stellar.anchor.api").map {
Class.forName(it.beanClassName, false, javaClass.classLoader)
}
}

private fun apiClasses(): List<Class<*>> = scan(true)

// Gson never reads a Lombok builder, writes an enum by constant name, and no exception reaches a
// response body as itself
private fun isSerialized(cls: Class<*>): Boolean =
!cls.isEnum &&
!cls.isInterface &&
!cls.isAnonymousClass &&
!Throwable::class.java.isAssignableFrom(cls) &&
!(cls.enclosingClass != null && cls.simpleName.endsWith("Builder"))

private fun camelCaseKeys(classes: Collection<Class<*>>): List<String> =
classes
.filter { isSerialized(it) }
.flatMap { cls ->
cls.declaredFields
.filter {
!Modifier.isStatic(it.modifiers) &&
!Modifier.isTransient(it.modifiers) &&
!it.isSynthetic
}
.filter { field ->
val key = field.getAnnotation(SerializedName::class.java)?.value ?: field.name
key.any { it.isUpperCase() } && "${cls.name}.${field.name}" !in camelCaseWireKeys
}
.map { "${cls.name}.${it.name}" }
}

// WIRE-10
@Test
fun `test no DTO field carries a JsonProperty without a matching SerializedName`() {
val classes = apiClasses()

// the scan must see the DTOs, or an empty result would pass for the wrong reason
val annotated =
classes
.flatMap { cls -> cls.declaredFields.map { "${cls.name}.${it.name}" to jacksonName(it) } }
.filter { it.second != null }
.map { it.first }
val sentinels =
listOf(
"org.stellar.anchor.api.sep.sep45.ValidationRequest.authorizationEntries",
"org.stellar.anchor.api.sep.sep10.ChallengeRequest.clientDomain",
"org.stellar.anchor.api.shared.Refunds.amountRefunded",
"org.stellar.anchor.api.platform.GetQuoteResponse.sellAmount",
)
assertEquals(emptyList<String>(), sentinels.filter { it !in annotated })

assertEquals(emptyList<String>(), violations(classes))
}

// WIRE-10
@Test
fun `test the guard names a class and field that carry only a JsonProperty`() {
class JacksonOnly {
@com.fasterxml.jackson.annotation.JsonProperty("home_domain") var homeDomain: String? = null
}
class Both {
@com.fasterxml.jackson.annotation.JsonProperty("home_domain")
@SerializedName("home_domain")
var homeDomain: String? = null
}
class Alternate {
@com.fasterxml.jackson.annotation.JsonProperty("home_domain")
@SerializedName(value = "homeDomain", alternate = ["home_domain"])
var homeDomain: String? = null
}
class Mismatched {
@com.fasterxml.jackson.annotation.JsonProperty("home_domain")
@SerializedName("homeDomain")
var homeDomain: String? = null
}

val found =
violations(
listOf(
JacksonOnly::class.java,
Both::class.java,
Alternate::class.java,
Mismatched::class.java,
)
)

assertEquals(3, found.size, found.toString())
assertTrue(found[0].contains("JacksonOnly.homeDomain"), found[0])
assertTrue(found[1].contains("Alternate.homeDomain"), found[1])
assertTrue(found[2].contains("Mismatched.homeDomain"), found[2])
}

// the scan must reach what Spring's default filter skips (abstract, nested and builder classes),
// or a DTO declared that way goes unchecked while the sentinels above still pass
@Test
fun `test the scan reaches classes beyond Spring's default candidate filter`() {
val widened = apiClasses().map { it.name }.toSet()
val default = scan(false).map { it.name }.toSet()

assertTrue(default.all { it in widened })
assertTrue(widened.size > default.size, "widened=${widened.size} default=${default.size}")
}

// WIRE-12
@Test
fun `test no serialized DTO field is published under a camelCase wire key`() {
assertEquals(emptyList<String>(), camelCaseKeys(apiClasses()))
}

// WIRE-12
@Test
fun `test the camelCase check names a field with no annotation`() {
class Bare {
var payoutRailHint: String? = null
}
class Named {
@SerializedName("payout_rail_hint") var payoutRailHint: String? = null
}
class PlainKey {
var amount: String? = null
}

val found = camelCaseKeys(listOf(Bare::class.java, Named::class.java, PlainKey::class.java))

assertEquals(1, found.size, found.toString())
assertTrue(found[0].contains("Bare.payoutRailHint"), found[0])
}
}
Loading
Loading