diff --git a/build.gradle.kts b/build.gradle.kts index e477068..064a438 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,7 @@ plugins { id("net.thauvin.erik.gradle.semver") version "1.0.4" id("org.jetbrains.dokka") version "0.9.18" id("org.jetbrains.kotlin.kapt").version("1.3.50") + id("org.jetbrains.kotlin.plugin.serialization").version("1.3.50") id("org.jmailen.kotlinter") version "2.1.1" id("org.sonarqube") version "2.7.1" } @@ -59,6 +60,7 @@ dependencies { compile("com.squareup.okhttp3:logging-interceptor:4.2.0") compile(kotlin("stdlib")) + compile("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.13.0") testImplementation("org.mockito:mockito-core:3.0.0") testImplementation("org.testng:testng:7.0.0") diff --git a/detekt-baseline.xml b/detekt-baseline.xml index 04a8573..4af77c9 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -2,12 +2,15 @@ - ComplexMethod:Akismet.kt$Akismet$private fun buildFormBody( userIp: String, userAgent: String, referrer: String, permalink: String, type: String, author: String, authorEmail: String, authorUrl: String, content: String, dateGmt: String, postModifiedGmt: String, blogLang: String, blogCharset: String, userRole: String, isTest: Boolean, recheckReason: String, other: Map<String, String> ): FormBody - LongMethod:Akismet.kt$Akismet$private fun buildFormBody( userIp: String, userAgent: String, referrer: String, permalink: String, type: String, author: String, authorEmail: String, authorUrl: String, content: String, dateGmt: String, postModifiedGmt: String, blogLang: String, blogCharset: String, userRole: String, isTest: Boolean, recheckReason: String, other: Map<String, String> ): FormBody + ComplexMethod:Akismet.kt$Akismet$ @JvmOverloads fun executeMethod(apiUrl: HttpUrl?, formBody: FormBody, trueOnError: Boolean = false): Boolean + ComplexMethod:Akismet.kt$Akismet$private fun buildFormBody(comment: AkismetComment): FormBody + ComplexMethod:AkismetComment.kt$AkismetComment$ @Suppress("DuplicatedCode") override fun equals(other: Any?): Boolean MagicNumber:Akismet.kt$Akismet$12 MagicNumber:Akismet.kt$Akismet.<no name provided>$8 - NestedBlockDepth:Akismet.kt$Akismet$ @Suppress("MemberVisibilityCanBePrivate") protected fun executeMethod(apiUrl: HttpUrl?, formBody: FormBody): Boolean - NestedBlockDepth:AkismetTest.kt$fun getApiKey(): String + MaxLineLength:Akismet.kt$Akismet$/** * The _x-akismet-pro-tip_ header from the last operation, if any. * * If the _x-akismet-pro-tip_ header is set to discard, then Akismet has determined that the comment is blatant spam, * and you can safely discard it without saving it in any spam queue. * * Read more about this feature in this * [Akismet blog post](https://blog.akismet.com/2014/04/23/theres-a-ninja-in-your-akismet/). * * @see [Akismet.isDiscard] */ @Suppress("MemberVisibilityCanBePrivate") var proTip: String = "" private set + NestedBlockDepth:Akismet.kt$Akismet$ @JvmOverloads fun executeMethod(apiUrl: HttpUrl?, formBody: FormBody, trueOnError: Boolean = false): Boolean + NestedBlockDepth:AkismetTest.kt$fun getKey(key: String): String + ReturnCount:Akismet.kt$Akismet$ @JvmOverloads fun executeMethod(apiUrl: HttpUrl?, formBody: FormBody, trueOnError: Boolean = false): Boolean TooManyFunctions:Akismet.kt$Akismet diff --git a/src/main/kotlin/net/thauvin/erik/akismet/Akismet.kt b/src/main/kotlin/net/thauvin/erik/akismet/Akismet.kt index 164317f..c32fda5 100644 --- a/src/main/kotlin/net/thauvin/erik/akismet/Akismet.kt +++ b/src/main/kotlin/net/thauvin/erik/akismet/Akismet.kt @@ -31,6 +31,8 @@ */ package net.thauvin.erik.akismet +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonConfiguration import net.thauvin.erik.semver.Version import okhttp3.FormBody import okhttp3.HttpUrl @@ -108,6 +110,16 @@ open class Akismet(apiKey: String) { var response: String = "" private set + /** + * The error message. + * + * The error (IO, empty response from Akismet, etc.) message is also logged as a warning. + * + * @see [Akismet.checkComment] + */ + var errorMessage: String = "" + private set + /** * The _x-akismet-pro-tip_ header from the last operation, if any. * @@ -269,6 +281,15 @@ open class Akismet(apiKey: String) { return executeMethod(buildApiUrl("submit-ham"), buildFormBody(comment)) } + /** + * (Re)Create a [comment][AkismetComment] from a JSON string. + * + * @see [AkismetComment.toString] + */ + fun jsonComment(json: String): AkismetComment { + return Json(JsonConfiguration.Stable).parse(AkismetComment.serializer(), json) + } + /** * Convert a date to a UTC timestamp. (ISO 8601) * @@ -304,7 +325,11 @@ open class Akismet(apiKey: String) { fun executeMethod(apiUrl: HttpUrl?, formBody: FormBody, trueOnError: Boolean = false): Boolean { reset() if (apiUrl != null) { - val request = Request.Builder().url(apiUrl).post(formBody).header("User-Agent", buildUserAgent()).build() + val request = if (formBody.size == 0) { + Request.Builder().url(apiUrl).header("User-Agent", buildUserAgent()).build() + } else { + Request.Builder().url(apiUrl).post(formBody).header("User-Agent", buildUserAgent()).build() + } try { val result = client.newCall(request).execute() httpStatusCode = result.code @@ -317,26 +342,28 @@ open class Akismet(apiKey: String) { if (response == "valid" || response == "true" || response.startsWith("Thanks")) { return true } else if (response != "false" && response != "invalid") { - logger.warning("Unexpected response: $body") - return trueOnError + errorMessage = "Unexpected response: " + if (body.isBlank()) "(0-byte body)" else body } } else { val message = "An empty response was received from Akismet." - if (debugHelp.isNotBlank()) { - logger.warning("$message: $debugHelp") + errorMessage = if (debugHelp.isNotBlank()) { + "$message: $debugHelp" } else { - logger.warning(message) + message } - return trueOnError } } catch (e: IOException) { - logger.log(Level.SEVERE, "An IO error occurred while communicating with the Akismet service.", e) - return trueOnError + errorMessage = "An IO error occurred while communicating with the Akismet service." } } else { - logger.severe("Invalid API end point URL.") + errorMessage = "Invalid API end point URL." + } + + if (errorMessage.isNotEmpty()) { + logger.warning(errorMessage) return trueOnError } + return false } @@ -345,6 +372,7 @@ open class Akismet(apiKey: String) { */ fun reset() { debugHelp = "" + errorMessage = "" httpStatusCode = 0 isDiscard = false isVerifiedKey = false diff --git a/src/main/kotlin/net/thauvin/erik/akismet/AkismetComment.kt b/src/main/kotlin/net/thauvin/erik/akismet/AkismetComment.kt index 7d6824e..1331e1b 100644 --- a/src/main/kotlin/net/thauvin/erik/akismet/AkismetComment.kt +++ b/src/main/kotlin/net/thauvin/erik/akismet/AkismetComment.kt @@ -32,6 +32,9 @@ package net.thauvin.erik.akismet +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonConfiguration import javax.servlet.http.HttpServletRequest /** @@ -50,6 +53,7 @@ import javax.servlet.http.HttpServletRequest * @param userIp IP address of the comment submitter. * @param userAgent User agent string of the web browser submitting the comment. */ +@Serializable open class AkismetComment(val userIp: String, val userAgent: String) { @Suppress("unused") companion object { @@ -171,27 +175,68 @@ open class AkismetComment(val userIp: String, val userAgent: String) { } /** - * Returns a string representation of the comment. + * Returns a JSON representation of the comment. + * + * @see [Akismet.jsonComment] */ override fun toString(): String { - return this.javaClass.simpleName + - "(userIp=$userIp" + - ", userAgent=$userAgent" + - ", referrer=$referrer" + - ", permalink=$permalink" + - ", type=$type" + - ", author=$author" + - ", authorEmail=$authorEmail" + - ", authorUrl=$authorUrl" + - ", content=$content" + - ", dateGmt=$dateGmt" + - ", postModifiedGmt=$postModifiedGmt" + - ", blogLang=$blogLang" + - ", blogCharset=$blogCharset" + - ", userRole=$userRole" + - ", isTest=$isTest" + - ", recheckReason=$recheckReason" + - ", serverEnv=$serverEnv)" + return Json(JsonConfiguration.Stable).stringify(serializer(), this) + } + + /** + * Indicates whether some other object is _equal to_ this one. + */ + @Suppress("DuplicatedCode") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AkismetComment + + if (userIp != other.userIp) return false + if (userAgent != other.userAgent) return false + if (referrer != other.referrer) return false + if (permalink != other.permalink) return false + if (type != other.type) return false + if (author != other.author) return false + if (authorEmail != other.authorEmail) return false + if (authorUrl != other.authorUrl) return false + if (content != other.content) return false + if (dateGmt != other.dateGmt) return false + if (postModifiedGmt != other.postModifiedGmt) return false + if (blogLang != other.blogLang) return false + if (blogCharset != other.blogCharset) return false + if (userRole != other.userRole) return false + if (isTest != other.isTest) return false + if (recheckReason != other.recheckReason) return false + if (serverEnv != other.serverEnv) return false + + return true + } + + /** + * Returns a hash code value for the object. + */ + @Suppress("DuplicatedCode") + override fun hashCode(): Int { + var result = userIp.hashCode() + result = 31 * result + userAgent.hashCode() + result = 31 * result + referrer.hashCode() + result = 31 * result + permalink.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + author.hashCode() + result = 31 * result + authorEmail.hashCode() + result = 31 * result + authorUrl.hashCode() + result = 31 * result + content.hashCode() + result = 31 * result + dateGmt.hashCode() + result = 31 * result + postModifiedGmt.hashCode() + result = 31 * result + blogLang.hashCode() + result = 31 * result + blogCharset.hashCode() + result = 31 * result + userRole.hashCode() + result = 31 * result + isTest.hashCode() + result = 31 * result + recheckReason.hashCode() + result = 31 * result + serverEnv.hashCode() + return result } } diff --git a/src/test/kotlin/net/thauvin/erik/akismet/AkismetTest.kt b/src/test/kotlin/net/thauvin/erik/akismet/AkismetTest.kt index cfafa5e..ca11761 100644 --- a/src/test/kotlin/net/thauvin/erik/akismet/AkismetTest.kt +++ b/src/test/kotlin/net/thauvin/erik/akismet/AkismetTest.kt @@ -37,6 +37,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.mockito.Mockito import org.testng.Assert.assertEquals import org.testng.Assert.assertFalse +import org.testng.Assert.assertNotEquals import org.testng.Assert.assertTrue import org.testng.Assert.expectThrows import org.testng.annotations.BeforeClass @@ -179,12 +180,37 @@ class AkismetTest { } } + @Test + fun testEmptyResponse() { + assertTrue( + akismet.executeMethod( + "https://postman-echo.com/status/200".toHttpUrlOrNull(), FormBody.Builder().build(), true + ) + ) + val expected = "{\"status\":200}" + assertEquals(akismet.response, expected, expected) + assertTrue(akismet.errorMessage.contains(expected), "errorMessage contains $expected") + } + + @Test + fun testProTipResponse() { + assertFalse( + akismet.executeMethod( + "https://postman-echo.com/response-headers?x-akismet-pro-tip=test".toHttpUrlOrNull(), + FormBody.Builder().build() + ) + ) + assertEquals(akismet.proTip, "test") + } + @Test fun resetTest() { akismet.reset() + + //@TODO fix assertTrue( - akismet.debugHelp == "" && akismet.httpStatusCode == 0 && !akismet.isDiscard && - !akismet.isVerifiedKey && akismet.proTip == "" && akismet.response == "" + akismet.debugHelp == "" && akismet.errorMessage == "" && akismet.httpStatusCode == 0 && + !akismet.isDiscard && !akismet.isVerifiedKey && akismet.proTip == "" && akismet.response == "" ) } @@ -225,18 +251,25 @@ class AkismetTest { assertTrue(akismet.submitSpam(mockComment), "submitHam(request)") } + @Test + fun testJsonComment() { + val jsonComment = akismet.jsonComment(mockComment.toString()) + + assertEquals(jsonComment, mockComment, "equals") + assertEquals(jsonComment.hashCode(), mockComment.hashCode(), "hashcode") + + assertNotEquals(jsonComment, comment, "json is different") + assertNotEquals(jsonComment.hashCode(), comment.hashCode(), "json hashcode is different") + } + @Test fun testBuildUserAgent() { val libAgent = "${GeneratedVersion.PROJECT}/${GeneratedVersion.VERSION}" - assertEquals( - akismet.buildUserAgent(), libAgent, "libAgent" - ) - akismet.applicationName = "My App" + assertEquals(akismet.buildUserAgent(), libAgent, "libAgent") + + akismet.applicationName = "My App" + assertEquals(akismet.buildUserAgent(), libAgent, "libAgent, no app") - assertEquals( - akismet.buildUserAgent(), libAgent, "libAgent, no app" - ) - akismet.applicationVersion = "1.0-test" assertEquals( akismet.buildUserAgent(), "${akismet.applicationName}/${akismet.applicationVersion} | $libAgent",