🗃️ Add JDBC-based preferences implementation
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Sun, 6 Apr 2025 07:24:45 +0000 (09:24 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Sun, 6 Apr 2025 16:19:37 +0000 (18:19 +0200)
build.gradle
src/main/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferences.kt [new file with mode: 0644]
src/main/resources/net/pterodactylus/sone/database/migration/V202501202137__create_sone_schema.sql [new file with mode: 0644]
src/main/resources/net/pterodactylus/sone/database/migration/V202501221927__create_options_table.sql [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferencesTest.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/sone/test/Database.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/sone/test/TestUtils.kt

index 27a35a0..4e452a9 100644 (file)
@@ -57,6 +57,7 @@ dependencies {
     implementation group: 'org.jsoup', name: 'jsoup', version: '1.18.1'
     implementation group: 'io.dropwizard.metrics', name: 'metrics-core', version: '4.2.27'
     implementation group: 'jakarta.activation', name: 'jakarta.activation-api', version: '2.1.3'
+    implementation group: 'com.h2database', name: 'h2', version: '1.4.200'
        implementation group: 'org.jooq', name: 'jooq', version: '3.14.16'
 
     testImplementation group: 'org.jetbrains.kotlin', name: 'kotlin-test-junit'
diff --git a/src/main/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferences.kt b/src/main/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferences.kt
new file mode 100644 (file)
index 0000000..ca5823a
--- /dev/null
@@ -0,0 +1,73 @@
+package net.pterodactylus.sone.database.h2
+
+import jakarta.inject.Inject
+import java.util.Locale
+import javax.sql.DataSource
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
+import net.pterodactylus.sone.core.Preferences
+import net.pterodactylus.sone.database.h2.jooq.tables.references.OPTIONS
+import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired
+import org.jooq.SQLDialect
+import org.jooq.impl.DSL
+
+class JdbcPreferences @Inject constructor(dataSource: DataSource, sqlDialect: SQLDialect) : Preferences {
+
+       override var insertionDelay: Int? by intOption(dataSource, sqlDialect)
+       override var postsPerPage: Int? by intOption(dataSource, sqlDialect)
+       override var imagesPerPage: Int? by intOption(dataSource, sqlDialect)
+       override var charactersPerPost: Int? by intOption(dataSource, sqlDialect)
+       override var postCutOffLength: Int? by intOption(dataSource, sqlDialect)
+       override var requireFullAccess: Boolean? by booleanOption(dataSource, sqlDialect)
+       override var fcpInterfaceActive: Boolean? by booleanOption(dataSource, sqlDialect)
+       override var fcpFullAccessRequired: FullAccessRequired? by enumOption(dataSource, sqlDialect, FullAccessRequired.entries::get)
+       override var strictFiltering: Boolean? by booleanOption(dataSource, sqlDialect)
+
+}
+
+private val KProperty<*>.asOptionName: String get() = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
+
+private fun intOption(dataSource: DataSource, sqlDialect: SQLDialect) = object : ReadWriteProperty<Preferences, Int?> {
+
+       override operator fun getValue(thisRef: Preferences, property: KProperty<*>): Int? = DSL.using(dataSource, sqlDialect)
+               .select(OPTIONS.VALUE).from(OPTIONS)
+               .where(OPTIONS.KEY.eq(property.asOptionName))
+               .fetch().getValue(0, OPTIONS.VALUE)
+
+       override fun setValue(thisRef: Preferences, property: KProperty<*>, value: Int?) = DSL.using(dataSource, sqlDialect)
+               .insertInto(OPTIONS, OPTIONS.KEY, OPTIONS.VALUE).values(property.asOptionName, value)
+               .onDuplicateKeyUpdate().set(OPTIONS.VALUE, value)
+               .execute().let { }
+
+}
+
+private fun <E : Enum<E>> enumOption(dataSource: DataSource, sqlDialect: SQLDialect, toEnum: (Int) -> E) = object : ReadWriteProperty<Preferences, E?> {
+
+       override operator fun getValue(thisRef: Preferences, property: KProperty<*>): E? = DSL.using(dataSource, sqlDialect)
+               .select(OPTIONS.VALUE).from(OPTIONS)
+               .where(OPTIONS.KEY.eq(property.asOptionName))
+               .fetch().getValue(0, OPTIONS.VALUE)
+               ?.let(toEnum)
+
+       override fun setValue(thisRef: Preferences, property: KProperty<*>, value: E?) = DSL.using(dataSource, sqlDialect)
+               .insertInto(OPTIONS, OPTIONS.KEY, OPTIONS.VALUE).values(property.asOptionName, value?.ordinal)
+               .onDuplicateKeyUpdate().set(OPTIONS.VALUE, value?.ordinal)
+               .execute().let { }
+
+}
+
+private fun booleanOption(dataSource: DataSource, sqlDialect: SQLDialect) = object : ReadWriteProperty<Preferences, Boolean?> {
+
+       override operator fun getValue(thisRef: Preferences, property: KProperty<*>): Boolean? = DSL.using(dataSource, sqlDialect)
+               .select(OPTIONS.VALUE).from(OPTIONS)
+               .where(OPTIONS.KEY.eq(property.asOptionName))
+               .fetch().getValue(0, OPTIONS.VALUE)?.let { it != 0 }
+
+       override fun setValue(thisRef: Preferences, property: KProperty<*>, value: Boolean?) = DSL.using(dataSource, sqlDialect)
+               .insertInto(OPTIONS, OPTIONS.KEY, OPTIONS.VALUE).values(property.asOptionName, value?.toInt())
+               .onDuplicateKeyUpdate().set(OPTIONS.VALUE, value?.toInt())
+               .execute().let { }
+
+}
+
+private fun Boolean.toInt() = if (this) 1 else 0
diff --git a/src/main/resources/net/pterodactylus/sone/database/migration/V202501202137__create_sone_schema.sql b/src/main/resources/net/pterodactylus/sone/database/migration/V202501202137__create_sone_schema.sql
new file mode 100644 (file)
index 0000000..cde12da
--- /dev/null
@@ -0,0 +1 @@
+CREATE SCHEMA sone;
diff --git a/src/main/resources/net/pterodactylus/sone/database/migration/V202501221927__create_options_table.sql b/src/main/resources/net/pterodactylus/sone/database/migration/V202501221927__create_options_table.sql
new file mode 100644 (file)
index 0000000..c9ad89a
--- /dev/null
@@ -0,0 +1,19 @@
+SET SCHEMA sone;
+
+CREATE TABLE options (
+    "key"   VARCHAR(21) PRIMARY KEY,
+    "value" INTEGER NULL DEFAULT NULL
+);
+
+INSERT INTO options ("key", "value")
+VALUES
+    ('InsertionDelay', NULL),
+    ('PostsPerPage', NULL),
+    ('ImagesPerPage', NULL),
+    ('CharactersPerPost', NULL),
+    ('PostCutOffLength', NULL),
+    ('RequireFullAccess', NULL),
+    ('FcpInterfaceActive', NULL),
+    ('FcpFullAccessRequired', NULL),
+    ('StrictFiltering', NULL)
+;
diff --git a/src/test/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferencesTest.kt b/src/test/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferencesTest.kt
new file mode 100644 (file)
index 0000000..3855ac0
--- /dev/null
@@ -0,0 +1,311 @@
+package net.pterodactylus.sone.database.h2
+
+import com.google.inject.Guice.createInjector
+import javax.sql.DataSource
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import net.pterodactylus.sone.database.h2.jooq.tables.references.OPTIONS
+import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.ALWAYS
+import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.NO
+import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.WRITING
+import net.pterodactylus.sone.test.getInstance
+import net.pterodactylus.sone.test.getMigrationSql
+import net.pterodactylus.sone.test.isProvidedBy
+import net.pterodactylus.sone.test.jOOQ
+import net.pterodactylus.sone.test.optionsTableMigrations
+import net.pterodactylus.sone.test.randomInMemoryDataSource
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.equalTo
+import org.jooq.SQLDialect
+
+class JdbcPreferencesTest {
+
+       @Test
+       fun `jdbc preferences can be created by guice`() {
+               val injector = createInjector(
+                       DataSource::class.isProvidedBy(dataSource),
+                       SQLDialect::class.isProvidedBy(SQLDialect.H2)
+               )
+               injector.getInstance<JdbcPreferences>()
+       }
+
+       @Test
+       fun `jdbc preferences can read current insertion delay`() {
+               verifyIntValueIsReadCorrectly("InsertionDelay", 58, jdbcPreferences::insertionDelay::get)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if insertion delay is null`() {
+               verifyIntValueIsReadCorrectly("InsertionDelay", null, jdbcPreferences::insertionDelay::get)
+       }
+
+       @Test
+       fun `jdbc preferences can write insertion delay`() {
+               verifyIntValueIsWrittenCorrectly("InsertionDelay", jdbcPreferences::insertionDelay::set, 47)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for insertion delay`() {
+               verifyIntValueIsWrittenCorrectly("InsertionDelay", jdbcPreferences::insertionDelay::set, null)
+       }
+
+       @Test
+       fun `jdbc preferences can read current posts per page`() {
+               verifyIntValueIsReadCorrectly("PostsPerPage", 17, jdbcPreferences::postsPerPage::get)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if posts per page is null`() {
+               verifyIntValueIsReadCorrectly("PostsPerPage", null, jdbcPreferences::postsPerPage::get)
+       }
+
+       @Test
+       fun `jdbc preferences can write posts per page`() {
+               verifyIntValueIsWrittenCorrectly("PostsPerPage", jdbcPreferences::postsPerPage::set, 19)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for posts per page`() {
+               verifyIntValueIsWrittenCorrectly("PostsPerPage", jdbcPreferences::postsPerPage::set, null)
+       }
+
+       @Test
+       fun `jdbc preferences can read current images per page`() {
+               verifyIntValueIsReadCorrectly("ImagesPerPage", 23, jdbcPreferences::imagesPerPage::get)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if images per page is null`() {
+               verifyIntValueIsReadCorrectly("ImagesPerPage", null, jdbcPreferences::imagesPerPage::get)
+       }
+
+       @Test
+       fun `jdbc preferences can write images per page`() {
+               verifyIntValueIsWrittenCorrectly("ImagesPerPage", jdbcPreferences::imagesPerPage::set, 27)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for images per page`() {
+               verifyIntValueIsWrittenCorrectly("ImagesPerPage", jdbcPreferences::imagesPerPage::set, null)
+       }
+
+       @Test
+       fun `jdbc preferences can read current characters per post`() {
+               verifyIntValueIsReadCorrectly("CharactersPerPost", 31, jdbcPreferences::charactersPerPost::get)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if characters per post is null`() {
+               verifyIntValueIsReadCorrectly("CharactersPerPost", null, jdbcPreferences::charactersPerPost::get)
+       }
+
+       @Test
+       fun `jdbc preferences can write characters per post`() {
+               verifyIntValueIsWrittenCorrectly("CharactersPerPost", jdbcPreferences::charactersPerPost::set, 37)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for characters per post`() {
+               verifyIntValueIsWrittenCorrectly("CharactersPerPost", jdbcPreferences::charactersPerPost::set, null)
+       }
+
+       @Test
+       fun `jdbc preferences can read current post cut-off length`() {
+               verifyIntValueIsReadCorrectly("PostCutOffLength", 41, jdbcPreferences::postCutOffLength::get)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if post cut-off length is null`() {
+               verifyIntValueIsReadCorrectly("PostCutOffLength", null, jdbcPreferences::postCutOffLength::get)
+       }
+
+       @Test
+       fun `jdbc preferences can write post cut-off length`() {
+               verifyIntValueIsWrittenCorrectly("PostCutOffLength", jdbcPreferences::postCutOffLength::set, 47)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for post cut-off length`() {
+               verifyIntValueIsWrittenCorrectly("PostCutOffLength", jdbcPreferences::postCutOffLength::set, null)
+       }
+
+       @Test
+       fun `jdbc preferences reads 0 for require full access flag as false`() {
+               verifyBooleanValueIsReadCorrectly("RequireFullAccess", 0, jdbcPreferences::requireFullAccess::get, false)
+       }
+
+       @Test
+       fun `jdbc preferences reads 1 for require full access flag as true`() {
+               verifyBooleanValueIsReadCorrectly("RequireFullAccess", 1, jdbcPreferences::requireFullAccess::get, true)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if require full access is null`() {
+               verifyBooleanValueIsReadCorrectly("RequireFullAccess", null, jdbcPreferences::requireFullAccess::get, null)
+       }
+
+       @Test
+       fun `jdbc preferences can write require full access flag when false`() {
+               verifyBooleanValueIsWrittenCorrectly("RequireFullAccess", false, jdbcPreferences::requireFullAccess::set, 0)
+       }
+
+       @Test
+       fun `jdbc preferences can write require full access flag when true`() {
+               verifyBooleanValueIsWrittenCorrectly("RequireFullAccess", true, jdbcPreferences::requireFullAccess::set, 1)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for require full access`() {
+               verifyBooleanValueIsWrittenCorrectly("RequireFullAccess", null, jdbcPreferences::requireFullAccess::set, null)
+       }
+
+       @Test
+       fun `jdbc preferences reads 0 for fcp interface active flag as false`() {
+               verifyBooleanValueIsReadCorrectly("FcpInterfaceActive", 0, jdbcPreferences::fcpInterfaceActive::get, false)
+       }
+
+       @Test
+       fun `jdbc preferences reads 1 for fcp interface active flag as true`() {
+               verifyBooleanValueIsReadCorrectly("FcpInterfaceActive", 1, jdbcPreferences::fcpInterfaceActive::get, true)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if fcp interface active is null`() {
+               verifyBooleanValueIsReadCorrectly("FcpInterfaceActive", null, jdbcPreferences::fcpInterfaceActive::get, null)
+       }
+
+       @Test
+       fun `jdbc preferences can write fcp interface active flag when false`() {
+               verifyBooleanValueIsWrittenCorrectly("FcpInterfaceActive", false, jdbcPreferences::fcpInterfaceActive::set, 0)
+       }
+
+       @Test
+       fun `jdbc preferences can write fcp interface active flag when true`() {
+               verifyBooleanValueIsWrittenCorrectly("FcpInterfaceActive", true, jdbcPreferences::fcpInterfaceActive::set, 1)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for fcp interface active`() {
+               verifyBooleanValueIsWrittenCorrectly("FcpInterfaceActive", null, jdbcPreferences::fcpInterfaceActive::set, null)
+       }
+
+       @Test
+       fun `jdbc preferences reads 0 for fcp full-access required as no`() {
+               verifyEnumValueIsReadCorrectly("FcpFullAccessRequired", 0, jdbcPreferences::fcpFullAccessRequired::get, NO)
+       }
+
+       @Test
+       fun `jdbc preferences reads 1 for fcp full-access required as writing`() {
+               verifyEnumValueIsReadCorrectly("FcpFullAccessRequired", 1, jdbcPreferences::fcpFullAccessRequired::get, WRITING)
+       }
+
+       @Test
+       fun `jdbc preferences reads 2 for fcp full-access required as always`() {
+               verifyEnumValueIsReadCorrectly("FcpFullAccessRequired", 2, jdbcPreferences::fcpFullAccessRequired::get, ALWAYS)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if fcp full-access required is null`() {
+               verifyEnumValueIsReadCorrectly("FcpFullAccessRequired", null, jdbcPreferences::fcpFullAccessRequired::get, null)
+       }
+
+       @Test
+       fun `jdbc preferences writes no for fcp full-access requires as 0`() {
+               verifyEnumValueIsWrittenCorrectly("FcpFullAccessRequired", NO, jdbcPreferences::fcpFullAccessRequired::set, 0)
+       }
+
+       @Test
+       fun `jdbc preferences writes writing for fcp full-access requires as 1`() {
+               verifyEnumValueIsWrittenCorrectly("FcpFullAccessRequired", WRITING, jdbcPreferences::fcpFullAccessRequired::set, 1)
+       }
+
+       @Test
+       fun `jdbc preferences writes always for fcp full-access requires as 2`() {
+               verifyEnumValueIsWrittenCorrectly("FcpFullAccessRequired", ALWAYS, jdbcPreferences::fcpFullAccessRequired::set, 2)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for fcp full-access required`() {
+               verifyEnumValueIsWrittenCorrectly("FcpFullAccessRequired", null, jdbcPreferences::fcpFullAccessRequired::set, null)
+       }
+
+       @Test
+       fun `jdbc preferences reads 0 for strict filtering flag as false`() {
+               verifyBooleanValueIsReadCorrectly("StrictFiltering", 0, jdbcPreferences::strictFiltering::get, false)
+       }
+
+       @Test
+       fun `jdbc preferences reads 1 for strict filtering flag as true`() {
+               verifyBooleanValueIsReadCorrectly("StrictFiltering", 1, jdbcPreferences::strictFiltering::get, true)
+       }
+
+       @Test
+       fun `jdbc preferences returns null if strict filtering is null`() {
+               verifyBooleanValueIsReadCorrectly("StrictFiltering", null, jdbcPreferences::strictFiltering::get, null)
+       }
+
+       @Test
+       fun `jdbc preferences can write strict filtering flag when false`() {
+               verifyBooleanValueIsWrittenCorrectly("StrictFiltering", false, jdbcPreferences::strictFiltering::set, 0)
+       }
+
+       @Test
+       fun `jdbc preferences can write strict filtering flag when true`() {
+               verifyBooleanValueIsWrittenCorrectly("StrictFiltering", true, jdbcPreferences::strictFiltering::set, 1)
+       }
+
+       @Test
+       fun `jdbc preferences can write null for strict filtering`() {
+               verifyBooleanValueIsWrittenCorrectly("StrictFiltering", null, jdbcPreferences::strictFiltering::set, null)
+       }
+
+       @BeforeTest
+       fun createOptionsTable() {
+               jOOQ.transaction { configuration ->
+                       getMigrationSql(*optionsTableMigrations).forEach(configuration.dsl()::execute)
+               }
+       }
+
+       private fun readConfigOption(key: String) =
+               jOOQ.select(OPTIONS.VALUE).from(OPTIONS).where(OPTIONS.KEY.eq(key)).fetchOne(OPTIONS.VALUE)
+
+       private fun insertConfigOption(key: String, value: Int?) =
+               jOOQ.update(OPTIONS).set(OPTIONS.VALUE, value).where(OPTIONS.KEY.eq(key)).execute()
+
+       private fun verifyIntValueIsReadCorrectly(propertyKey: String, intValue: Int?, getter: () -> Int?) {
+               insertConfigOption(propertyKey, intValue)
+               assertThat(getter(), equalTo(intValue))
+       }
+
+       private fun verifyIntValueIsWrittenCorrectly(propertyKey: String, setter: (Int?) -> Unit, intValue: Int?) {
+               setter(intValue)
+               assertThat(readConfigOption(propertyKey), equalTo(intValue))
+       }
+
+       private fun verifyBooleanValueIsReadCorrectly(propertyKey: String, databaseIntValue: Int?, getter: () -> Boolean?, expectedValue: Boolean?) {
+               insertConfigOption(propertyKey, databaseIntValue)
+               assertThat(getter(), equalTo(expectedValue))
+       }
+
+       private fun verifyBooleanValueIsWrittenCorrectly(propertyKey: String, booleanValue: Boolean?, setter: (Boolean?) -> Unit, databaseIntValue: Int?) {
+               setter(booleanValue)
+               assertThat(readConfigOption(propertyKey), equalTo(databaseIntValue))
+       }
+
+       @Suppress("SameParameterValue")
+       private fun <E> verifyEnumValueIsReadCorrectly(propertyKey: String, databaseIntValue: Int?, getter: () -> E?, expectedValue: E?) {
+               insertConfigOption(propertyKey, databaseIntValue)
+               assertThat(getter(), equalTo(expectedValue))
+       }
+
+       @Suppress("SameParameterValue")
+       private fun <E> verifyEnumValueIsWrittenCorrectly(propertyKey: String, enumValue: E?, setter: (E?) -> Unit, databaseIntValue: Int?) {
+               setter(enumValue)
+               assertThat(readConfigOption(propertyKey), equalTo(databaseIntValue))
+       }
+
+       private val dataSource = randomInMemoryDataSource
+       private val jOOQ = jOOQ(dataSource)
+       private val jdbcPreferences = JdbcPreferences(dataSource, SQLDialect.H2)
+
+}
diff --git a/src/test/kotlin/net/pterodactylus/sone/test/Database.kt b/src/test/kotlin/net/pterodactylus/sone/test/Database.kt
new file mode 100644 (file)
index 0000000..0b78234
--- /dev/null
@@ -0,0 +1,24 @@
+package net.pterodactylus.sone.test
+
+import javax.sql.DataSource
+import org.h2.jdbcx.JdbcDataSource
+import org.jooq.SQLDialect
+import org.jooq.impl.DSL
+
+fun testDataSource(jdbcUrl: String): DataSource =
+       JdbcDataSource().apply {
+               setUrl(jdbcUrl)
+       }
+
+val inMemoryDataSource = testDataSource("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
+val randomInMemoryDataSource get() = testDataSource("jdbc:h2:mem:${System.nanoTime()};DB_CLOSE_DELAY=-1")
+fun jOOQ(dataSource: DataSource = inMemoryDataSource) = DSL.using(dataSource, SQLDialect.H2)
+
+fun getMigrationSql(vararg migrationNames: String) =
+       migrationNames
+               .map { "/net/pterodactylus/sone/database/migration/$it.sql" }
+               .mapNotNull { resource(it, object {}.javaClass) }
+               .flatMap { it.split(";") }
+               .filterNot(String::isBlank)
+
+val optionsTableMigrations = arrayOf("V202501202137__create_sone_schema", "V202501221927__create_options_table")
index e49663f..2b9854d 100644 (file)
@@ -1,6 +1,9 @@
 package net.pterodactylus.sone.test
 
 import org.junit.rules.ExpectedException
+import java.io.InputStreamReader
+import java.nio.charset.Charset
+import java.nio.charset.StandardCharsets.UTF_8
 import sun.misc.Unsafe
 
 inline fun <reified O : Any> setField(instance: O, name: String, value: Any?) {
@@ -13,3 +16,9 @@ inline fun <reified O : Any> setField(instance: O, name: String, value: Any?) {
 }
 
 inline fun <reified T : Throwable> ExpectedException.expect() = expect(T::class.java)
+
+fun resource(name: String, resourceClass: Class<*>, encoding: Charset = UTF_8): String? = resourceClass.getResourceAsStream(name)?.use { inputStream ->
+       InputStreamReader(inputStream, encoding).use { inputStreamReader ->
+               inputStreamReader.readText()
+       }
+}