Skip to content

Commit f1732d5

Browse files
committed
feat: kotlin environment variables article
1 parent 156ecdc commit f1732d5

File tree

3 files changed

+210
-2
lines changed

3 files changed

+210
-2
lines changed

.vscode/settings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
22
"editor.formatOnSave": true,
33
"editor.codeActionsOnSave": {
4-
"source.fixAll.eslint": true
4+
"source.fixAll.eslint": "explicit"
55
},
66
"[markdown]": {
77
"editor.wordWrap": "on",
88
"editor.codeActionsOnSave": {
9-
"source.fixAll.markdownlint": true
9+
"source.fixAll.markdownlint": "explicit"
1010
}
1111
},
1212
"prettier.documentSelectors": ["**/*.astro"],
Loading
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
---
2+
title: "Managing environment variables in Kotlin - DoToo [backend]"
3+
publishDate: "23 April 2024"
4+
description: "Manage your environment variables with ease and type safety!"
5+
tags: ["kotlin", "backend"]
6+
ogImage: "/thumbnails/env-variables-kotlin-illustration.png"
7+
---
8+
Environment variables are always needed when developing some sort of api. This is why I built my own small script to manage and retrieve them with ease, while also keeping type safety.
9+
10+
#### Features
11+
✅ Configuration variables split across Kotlin objects
12+
✅ Automatic type safety by defining your custom parser
13+
✅ Support for optional and default values
14+
✅ Template .env file generation based on Kotlin defined variables
15+
16+
## Adding dependencies
17+
- [dotenv kotlin](https://github.com/cdimascio/dotenv-kotlin)
18+
- [reflections](https://github.com/ronmamo/reflections)
19+
- [ktor-utils-jvm](https://mvnrepository.com/artifact/io.ktor/ktor-utils-jvm)
20+
- [optional] A logger, I recommend [KotlinLogging](https://github.com/oshai/kotlin-logging)
21+
22+
## Creating the required annotations
23+
We are gonna use reflections to detect the environment variables that need to be read.
24+
To make life easier we are gonna create our own annotations for that.
25+
26+
The first one will be used to annotate the Kotlin objects that contain the variables, it accepts a prefix string which we'll use later:
27+
```kotlin
28+
@Target(AnnotationTarget.CLASS)
29+
annotation class Configuration(
30+
val prefix: String,
31+
)
32+
```
33+
34+
While the second is for the actual variables, it accepts a name and whether that variable can be considered optional:
35+
```kotlin
36+
@Target(AnnotationTarget.PROPERTY)
37+
annotation class ConfigurationProperty(
38+
val name: String,
39+
val optional: Boolean = false,
40+
)
41+
```
42+
43+
Here are a couple of configuration objects that use those annotations:
44+
```kotlin
45+
@Configuration("api")
46+
object ApiConfig {
47+
@ConfigurationProperty("port")
48+
var port: Int = 8080
49+
50+
@ConfigurationProperty("cookie.secure")
51+
var cookieSecure: Boolean = true
52+
53+
@ConfigurationProperty("session.max.age.in.seconds")
54+
var sessionMaxAgeInSeconds: Long = 2592000 // 30 days by default
55+
56+
@ConfigurationProperty("admin.key")
57+
lateinit var adminKey: String
58+
}
59+
```
60+
```kotlin
61+
@Configuration("application")
62+
object ApplicationConfig {
63+
@ConfigurationProperty("log.level")
64+
var logLevel: Level = Level.INFO
65+
}
66+
```
67+
68+
As you can see we can use Kotlin types directly and even assign default values to these properties. If you don't wanna assign a default value you can also make it lateinit.
69+
70+
## Creating the environment reader
71+
We do need to read enviornment variables somehow. In my case I used the dotenv library mentioned in the dependencies, but the good part is that you are free to use anything else.
72+
What's important is that you provide a function that conforms to the required signature, which is the following:
73+
```kotlin
74+
val configurationReader: (key: String, clazz: KClass<*>) -> Any?
75+
```
76+
The function we need to create receives a key for the env variable we are looking for and a `KClass` that indicates the type of the variables we are excepting.
77+
78+
Let's fullfil this real quick, first lets create our dotenv instance, which by default looks for a `.env` file and fallsback to the System env vars:
79+
```kotlin
80+
private val dotenv: Dotenv? =
81+
try {
82+
dotenv()
83+
} catch (_: DotenvException) {
84+
log.info { ".env file not found, using System environment variables" }
85+
null
86+
}
87+
```
88+
89+
Now the actual function:
90+
```kotlin
91+
/**
92+
* Reads a value with the specified [key] from the environment, according to the [type]
93+
*/
94+
fun read(key: String, type: KClass<*>, ): Any? {
95+
val value: String? = dotenv?.get(key) ?: System.getenv(key)
96+
97+
return try {
98+
when (type) {
99+
String::class -> value
100+
Int::class -> value?.toInt()
101+
Long::class -> value?.toLong()
102+
Boolean::class -> value?.toBoolean()
103+
Level::class -> {
104+
try {
105+
value?.uppercase()?.let { Level.valueOf(it) }
106+
} catch (_: IllegalArgumentException) {
107+
throw IllegalArgumentException(
108+
"Tried to read a value of type 'Level' for key '$key' but casting failed, value: $value",
109+
)
110+
}
111+
}
112+
else -> throw UnsupportedOperationException(
113+
"Configuration reader required to read a value of type $type for key '$key' but no casting is implemented for that type",
114+
)
115+
}
116+
} catch (e: NumberFormatException) {
117+
log.error { "Could not cast a value with key '$key' in configuration reader, see following exception" }
118+
throw e
119+
}
120+
}
121+
```
122+
As you can see we read the value using dotenv and then parse it depending on the received `type` parameter!
123+
124+
## Creating the bridge to our Kotlin objects
125+
<script src="https://gist.github.com/Giuliopime/6c1809c6dbc244d85f44350d704d2892.js"></script>
126+
127+
This class receives two parameters:
128+
- the package that contains out Kotlin configuration objects
129+
- the function used to read values from the environment
130+
131+
It then provides two functions:
132+
- `listConfigurations` that gives back all the detected configuration variables and their info
133+
- `initialize` which actually reads values and loads them into our objects
134+
135+
The code is commented so I'll limit the explanation here but the bullet points regarding how it functions are:
136+
- initialize the reflections library using the provided package name
137+
- read all classes (objects in our case) annotated with the `Configuration` annotation
138+
- validate the object
139+
- read all its properties and for each
140+
- filter only the ones annotated with `ConfigurationProperty` (warn in case it's missing)
141+
- throw if any is declared as immutable
142+
- create the key to pass to our reader function by combining the `Configuration` `prefix` value and the `ConfigurationProperty` `name`.
143+
- detect the `KClass` of the variable
144+
- pass the ball to our reader function and get back the read value, if that returns null we try to use the default value of the variable if existing
145+
- if we got a null value and the variable isn't marked as nullable we throw or return depending on the `optional` property.
146+
- type check
147+
- set the value on the Kotlin object
148+
149+
## Putting it together
150+
Where we start out application we simply need to put the following now:
151+
```kotlin
152+
val configInitializer = ConfigurationManager(
153+
packageName = ConfigurationManager.DEFAULT_CONFIG_PACKAGE,
154+
configurationReader = ConfigurationReader::read
155+
)
156+
157+
configInitializer.initialize()
158+
```
159+
(`ConfigurationReader` is an object that contains my reader function)
160+
161+
We can now freely use our env variables like so:
162+
```kotlin
163+
println(ApiConfig.port)
164+
```
165+
✅ type safety
166+
✅ auto completion
167+
✅ globally accessible
168+
✅ DX happiness
169+
170+
## Automatically generating our .env file
171+
Let's take this one step further. We don't wanna double our jobs and have to create both our Kotlin objects and .env file. Let's generate the .env file automatically!
172+
173+
Here is a small script (you can put this in a separate module) that does this for us:
174+
```kotlin
175+
/**
176+
* Script that generates a template .env file based on the declared @Configuration objects
177+
*/
178+
fun main() {
179+
val configs = ConfigurationManager(
180+
packageName = ConfigurationManager.DEFAULT_CONFIG_PACKAGE,
181+
configurationReader = ConfigurationReader::read
182+
).listConfigurations()
183+
184+
val folder = createScriptOutputsFolderIfNotExisting()
185+
val file = File(folder, ".env.template")
186+
file.writeText(configs.joinToString("\n") { it.toString() } )
187+
}
188+
```
189+
If we run this we get the following `.env.template` file:
190+
```
191+
# Level
192+
APPLICATION_LOG_LEVEL=INFO
193+
API_ADMIN_KEY=
194+
# Boolean
195+
API_COOKIE_SECURE=true
196+
# Int
197+
API_PORT=8080
198+
# Long
199+
API_SESSION_MAX_AGE_IN_SECONDS=2592000
200+
```
201+
202+
## Sources and mentions
203+
You can find a full sample [in my repo](https://github.com/Giuliopime/do-too/tree/main/do-too-api/src/main/kotlin/app/dotoo/config)!
204+
205+
206+
Feel free to also checkout the new Apple Pkl library which seems quite handy and powerful too https://pkl-lang.org/index.html ^^
207+
208+
*Originally made for [Index](https://index-it.app)*

0 commit comments

Comments
 (0)