Skip to content

Commit

Permalink
rules: verify data classes are annotated with JsonIgnoreProperties(ig…
Browse files Browse the repository at this point in the history
…noreUnknown=true) (#20)
  • Loading branch information
chandan-satapathy authored Sep 18, 2024
1 parent 8b3e44f commit 7427384
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 1 deletion.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Inspections provided:
non-blocking alternatives
- **JerseyMethodParameterDefaultValue**: infer if a probable jersey method contains a parameter with a default value
- **JerseyMainThreadBlockingCall**: infer if a probable jersey resource method contains a main thread blocking call(runblocking)
- **JsonIgnorePropertiesOnDataClass**: infer if a data class is not annotated with `@JsonIgnoreProperties(ignoreUnknown=true)`.

## detekt run

Expand Down Expand Up @@ -139,3 +140,17 @@ sidekt:
active: true
# debug: 'stderr'
```

## JsonIgnorePropertiesOnDataClass
```yml
sidekt:
JsonIgnorePropertiesOnDataClass:
active: true
excludes: "com.example.excluded, another.package"
# debug: 'stderr'
```
#### JsonIgnorePropertiesOnDataClass
This rule enforces the use of the `@JsonIgnoreProperties` annotation on all data classes within the codebase.
Data classes must be annotated with `@JsonIgnoreProperties(ignoreUnknown = true)` to ensure compatibility with JSON deserialization.
### excludes
This allows exclusion of certain packages where you don't want to run the check
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ class SideKtRuleSetProvider : RuleSetProvider {
ResourceOnboardedOnAsec(config),
SQLQuerySniffer(config),
ImageQuality(config),
ImageWidth(config)
ImageWidth(config),
JsonIgnorePropertiesOnDataClass(config)
)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package io.github.thewisenerd.linters.sidekt.rules

import io.github.thewisenerd.linters.sidekt.helpers.Debugger
import io.gitlab.arturbosch.detekt.api.*
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtFile

/**
* This rule enforces the use of the `@JsonIgnoreProperties` annotation on all data classes within the codebase.
* Data classes must be annotated with `@JsonIgnoreProperties(ignoreUnknown = true)` to ensure compatibility with JSON deserialization.
*
* **Exclusions**: Packages listed in the `excludedPackages` configuration are excluded from this check.
*
* **Severity**: Maintainability
* **Debt**: 5 minutes
*
* **Usage**:
* ```yaml
* JsonIgnorePropertiesOnDataClass:
* active: true
* excludes: "com.example.excluded, another.package"
* ```
*/

class JsonIgnorePropertiesOnDataClass(config: Config) : Rule(config) {

companion object {
private const val JSON_IGNORE_ANNOTATION = "JsonIgnoreProperties"
private val JSON_IGNORE_ANNOTATION_FQ = FqName("com.fasterxml.jackson.annotation.JsonIgnoreProperties")
private val JSON_IGNORE_ANNOTATION_PACKAGE_FQ = JSON_IGNORE_ANNOTATION_FQ.parent()
private const val IGNORE_UNKNOWN = "ignoreUnknown"
}

private val debugStream by lazy {
valueOrNull<String>("debug")?.let {
Debugger.getOutputStreamForDebugger(it)
}
}

private fun readConfig(key: String, initial: Set<String>? = null): Set<String> {
val result = initial?.toMutableSet() ?: mutableSetOf()
valueOrNull<ArrayList<String>>(key)?.let {
result.addAll(it)
}
return result
}

private val excludedPackages: Set<String> by lazy {
readConfig("excludes")
}

override val issue: Issue = Issue(
id = JsonIgnorePropertiesOnDataClass::class.java.simpleName,
severity = Severity.Maintainability,
description = "JsonIgnoreProperties(ignoreUnknown = true) is not annotated on the data class",
debt = Debt.FIVE_MINS
)

override fun visitClass(kclass: KtClass) {
super.visitClass(kclass)
val dbg = Debugger.make(JsonIgnorePropertiesOnDataClass::class.java.simpleName, debugStream)

val packageName = kclass.containingKtFile.packageFqName.asString()

// Check if the current class's package is in the excluded list
if (excludedPackages.any { packageName.startsWith(it) }) {
dbg.i("Package $packageName is excluded under JsonIgnoreProperties check")
return
}

// Check if the class is a data class
if (kclass.isData()) {
// Check if @JsonIgnoreProperties(ignoreUnknown = true) annotation exists
val hasJsonIgnoreProperties = kclass.annotationEntries.any { annotation ->
if (annotation.shortName?.identifier == JSON_IGNORE_ANNOTATION) {
val correctPackageImport = hasDirectImport(
kclass.containingKtFile,
JSON_IGNORE_ANNOTATION_FQ
) || hasWildCardImport(kclass.containingKtFile, JSON_IGNORE_ANNOTATION_PACKAGE_FQ)
if (correctPackageImport) {
val ignoreUnknown = annotation.valueArguments.find {
it.getArgumentName()?.asName?.identifier == IGNORE_UNKNOWN
}

// assuming no compilation errors, getArgumentExpression() would be a boolean constant
ignoreUnknown != null && ignoreUnknown.getArgumentExpression()?.text == "true"
} else {
false
}
} else {
false
}
}

// Report if annotation is missing
if (!hasJsonIgnoreProperties) {
dbg.i("JsonIgnoreProperties annotation is missed for data class ${kclass.name}")
report(
CodeSmell(
issue = issue,
entity = Entity.from(kclass),
message = "Data class '${kclass.name}' should be annotated with @JsonIgnoreProperties(ignoreUnknown = true)"
)
)
} else {
dbg.i("JsonIgnoreProperties annotation is present for data class ${kclass.name}")
}
}
}
}

private fun hasDirectImport(file: KtFile, fqName: FqName): Boolean {
return file.importDirectives.any { import ->
import.importedFqName == fqName
}
}

// rudimentary, does not handle cases where people alias imports
private fun hasWildCardImport(file: KtFile, parentFqName: FqName): Boolean {
return file.importDirectives.any { import ->
import.isAllUnder && import.importPath?.fqName == parentFqName
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package io.github.thewisenerd.linters.sidekt

import io.github.thewisenerd.linters.sidekt.rules.JsonIgnorePropertiesOnDataClass
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Finding
import io.gitlab.arturbosch.detekt.api.SourceLocation
import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext
import org.junit.Test

class TestJsonIgnorePropOnDataClass {

companion object {
private val jsonIgnorePropOnDataClass = JsonIgnorePropertiesOnDataClass::class.java.simpleName

private fun ensureJsonIgnorePropOnDataClassFindings(
findings: List<Finding>,
requiredFindings: List<SourceLocation>
) = TestUtils.ensureFindings(jsonIgnorePropOnDataClass, findings, requiredFindings)
}

private val testConfig = object : Config {
override fun subConfig(key: String): Config = this

@Suppress("UNCHECKED_CAST")
override fun <T : Any> valueOrNull(key: String): T? {
return when (key) {
"active" -> true as? T
"debug" -> "stderr" as? T
else -> null
}
}
}

private val testConfigWithExcludedPackage = object : Config {
override fun subConfig(key: String): Config = this

@Suppress("UNCHECKED_CAST")
override fun <T : Any> valueOrNull(key: String): T? {
return when (key) {
"active" -> true as? T
"debug" -> "stderr" as? T
"excludes" -> arrayListOf("io.github.thewisenerd", "io.github.thewisenerd.linters") as? T
else -> null
}
}
}
private val subject = JsonIgnorePropertiesOnDataClass(testConfig)
private val subjectWithExcludedPackage = JsonIgnorePropertiesOnDataClass(testConfigWithExcludedPackage)

@Test
fun testDataClasses() {
val code = TestUtils.readFile("TestJsonIgnorePropertiesOnDataClass.kt")
val findings = subject.compileAndLintWithContext(TestUtils.env, code)
ensureJsonIgnorePropOnDataClassFindings(
findings,
listOf(
SourceLocation(11, 1),
SourceLocation(16, 1),
SourceLocation(21, 1)
)
)
}

@Test
fun testDataClassesWildCardImport() {
val code = TestUtils.readFile("TestJsonIgnorePropertiesOnDataClass.kt").replace(
"import com.fasterxml.jackson.annotation.JsonIgnoreProperties",
"import com.fasterxml.jackson.annotation.*"
)
val findings = subject.compileAndLintWithContext(TestUtils.env, code)
ensureJsonIgnorePropOnDataClassFindings(
findings,
listOf(
SourceLocation(11, 1),
SourceLocation(16, 1),
SourceLocation(21, 1)
)
)
}

@Test
fun testDataClassesIncorrectImport() {
val code = TestUtils.readFile("TestJsonIgnorePropertiesOnDataClass.kt").replace(
"import com.fasterxml.jackson.annotation.JsonIgnoreProperties",
"import org.apache.tinkerpop.shaded.jackson.annotation.JsonIgnoreProperties"
)
val findings = subject.compileAndLintWithContext(TestUtils.env, code)
ensureJsonIgnorePropOnDataClassFindings(
findings,
listOf(
SourceLocation(5, 1),
SourceLocation(11, 1),
SourceLocation(16, 1),
SourceLocation(21, 1),
SourceLocation(27, 1)
)
)
}

@Test
fun testDataClassesWithExcludedPackage() {
val code = TestUtils.readFile("TestJsonIgnorePropertiesOnDataClass.kt")
val findings = subjectWithExcludedPackage.compileAndLintWithContext(TestUtils.env, code)
ensureJsonIgnorePropOnDataClassFindings(
findings,
emptyList()
)
}
}
31 changes: 31 additions & 0 deletions src/test/resources/TestJsonIgnorePropertiesOnDataClass.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.github.thewisenerd.linters.sidekt

import com.fasterxml.jackson.annotation.JsonIgnoreProperties

@JsonIgnoreProperties(ignoreUnknown = true)
data class SampleWithAnnotation(
val firstVariable: String,
val secondVariable: Int
)

data class SampleWithoutAnnotation(
val firstVariable: String,
val secondVariable: Int
)

data class SampleWithoutAnnotationWithJvmOverloads @JvmOverloads constructor(
val firstVariable: String,
val secondVariable: Int = 1
)

@JsonIgnoreProperties(allowSetters = true)
data class SampleWithWrongAnnotation(
val firstVariable: String,
val secondVariable: Int
)

@JsonIgnoreProperties(allowSetters = true, ignoreUnknown = true)
data class SampleWithMultipleAnnotation(
val firstVariable: String,
val secondVariable: Int
)

0 comments on commit 7427384

Please sign in to comment.