slack.lint.retrofit.RetrofitUsageDetector.kt Maven / Gradle / Ivy
The newest version!
// Copyright (C) 2021 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package slack.lint.retrofit
import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.LintFix
import com.android.tools.lint.detector.api.Location
import com.android.tools.lint.detector.api.Severity
import com.android.tools.lint.detector.api.SourceCodeScanner
import com.intellij.psi.PsiTypes
import org.jetbrains.uast.UElement
import org.jetbrains.uast.UMethod
import org.jetbrains.uast.sourcePsiElement
import slack.lint.util.removeNode
import slack.lint.util.safeReturnType
import slack.lint.util.sourceImplementation
/**
* A simple detector that validates basic Retrofit usage.
* - Retrofit endpoints must be annotated with a retrofit method API unless they're an extension
* function or private.
* - `@FormUrlEncoded` must use `@POST`, `@PUT`, or `@PATCH`.
* - `@Body` parameter requires `@POST`, `@PUT`, or `@PATCH`.
* - `@Field` parameters require it to be annotated with `@FormUrlEncoded`.
* - Must return something other than [Unit].
*/
class RetrofitUsageDetector : Detector(), SourceCodeScanner {
override fun getApplicableUastTypes() = listOf(UMethod::class.java)
override fun createUastHandler(context: JavaContext): UElementHandler {
return object : UElementHandler() {
override fun visitMethod(node: UMethod) {
val httpAnnotation =
HTTP_ANNOTATIONS.firstNotNullOfOrNull { node.findAnnotation(it) } ?: return
val returnType = node.safeReturnType(context)
if (
returnType == null ||
returnType == PsiTypes.voidType() ||
returnType.canonicalText == "kotlin.Unit"
) {
node.report(
"Retrofit endpoints should return something other than Unit/void.",
context.getNameLocation(node),
)
}
val httpAnnotationFqnc = httpAnnotation.qualifiedName ?: return
val isBodyMethod = httpAnnotationFqnc in HTTP_BODY_ANNOTATIONS
val annotationsByFqcn = node.uAnnotations.associateBy { it.qualifiedName }
val isFormUrlEncoded = FQCN_FORM_ENCODED in annotationsByFqcn
if (isFormUrlEncoded && !isBodyMethod) {
node.report("@FormUrlEncoded requires @PUT, @POST, or @PATCH.")
return
}
val hasPath =
(httpAnnotation.findDeclaredAttributeValue("value")?.evaluate() as? String)?.isNotBlank()
?: false
var hasBodyParam = false
var hasFieldParams = false
var hasUrlParam = false
for (parameter in node.uastParameters) {
if (parameter.hasAnnotation(FQCN_BODY)) {
if (!isBodyMethod) {
httpAnnotation.report("@Body param requires @PUT, @POST, or @PATCH.")
} else if (hasBodyParam) {
parameter.report("Duplicate @Body param!.")
} else {
hasBodyParam = true
}
} else if (
parameter.hasAnnotation(FQCN_FIELD) || parameter.hasAnnotation(FQCN_FIELD_MAP)
) {
hasFieldParams = true
if (!isFormUrlEncoded) {
val currentText = node.text
node.report(
"@Field(Map) param requires @FormUrlEncoded.",
quickFixData =
LintFix.create()
.replace()
.text(currentText)
.with("@$FQCN_FORM_ENCODED\n$currentText")
.autoFix()
.build(),
)
}
} else if (parameter.hasAnnotation(FQCN_URL)) {
if (hasPath) {
httpAnnotation.report("@Url param should be used with an empty path.")
} else {
hasUrlParam = true
}
}
}
if (isFormUrlEncoded) {
if (!hasFieldParams) {
val annotation = annotationsByFqcn.getValue(FQCN_FORM_ENCODED)
annotation.report(
"@FormUrlEncoded but has no @Field(Map) parameters.",
quickFixData = LintFix.create().removeNode(context, annotation.sourcePsiElement!!),
)
}
} else if (isBodyMethod && !hasBodyParam && !hasFieldParams) {
httpAnnotation.report("This annotation requires an `@Body` parameter.")
}
if (!hasPath && !hasUrlParam) {
httpAnnotation.report("Http path is empty but has no @Url parameter.")
}
}
private fun UElement.report(
briefDescription: String,
location: Location = context.getLocation(this),
quickFixData: LintFix? = null,
) {
context.report(ISSUE, location, briefDescription, quickfixData = quickFixData)
}
}
}
companion object {
private val HTTP_ANNOTATIONS =
setOf(
"retrofit2.http.DELETE",
"retrofit2.http.GET",
"retrofit2.http.HEAD",
"retrofit2.http.OPTIONS",
"retrofit2.http.PATCH",
"retrofit2.http.POST",
"retrofit2.http.PUT",
)
private val HTTP_BODY_ANNOTATIONS =
setOf("retrofit2.http.PATCH", "retrofit2.http.POST", "retrofit2.http.PUT")
private const val FQCN_FORM_ENCODED = "retrofit2.http.FormUrlEncoded"
private const val FQCN_FIELD = "retrofit2.http.Field"
private const val FQCN_FIELD_MAP = "retrofit2.http.FieldMap"
private const val FQCN_BODY = "retrofit2.http.Body"
private const val FQCN_URL = "retrofit2.http.Url"
val ISSUE: Issue =
Issue.create(
"RetrofitUsage",
"This is replaced by the caller.",
"This linter reports various common configuration issues with Retrofit.",
Category.CORRECTNESS,
10,
Severity.ERROR,
sourceImplementation(),
)
}
}