├── .editorconfig
├── .github
├── dependabot.yml
└── workflows
│ ├── check-readme-links.sh
│ └── ci.yml
├── .gitignore
├── .idea
├── .gitignore
├── .name
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── compiler.xml
├── dataSources.xml
├── file.template.settings.xml
├── gradle.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── jarRepositories.xml
├── kotlinc.xml
├── misc.xml
├── runConfigurations
│ ├── All_tests.xml
│ └── sample_Launcher.xml
├── sqldialects.xml
└── vcs.xml
├── .jitpack.yml
├── CHANGELOG.md
├── CNAME
├── LICENSE
├── README.md
├── TUTORIAL.md
├── build.gradle.kts
├── core
├── README.md
├── build.gradle.kts
├── src
│ ├── Cache.kt
│ ├── Config.kt
│ ├── Converter.kt
│ ├── Decimal.kt
│ ├── Extensions.kt
│ ├── Logger.kt
│ ├── Registry.kt
│ ├── TSID.kt
│ ├── Types.kt
│ ├── Values.kt
│ └── http
│ │ └── TypedHttpClient.kt
└── test
│ ├── CacheTest.kt
│ ├── ConverterTest.kt
│ ├── DecimalTest.kt
│ ├── LoggerTest.kt
│ ├── TSIDTest.kt
│ ├── TypesTest.kt
│ ├── ValuesTest.kt
│ └── http
│ └── TypedHttpClientTest.kt
├── csv
├── README.md
├── build.gradle.kts
├── src
│ ├── CSVGenerator.kt
│ └── CSVParser.kt
└── test
│ ├── CSVGeneratorTest.kt
│ └── CSVParserTest.kt
├── docs
├── Comparisons.md
└── Kotlin.md
├── google2834a0f0b505c168.html
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── i18n
├── README.md
├── build.gradle.kts
├── src
│ └── Lang.kt
└── test
│ └── LangTest.kt
├── jackson
├── README.md
├── build.gradle.kts
├── src
│ ├── Extensions.kt
│ ├── JsonBody.kt
│ └── JsonHttpClient.kt
└── test
│ ├── JsonBodyTest.kt
│ └── JsonHttpClientTest.kt
├── jdbc-test
├── README.md
├── build.gradle.kts
└── src
│ └── DBTest.kt
├── jdbc
├── README.md
├── TEX.md
├── build.gradle.kts
├── src
│ ├── Annotations.kt
│ ├── ConfigDataSource.kt
│ ├── DBModule.kt
│ ├── DockerCompose.kt
│ ├── Exceptions.kt
│ ├── GeneratedKey.kt
│ ├── HikariModule.kt
│ ├── JdbcConverter.kt
│ ├── JdbcExtensions.kt
│ ├── PooledDataSource.kt
│ ├── PostgresExtensions.kt
│ ├── PostgresNotifier.kt
│ ├── Repository.kt
│ ├── RequestTransactionHandler.kt
│ ├── ResultSets.kt
│ ├── SqlExpr.kt
│ ├── Transaction.kt
│ ├── Values.kt
│ └── migrator
│ │ ├── ChangeSet.kt
│ │ ├── ChangeSetFileReader.kt
│ │ ├── DBMigrator.kt
│ │ ├── init.sql
│ │ └── liquibase.sql
└── test
│ ├── AlreadyExistsExceptionTest.kt
│ ├── BaseCrudRepositoryTest.kt
│ ├── JdbcConverterTest.kt
│ ├── JdbcExtensionsTest.kt
│ ├── PooledDataSourceTest.kt
│ ├── PostgresExtensionsTest.kt
│ ├── RequestTransactionHandlerTest.kt
│ ├── SqlExprTest.kt
│ ├── TransactionContextTest.kt
│ ├── TransactionTest.kt
│ ├── ValuesTest.kt
│ └── migrator
│ ├── ChangeSetFileReaderTest.kt
│ ├── ChangeSetTest.kt
│ ├── invalid.sql
│ └── test.sql
├── jobs
├── README.md
├── build.gradle.kts
├── src
│ └── JobRunner.kt
└── test
│ └── JobRunnerTest.kt
├── json
├── README.md
├── build.gradle.kts
├── src
│ ├── JsonBody.kt
│ ├── JsonHttpClient.kt
│ ├── JsonMapper.kt
│ ├── JsonNode.kt
│ ├── JsonParser.kt
│ ├── JsonRenderer.kt
│ └── TSGenerator.kt
└── test
│ ├── JsonParserTest.kt
│ ├── JsonRendererTest.kt
│ └── TSGeneratorTest.kt
├── liquibase
├── README.md
├── build.gradle.kts
└── src
│ └── LiquibaseModule.kt
├── logo.png
├── oauth
├── README.md
├── build.gradle.kts
├── src
│ ├── AuthRoutes.kt
│ ├── JWT.kt
│ ├── OAuthClient.kt
│ ├── OAuthRoutes.kt
│ └── OAuthUser.kt
└── test
│ ├── JWTTest.kt
│ ├── OAuthRoutesTest.kt
│ ├── en.json
│ └── langs.json
├── openapi
├── README.md
├── build.gradle.kts
├── src
│ ├── Generate.kt
│ └── OpenAPI.kt
└── test
│ ├── JsonSerializationTest.kt
│ └── OpenAPITest.kt
├── sample
├── .env
├── Dockerfile
├── README.md
├── build.gradle.kts
├── db
│ ├── create_test_db.sh
│ ├── db.sql
│ └── users.sql
├── docker-compose.override.yml
├── docker-compose.yml
├── i18n
│ ├── en.json
│ ├── et.json
│ └── langs.json
├── public
│ ├── favicon.ico
│ ├── favicon.ico.gz
│ ├── index.html
│ └── robots.txt
├── src
│ ├── APIRoutes.kt
│ ├── AdminChecker.kt
│ ├── Launcher.kt
│ ├── SSERoutes.kt
│ └── users
│ │ ├── Id.kt
│ │ ├── User.kt
│ │ └── UserRepository.kt
└── test
│ ├── DBTest.kt
│ ├── ServerIntegrationTest.kt
│ ├── TempTableDBTest.kt
│ ├── klite
│ ├── ConfigTest.kt
│ └── jdbc
│ │ ├── BaseRepositoryTest.kt
│ │ ├── DBMigratorTest.kt
│ │ └── JdbcExtensionsTest.kt
│ └── users
│ ├── UserRepositoryTest.kt
│ ├── UserTest.kt
│ └── users
│ └── TestData.kt
├── serialization
├── README.md
├── build.gradle.kts
├── src
│ └── JsonBody.kt
└── test
│ └── JsonBodyTest.kt
├── server
├── README.md
├── build.gradle.kts
├── src
│ └── klite
│ │ ├── AppScope.kt
│ │ ├── AssetsHandler.kt
│ │ ├── Body.kt
│ │ ├── Browser.kt
│ │ ├── Cookie.kt
│ │ ├── CorsHandler.kt
│ │ ├── Decorators.kt
│ │ ├── ErrorHandler.kt
│ │ ├── Exceptions.kt
│ │ ├── FormDataParser.kt
│ │ ├── FormDataRenderer.kt
│ │ ├── HttpExchange.kt
│ │ ├── MimeTypes.kt
│ │ ├── RequestIdGenerator.kt
│ │ ├── RequestLogger.kt
│ │ ├── Router.kt
│ │ ├── Server.kt
│ │ ├── Session.kt
│ │ ├── StatusCode.kt
│ │ ├── UtilDecorators.kt
│ │ ├── Utils.kt
│ │ ├── XForwardedHttpExchange.kt
│ │ ├── annotations
│ │ └── Annotations.kt
│ │ ├── crypto
│ │ ├── KeyCipher.kt
│ │ └── KeyGenerator.kt
│ │ ├── html
│ │ └── Helpers.kt
│ │ └── sse
│ │ └── Event.kt
└── test
│ └── klite
│ ├── AssetsHandlerTest.kt
│ ├── BrowserTest.kt
│ ├── CookieTest.kt
│ ├── CorsHandlerTest.kt
│ ├── DecoratorsTest.kt
│ ├── DependencyInjectingRegistryTest.kt
│ ├── ErrorHandlerTest.kt
│ ├── FormDataParserTest.kt
│ ├── FormDataRendererTest.kt
│ ├── HttpExchangeTest.kt
│ ├── MimeTypesTest.kt
│ ├── PathParamRegexerTest.kt
│ ├── RequestLogFormatterTest.kt
│ ├── RouteTest.kt
│ ├── RouterConfigTest.kt
│ ├── ServerTest.kt
│ ├── SimpleRegistryTest.kt
│ ├── StatusCodeTest.kt
│ ├── UtilsTest.kt
│ ├── annotations
│ ├── AnnotationsTest.kt
│ └── CustomAnnotation.kt
│ ├── crypto
│ └── KeyCipherTest.kt
│ └── sse
│ └── EventTest.kt
├── settings.gradle.kts
├── slf4j
├── README.md
├── build.gradle.kts
├── src
│ ├── KliteLogger.kt
│ ├── KliteLoggerFactory.kt
│ ├── KliteLoggerProvider.kt
│ ├── META-INF
│ │ └── services
│ │ │ ├── java.lang.System$LoggerFinder
│ │ │ └── org.slf4j.spi.SLF4JServiceProvider
│ ├── Slf4jRedirector.kt
│ ├── Slf4jRedirectorCreator.kt
│ ├── StackTraceOptimizingJsonLogger.kt
│ └── StackTraceOptimizingLogger.kt
└── test
│ ├── KliteLoggerFactoryTest.kt
│ ├── KliteLoggerTest.kt
│ ├── StackTraceOptimizingJsonLoggerTest.kt
│ └── StackTraceOptimizingLoggerTest.kt
└── smtp
├── .env
├── README.md
├── build.gradle.kts
├── src
├── EmailContent.kt
├── EmailSender.kt
└── SmtpEmailSender.kt
└── test
├── EmailSenderTest.kt
└── SmtpEmailSenderTest.kt
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | indent_style = space
3 | indent_size = 2
4 | charset = UTF-8
5 | trim_trailing_whitespace = true
6 | insert_final_newline = true
7 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gradle"
9 | directory: "/"
10 | schedule:
11 | interval: "weekly"
12 | - package-ecosystem: "docker"
13 | directory: "sample"
14 | schedule:
15 | interval: "weekly"
16 | ignore:
17 | - dependency-name: "openjdk"
18 |
--------------------------------------------------------------------------------
/.github/workflows/check-readme-links.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | for f in `find . -name '*.md'`; do
4 | echo "Checking $f"
5 | PATHS=`grep -oP "(?<=\]\()[^)]*(?=\))" $f | grep -v '^http'`
6 | for l in $PATHS; do
7 | path=`dirname $f`/$l
8 | if [ ! -e "$path" ]; then
9 | echo "$path doesn't exist" && exit 1
10 | fi
11 | done
12 | done
13 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Build & Test
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | permissions:
13 | contents: write # The Dependency Submission API requires write permission
14 | packages: write
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: actions/setup-java@v4
18 | with:
19 | java-version: '11'
20 | distribution: 'temurin'
21 | cache: 'gradle'
22 |
23 | - run: ./gradlew jar testClasses
24 | - run: cd sample && docker compose up -d db && cd -
25 | - run: ./gradlew test --info
26 | - run: cd sample && docker compose stop db && cd -
27 | - run: cd sample && docker compose build && cd -
28 | - run: .github/workflows/check-readme-links.sh
29 |
30 | - uses: mikepenz/gradle-dependency-submission@v0.8.6
31 | if: ${{ github.ref == 'refs/heads/master' }}
32 | with:
33 | gradle-build-module: |-
34 | :server
35 | :slf4j
36 | :jackson
37 | :liquibase
38 | :sample
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.gradle/
2 | build/
3 | out
4 | .kotlin
5 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Datasource local storage ignored files
5 | /dataSources/
6 | /dataSources.local.xml
7 | # Editor-based HTTP Client requests
8 | /httpRequests/
9 | # GitHub Copilot persisted chat sessions
10 | /copilot/chatSessions
11 |
--------------------------------------------------------------------------------
/.idea/.name:
--------------------------------------------------------------------------------
1 | klite
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/dataSources.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | postgresql
6 | true
7 | org.postgresql.Driver
8 | jdbc:postgresql://localhost:65432/postgres
9 | $ProjectFileDir$
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/file.template.settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/jarRepositories.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/All_tests.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | false
19 | true
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | false
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/sample_Launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/sqldialects.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.jitpack.yml:
--------------------------------------------------------------------------------
1 | jdk: openjdk11
2 |
--------------------------------------------------------------------------------
/CNAME:
--------------------------------------------------------------------------------
1 | klite.codeborne.com
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2021-2025 Anton Keks and contributors
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
4 | files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
5 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
6 | is furnished to do so, subject to the following conditions:
7 |
8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9 |
10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
11 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
12 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
13 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
14 |
--------------------------------------------------------------------------------
/core/README.md:
--------------------------------------------------------------------------------
1 | # klite-core
2 |
3 | Some core concepts that can be useful without [klite-server](../server). Dependency of other klite modules.
4 |
5 | * [Config](src/Config.kt) - simple env/system properties based configuration
6 | * [Converter](src/Converter.kt) - base for http/jdbc/json conversion of Strings to type-safe values
7 | * [Logger](src/Logger.kt) - convenient extensions for JDK System.Logger
8 | * [toValues/create](src/Values.kt) - functions for conversion of objects to maps and back; can be used for DTO mapping, but also jdbc/json/etc
9 | * [Decimal](src/Decimal.kt) - an alternative for BigDecimal for monetary amounts
10 | * [Registry/DependencyInjectingRegistry](src/Registry.kt) - simple registry of class instances and Dependency Injection
11 |
--------------------------------------------------------------------------------
/core/build.gradle.kts:
--------------------------------------------------------------------------------
1 | dependencies {
2 | api("org.jetbrains.kotlin:kotlin-reflect:${kotlin.coreLibrariesVersion}")
3 | implementation(libs.kotlinx.coroutines)
4 | }
5 |
--------------------------------------------------------------------------------
/core/src/Cache.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import java.lang.System.currentTimeMillis
4 | import java.util.concurrent.ConcurrentHashMap
5 | import kotlin.concurrent.thread
6 | import kotlin.time.Duration
7 |
8 | /** Simple in-memory cache with automatic expiration */
9 | class Cache(expiration: Duration, autoRemoveExpired: Boolean = true, val prolongOnAccess: Boolean = false, val keepAlive: (Map.Entry.Entry>) -> Unit = {}): AutoCloseable {
10 | private val expirationMs = expiration.inWholeMilliseconds
11 | val entries = ConcurrentHashMap>()
12 | private val expirationTimer = if (autoRemoveExpired) thread(name = "${this}ExpirationTimer", isDaemon = true) {
13 | while (!Thread.interrupted()) {
14 | try { Thread.sleep(expirationMs) } catch (e: InterruptedException) { break }
15 | removeExpired()
16 | }
17 | } else null
18 |
19 | operator fun get(key: K) = entries[key]?.takeIf { !it.isExpired() }?.access()
20 | operator fun set(key: K, value: V) { entries.put(key, Entry(value)) }
21 | inline fun getOrSet(key: K, compute: (key: K) -> V) = entries.getOrPut(key) { Entry(compute(key)) }.access()
22 | fun isEmpty() = entries.isEmpty()
23 |
24 | fun removeExpired() {
25 | val now = currentTimeMillis()
26 | val i = entries.iterator()
27 | while (i.hasNext()) {
28 | val entry = i.next()
29 | keepAlive(entry)
30 | if (entry.value.isExpired(now)) i.remove()
31 | }
32 | }
33 |
34 | override fun close() {
35 | entries.clear()
36 | expirationTimer?.interrupt()
37 | }
38 |
39 | inner class Entry(val value: V, var since: Long = currentTimeMillis()) {
40 | fun access() = value.also {
41 | if (prolongOnAccess) since = currentTimeMillis()
42 | }
43 | fun isExpired(now: Long = currentTimeMillis()) = since + expirationMs < now
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/core/src/Config.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import klite.Config.useEnvFile
4 | import java.io.File
5 |
6 | /**
7 | * An overridable way to read configuration from env vars or system properties,
8 | * as per 12-factor apps spec. In development, it is convenient to use [useEnvFile],
9 | * which will skip already set env vars by default, giving them precedence.
10 | */
11 | object Config {
12 | fun optional(env: String): String? = System.getProperty(env) ?: System.getenv(env)
13 | fun optional(env: String, default: String) = optional(env) ?: default
14 | fun required(env: String) = optional(env) ?: error("$env should be provided as system property or env var")
15 |
16 | /** For dot-separated inheritance, e.g. logger level */
17 | fun inherited(env: String): String? = optional(env) ?: if ("." in env) inherited(env.substringBeforeLast(".")) else null
18 | fun inherited(env: String, default: String): String = inherited(env) ?: default
19 |
20 | /** List of active configurations, e.g. dev or prod, from ENV var */
21 | val active: Set by lazy {
22 | optional("ENV", "dev").split(",").map { it.trim() }.toSet()
23 | }
24 | fun isActive(conf: String) = conf in active
25 | fun isAnyActive(vararg confs: String) = confs.any { it in active }
26 |
27 | operator fun get(env: String) = required(env)
28 | operator fun set(env: String, value: String): String? = System.setProperty(env, value)
29 |
30 | fun overridable(env: String, value: String) {
31 | if (optional(env) == null) Config[env] = value
32 | }
33 |
34 | /**
35 | * Use this as the first thing in your app before creating a Server instance.
36 | * @param force use to override already set env vars
37 | */
38 | fun useEnvFile(name: String = ".env", force: Boolean = false) = useEnvFile(File(name), force)
39 | fun useEnvFile(file: File, force: Boolean = false) {
40 | if (!force && !file.exists()) return logger().info("No ${file.absolutePath} found, skipping")
41 | file.forEachLine {
42 | val line = it.trim()
43 | if (line.isNotEmpty() && !line.startsWith('#'))
44 | line.split('=', limit = 2).let { (key, value) ->
45 | if (force || optional(key) == null) set(key, value)
46 | }
47 | }
48 | }
49 | }
50 |
51 | val Config.isDev get() = isActive("dev")
52 | val Config.isTest get() = isActive("test")
53 | val Config.isProd get() = isActive("prod")
54 |
--------------------------------------------------------------------------------
/core/src/Extensions.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import java.io.OutputStream
4 | import java.util.*
5 | import kotlin.reflect.KClass
6 | import kotlin.reflect.KType
7 |
8 | fun String?.trimToNull() = this?.trim()?.takeIf { it.isNotEmpty() }
9 |
10 | fun OutputStream.write(s: String) = write(s.toByteArray())
11 |
12 | @Suppress("UNCHECKED_CAST")
13 | fun notNullValues(vararg pairs: Pair?) = pairs.filter { it?.second != null } as List>
14 | fun mapOfNotNull(vararg pairs: Pair?) = notNullValues(*pairs).toMap()
15 |
16 | val KType.java get() = (classifier as KClass<*>).java
17 | fun Any.unboxInline() = javaClass.getMethod("unbox-impl").invoke(this)
18 |
19 | val String.uuid: UUID get() = UUID.fromString(this)
20 |
21 | fun > T.min(o: T) = if (this <= o) this else o
22 | fun > T.max(o: T) = if (this >= o) this else o
23 |
--------------------------------------------------------------------------------
/core/src/Logger.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("NOTHING_TO_INLINE")
2 | package klite
3 |
4 | import java.lang.System.Logger.Level.*
5 |
6 | fun logger(name: String): System.Logger = System.getLogger(name)
7 | fun Any.logger(): System.Logger = logger(nonAnonymousClassName())
8 |
9 | internal fun Any.nonAnonymousClassName(): String {
10 | var cls = javaClass
11 | while (cls.isAnonymousClass) cls = cls.superclass
12 | return cls.name
13 | }
14 |
15 | inline fun System.Logger.debug(msg: String) = log(DEBUG, msg)
16 | inline fun System.Logger.info(msg: String) = log(INFO, msg)
17 | inline fun System.Logger.warn(msg: String) = log(WARNING, msg)
18 | inline fun System.Logger.error(msg: String, e: Throwable? = null) = log(ERROR, msg, e)
19 | inline fun System.Logger.error(e: Throwable) = log(ERROR, e.message ?: e.javaClass.name, e)
20 |
--------------------------------------------------------------------------------
/core/src/TSID.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import java.lang.System.currentTimeMillis
4 | import java.security.SecureRandom
5 | import java.time.Instant
6 | import java.util.concurrent.atomic.AtomicInteger
7 | import java.util.concurrent.atomic.AtomicLong
8 |
9 | /**
10 | * Time-Sorted unique ID, a more compact and DB index-friendly alternative to UUID.
11 | * Add a `typealias Id = TSID` or `Id = TSID` in your own project.
12 | */
13 | @JvmInline value class TSID(val value: Long) {
14 | companion object: TSIDGenerator() {
15 | init {
16 | Converter.use { TSID(it) }
17 | }
18 | }
19 |
20 | constructor(): this(generateValue())
21 | constructor(tsid: String): this(tsid.toLong(36))
22 | override fun toString() = value.toString(36)
23 |
24 | val createdAt: Instant get() = createdAt(value)
25 | }
26 |
27 | open class TSIDGenerator(
28 | val epoch: Long = Instant.parse(Config.optional("TSID_EPOCH", "2022-10-21T03:45:00.000Z")).toEpochMilli(),
29 | val randomBits: Int = 22
30 | ) {
31 | val randomMask = (1 shl randomBits) - 1
32 | private var random = SecureRandom()
33 | private val counter = AtomicInteger()
34 | @Volatile private var lastTime = 0L
35 | var deterministic: AtomicLong? = null
36 |
37 | open fun generateValue(): Long {
38 | deterministic?.let { return it.incrementAndGet() }
39 | val time = (currentTimeMillis() - epoch) shl randomBits
40 | if (time != lastTime) {
41 | counter.set(random.nextInt())
42 | lastTime = time
43 | }
44 | val tail = counter.incrementAndGet() and randomMask
45 | return time or tail.toLong()
46 | }
47 |
48 | open fun createdAt(value: Long): Instant = Instant.ofEpochMilli((value shr randomBits) + epoch)
49 | }
50 |
--------------------------------------------------------------------------------
/core/src/Types.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | /** Base class for String-based normalized value types, handled automatically by [Converter] */
4 | abstract class StringValue(val value: String) {
5 | override fun toString() = value
6 | override fun equals(other: Any?) = other != null && javaClass == other.javaClass && value == (other as StringValue).value
7 | override fun hashCode() = value.hashCode()
8 | }
9 |
10 | class Email(email: String): StringValue(email.trim().lowercase()) {
11 | companion object {}
12 | init { require(value.length > 3 && value.contains("@")) { "Invalid email: $email" } }
13 | val domain get() = value.substringAfter("@")
14 | }
15 |
16 | class Phone(phone: String): StringValue(phone.replace(removeChars, "")) {
17 | companion object {
18 | private val removeChars = "[\\s-()]+".toRegex()
19 | private val valid = "\\+[0-9]{9,}".toRegex()
20 | }
21 | init { require(valid.matches(value)) {
22 | "International phone number should start with + and have at least 9 digits: $value" }
23 | }
24 | }
25 |
26 | class Password(value: String): StringValue(value) {
27 | override fun toString() = "Password<***>"
28 | }
29 |
--------------------------------------------------------------------------------
/core/test/CacheTest.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import ch.tutteli.atrium.api.fluent.en_GB.toBeTheInstance
4 | import ch.tutteli.atrium.api.fluent.en_GB.toEqual
5 | import ch.tutteli.atrium.api.verbs.expect
6 | import io.mockk.mockk
7 | import io.mockk.verify
8 | import kotlinx.coroutines.delay
9 | import kotlinx.coroutines.test.runTest
10 | import org.junit.jupiter.api.Test
11 | import java.time.LocalDate
12 | import kotlin.time.Duration.Companion.milliseconds
13 |
14 | class CacheTest {
15 | val data = LocalDate.now()
16 |
17 | @Test fun `set & get`() { Cache(10.milliseconds).use { cache ->
18 | expect(cache.isEmpty()).toEqual(true)
19 |
20 | cache["key"] = data
21 | expect(cache.isEmpty()).toEqual(false)
22 |
23 | expect(cache["key"]).toBeTheInstance(data)
24 | Thread.sleep(12)
25 | expect(cache["key"]).toEqual(null)
26 | Thread.sleep(9)
27 | expect(cache.isEmpty()).toEqual(true)
28 |
29 | expect(cache.getOrSet("key") { data }).toBeTheInstance(data)
30 | runTest { expect(cache.getOrSet("key") { delay(20); data }).toBeTheInstance(data) }
31 | // expect(cache["key"]).toBeTheInstance(data) - this line is flaky in Github Actions
32 | }}
33 |
34 | @Test fun prolongOnAccess() { Cache(10.milliseconds, prolongOnAccess = true, keepAlive = mockk(relaxed = true)).use { cache ->
35 | cache["key"] = data
36 | Thread.sleep(7)
37 | expect(cache["key"]).toBeTheInstance(data)
38 | Thread.sleep(4)
39 | expect(cache["key"]).toBeTheInstance(data)
40 | verify { cache.keepAlive(match { it.key == "key" && it.value.value == data }) }
41 | Thread.sleep(11)
42 | expect(cache["key"]).toEqual(null)
43 | }}
44 | }
45 |
--------------------------------------------------------------------------------
/core/test/LoggerTest.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import ch.tutteli.atrium.api.fluent.en_GB.toBeAnInstanceOf
4 | import ch.tutteli.atrium.api.fluent.en_GB.toEqual
5 | import ch.tutteli.atrium.api.verbs.expect
6 | import org.junit.jupiter.api.Test
7 | import java.util.Date
8 |
9 | class LoggerTest {
10 | @Test fun name() {
11 | expect(nonAnonymousClassName()).toEqual(LoggerTest::class.java.name)
12 | expect(object: Date() {}.nonAnonymousClassName()).toEqual(Date::class.java.name)
13 | }
14 |
15 | @Test fun testLogger() {
16 | expect(logger()).toBeAnInstanceOf()
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/core/test/TSIDTest.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import ch.tutteli.atrium.api.fluent.en_GB.toBeGreaterThan
4 | import ch.tutteli.atrium.api.fluent.en_GB.toBeLessThanOrEqualTo
5 | import ch.tutteli.atrium.api.fluent.en_GB.toEqual
6 | import ch.tutteli.atrium.api.verbs.expect
7 | import org.junit.jupiter.api.Test
8 | import java.lang.System.currentTimeMillis
9 | import java.util.concurrent.atomic.AtomicLong
10 |
11 | typealias Id = TSID
12 |
13 | class TSIDTest {
14 | val maxValue = Id(Long.MAX_VALUE)
15 |
16 | @Test fun tsid() {
17 | expect(Id(1234L).toString()).toEqual("ya")
18 | expect(maxValue.toString()).toEqual("1y2p0ij32e8e7")
19 | expect(Id("1y2p0ij32e8e7")).toEqual(maxValue)
20 | }
21 |
22 | @Test fun converter() {
23 | expect(Converter.from(maxValue.toString())).toEqual(maxValue)
24 | }
25 |
26 | @Test fun createdAt() {
27 | expect(Id().createdAt.toEpochMilli()).toBeLessThanOrEqualTo(currentTimeMillis())
28 | }
29 |
30 | @Test fun `no collisions`() {
31 | val ids = mutableSetOf()
32 | for (i in 1..1000000) ids.add(Id())
33 | expect(ids.size).toEqual(1000000)
34 | }
35 |
36 | @Test
37 | fun deterministic() {
38 | TSID.deterministic = AtomicLong(123123123)
39 | expect(TSID().toString()).toEqual("21ayes")
40 | expect(TSID().toString()).toEqual("21ayet")
41 | expect(TSID().toString()).toEqual("21ayeu")
42 | TSID.deterministic = null
43 | expect(TSID().toString().length).toBeGreaterThan(8)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/core/test/TypesTest.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import ch.tutteli.atrium.api.fluent.en_GB.messageToContain
4 | import ch.tutteli.atrium.api.fluent.en_GB.toEqual
5 | import ch.tutteli.atrium.api.fluent.en_GB.toThrow
6 | import ch.tutteli.atrium.api.verbs.expect
7 | import org.junit.jupiter.api.Test
8 |
9 | class TypesTest {
10 | @Test fun email() {
11 | val email = Email(" hello@DOmain.zz\n")
12 | expect(email.value).toEqual("hello@domain.zz")
13 | expect(email).toEqual(Email(email.value))
14 | expect(email.hashCode()).toEqual(email.value.hashCode())
15 | expect(email.domain).toEqual("domain.zz")
16 | }
17 |
18 | @Test fun `invalid email`() {
19 | expect { Email("blah") }.toThrow().messageToContain("Invalid email: blah")
20 | }
21 |
22 | @Test fun phone() {
23 | expect(Phone(" +372 (56) 639-678").value).toEqual("+37256639678")
24 | }
25 |
26 | @Test fun `invalid phone`() {
27 | expect { Phone("blah") }.toThrow()
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/core/test/ValuesTest.kt:
--------------------------------------------------------------------------------
1 | package klite
2 |
3 | import ch.tutteli.atrium.api.fluent.en_GB.message
4 | import ch.tutteli.atrium.api.fluent.en_GB.toBeEmpty
5 | import ch.tutteli.atrium.api.fluent.en_GB.toEqual
6 | import ch.tutteli.atrium.api.fluent.en_GB.toThrow
7 | import ch.tutteli.atrium.api.verbs.expect
8 | import org.junit.jupiter.api.Test
9 |
10 | class ValuesTest {
11 | @Test fun toValues() {
12 | val data = SomeData("Hello", 123, nullable = null)
13 | expect(data.toValues()).toEqual(mapOf(
14 | SomeData::hello to "Hello", SomeData::world to 123, SomeData::nullable to null, SomeData::list to listOf(1, 2)))
15 | expect(data.toValues(SomeData::world to 124)).toEqual(mapOf(
16 | SomeData::hello to "Hello", SomeData::world to 124, SomeData::nullable to null, SomeData::list to listOf(1, 2)))
17 | expect(data.toValues().create()).toEqual(data)
18 | }
19 |
20 | @Test fun toValuesSkipping() {
21 | val data = SomeData("Hello", 123)
22 | expect(data.toValuesSkipping(SomeData::nullable, SomeData::list)).toEqual(mapOf(SomeData::hello to "Hello", SomeData::world to 123))
23 | expect(data.toValuesSkipping(SomeData::hello, SomeData::world, SomeData::nullable, SomeData::list)).toBeEmpty()
24 | }
25 |
26 | @Test fun createFrom() {
27 | val values = mapOf("hello" to "Hello", "world" to 34)
28 | expect(SomeData::class.createFrom(values)).toEqual(SomeData("Hello", 34))
29 | expect(values.create()).toEqual(SomeData("Hello", 34))
30 | }
31 |
32 | @Test fun `create with explicit nullable`() {
33 | val values = mapOf("hello" to "", "world" to 0, "nullable" to null, "list" to null)
34 | expect(SomeData::class.createFrom(values)).toEqual(SomeData("", 0, nullable = null))
35 |
36 | expect(AnotherData::class.createFrom(emptyMap())).toEqual(AnotherData(null))
37 | }
38 |
39 | @Test fun `descriptive error message`() {
40 | val values = mapOf("world" to 34)
41 | expect { SomeData::class.createFrom(values) }.toThrow()
42 | .message.toEqual("Cannot create SomeData from {world=34}: missing hello")
43 | }
44 |
45 | data class SomeData(val hello: String, val world: Int, val nullable: String? = "default", val list: List = listOf(1, 2))
46 | data class AnotherData(val hello: String?)
47 | }
48 |
--------------------------------------------------------------------------------
/core/test/http/TypedHttpClientTest.kt:
--------------------------------------------------------------------------------
1 | package klite.http
2 |
3 | import ch.tutteli.atrium.api.fluent.en_GB.toEqual
4 | import ch.tutteli.atrium.api.verbs.expect
5 | import org.junit.jupiter.api.Test
6 | import java.net.http.HttpClient
7 |
8 | class TypedHttpClientTest {
9 | @Test fun `logger name should get container class name`() {
10 | expect(TypedHttpClient(http = HttpClient.newHttpClient(), contentType = "").logger.name)
11 | .toEqual(javaClass.name)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/csv/README.md:
--------------------------------------------------------------------------------
1 | # klite-csv
2 |
3 | Provides simple CSV parsing and generation classes
4 |
5 | * [CSVGenerator](src/CSVGenerator.kt)
6 | * [CSVParser](src/CSVParser.kt)
7 |
--------------------------------------------------------------------------------
/csv/build.gradle.kts:
--------------------------------------------------------------------------------
1 | dependencies {
2 | testImplementation(project(":core"))
3 | }
4 |
--------------------------------------------------------------------------------
/csv/src/CSVGenerator.kt:
--------------------------------------------------------------------------------
1 | package klite.csv
2 |
3 | import java.io.OutputStream
4 | import java.nio.charset.Charset
5 | import java.sql.ResultSet
6 | import kotlin.text.Charsets.UTF_8
7 |
8 | const val bomUTF8 = "\uFEFF"
9 | private val needsQuotes = "[\\s\"';,]".toRegex()
10 |
11 | open class CSVGenerator(val out: OutputStream, val separator: String = ",", val charset: Charset = UTF_8, bom: String = if (charset == UTF_8) bomUTF8 else "") {
12 | init { out.write(bom.toByteArray(charset)) }
13 |
14 | fun row(vararg values: Any?) = this.apply {
15 | out.write(values.joinToString(separator, postfix = "\n", transform = ::transform).toByteArray(charset))
16 | }
17 |
18 | protected open fun transform(o: Any?): String = when(o) {
19 | null -> ""
20 | is Number -> if (separator == ";") o.toString().replace(".", ",") else o.toString()
21 | is String -> if (o.contains(needsQuotes)) "\"${o.replace("\"", "\"\"")}\"" else o
22 | else -> transform(o.toString())
23 | }
24 |
25 | private fun sqlHeader(rs: ResultSet) = row(*(1..rs.metaData.columnCount).map { rs.metaData.getColumnName(it) }.toTypedArray())
26 | fun sqlDump(rs: ResultSet) {
27 | if (rs.isFirst) sqlHeader(rs)
28 | row(*(1..rs.metaData.columnCount).map { rs.getObject(it) }.toTypedArray())
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/csv/src/CSVParser.kt:
--------------------------------------------------------------------------------
1 | package klite.csv
2 |
3 | import java.io.InputStream
4 | import java.nio.charset.Charset
5 | import kotlin.text.Charsets.UTF_8
6 |
7 | class CSVParser(separator: String = ",", private val charset: Charset = UTF_8) {
8 | private val splitter = """(?:$separator|^)("((?:(?:"")*[^"]*)*)"|([^"$separator]*))""".toRegex()
9 |
10 | fun parse(stream: InputStream): Sequence