-
Notifications
You must be signed in to change notification settings - Fork 0
✨ feat(environment): add type-safe environment variable API #400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package dev.slne.surf.api.core.environment | ||
|
|
||
| /** | ||
| * Supplies raw environment-variable values. | ||
| * | ||
| * A source returns `null` only when a variable is missing. Present blank values must be returned as | ||
| * an empty or whitespace-only string so callers can distinguish them from missing variables. | ||
| */ | ||
| fun interface EnvironmentSource { | ||
| /** | ||
| * Returns the raw value for [name], or `null` when the variable is missing. | ||
| */ | ||
| operator fun get(name: String): String? | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package dev.slne.surf.api.core.environment | ||
|
|
||
| import kotlin.properties.PropertyDelegateProvider | ||
| import kotlin.properties.ReadOnlyProperty | ||
| import kotlin.reflect.KProperty | ||
|
|
||
| /** | ||
| * A lazy, thread-safe environment-variable property delegate. | ||
| * | ||
| * The variable is resolved on first access and the parsed value, including `null`, is cached for | ||
| * all later accesses. When no explicit name is supplied, the delegated property's name is used | ||
| * exactly as written. | ||
| */ | ||
| class EnvironmentVariableDelegate<T> internal constructor( | ||
| private val explicitName: String?, | ||
| private val resolver: (String) -> T | ||
| ) : PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, T>> { | ||
| /** | ||
| * Returns an equivalent delegate that resolves the explicit environment-variable [name]. | ||
| * | ||
| * ```kotlin | ||
| * val databaseHost by env.required().named("DATABASE_HOST") | ||
| * ``` | ||
| */ | ||
| fun named(name: String): EnvironmentVariableDelegate<T> { | ||
| require(name.isNotEmpty()) { "Environment-variable names must not be empty." } | ||
| require('\u0000' !in name && '=' !in name) { | ||
| "Environment-variable name '$name' contains an invalid character." | ||
| } | ||
|
|
||
| return EnvironmentVariableDelegate(name, resolver) | ||
| } | ||
|
|
||
| /** | ||
| * Captures the property name and creates the cached read-only delegate. | ||
| */ | ||
| override fun provideDelegate( | ||
| thisRef: Any?, | ||
| property: KProperty<*> | ||
| ): ReadOnlyProperty<Any?, T> { | ||
| val variableName = explicitName ?: property.name | ||
| val resolved = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { | ||
| resolver(variableName) | ||
| } | ||
|
|
||
| return ReadOnlyProperty { _, _ -> resolved.value } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package dev.slne.surf.api.core.environment | ||
|
|
||
| /** | ||
| * Base class for failures encountered while resolving an environment variable. | ||
| * | ||
| * @property variableName The name of the environment variable that could not be resolved. | ||
| */ | ||
| sealed class EnvironmentVariableException( | ||
| val variableName: String, | ||
| message: String, | ||
| cause: Throwable? = null | ||
| ) : IllegalStateException(message, cause) | ||
|
|
||
| /** | ||
| * Thrown when a required environment variable is missing. | ||
| */ | ||
| class MissingEnvironmentVariableException( | ||
| variableName: String | ||
| ) : EnvironmentVariableException( | ||
| variableName, | ||
| "Required environment variable '$variableName' is missing." | ||
| ) | ||
|
|
||
| /** | ||
| * Thrown when an environment variable cannot be converted to its expected type. | ||
| * | ||
| * For sensitive variables, [rawValue] and [cause] are deliberately omitted because parser | ||
| * exceptions can include their input in the exception message. | ||
| * | ||
| * @property expectedType A human-readable description of the expected target type. | ||
| * @property rawValue The invalid raw value, or `null` when the variable is sensitive. | ||
| */ | ||
| class InvalidEnvironmentVariableException internal constructor( | ||
| variableName: String, | ||
| val expectedType: String, | ||
| val rawValue: String?, | ||
| sensitive: Boolean, | ||
| cause: Throwable | ||
| ) : EnvironmentVariableException( | ||
| variableName, | ||
| buildString { | ||
| append("Environment variable '") | ||
| append(variableName) | ||
| append("' has an invalid value for ") | ||
| append(expectedType) | ||
| append(": ") | ||
| if (sensitive) { | ||
| append("the sensitive value was not included.") | ||
| } else { | ||
| append(formatRawValue(rawValue.orEmpty())) | ||
| append('.') | ||
| } | ||
| }, | ||
| cause.takeUnless { sensitive } | ||
| ) | ||
|
|
||
| /** | ||
| * Thrown when a parsed environment-variable value fails inline validation. | ||
| * | ||
| * @property reason A description of the failed validation rule. | ||
| * @property rawValue The resolved value, or `null` when the variable is sensitive. | ||
| */ | ||
| class EnvironmentVariableValidationException internal constructor( | ||
| variableName: String, | ||
| val reason: String, | ||
| val rawValue: String?, | ||
| sensitive: Boolean, | ||
| cause: Throwable? = null | ||
| ) : EnvironmentVariableException( | ||
| variableName, | ||
| buildString { | ||
| append("Environment variable '") | ||
| append(variableName) | ||
| append("' failed validation: ") | ||
| append(reason) | ||
| if (sensitive) { | ||
| append(" The sensitive value was not included.") | ||
| } else if (rawValue != null) { | ||
| append(" Resolved ") | ||
| append(formatRawValue(rawValue)) | ||
| append('.') | ||
| } | ||
| }, | ||
| cause.takeUnless { sensitive } | ||
| ) | ||
|
|
||
| private fun formatRawValue(value: String): String { | ||
| val truncated = value.length > MAX_DISPLAYED_VALUE_LENGTH | ||
| val escaped = buildString { | ||
| value.take(MAX_DISPLAYED_VALUE_LENGTH).forEach { character -> | ||
| when (character) { | ||
| '\n' -> append("\\n") | ||
| '\r' -> append("\\r") | ||
| '\t' -> append("\\t") | ||
| else -> append(character) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return if (truncated) { | ||
| "value '$escaped…'" | ||
| } else { | ||
| "value '$escaped'" | ||
| } | ||
| } | ||
|
|
||
| private const val MAX_DISPLAYED_VALUE_LENGTH = 128 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package dev.slne.surf.api.core.environment | ||
|
|
||
| /** | ||
| * Validates one parsed environment-variable value. | ||
| * | ||
| * Validation is performed immediately for direct reads and during the first access for delegated | ||
| * reads. Use [require] to add rules inside a typed environment-variable declaration. | ||
| */ | ||
| class EnvironmentVariableValidation<T> internal constructor( | ||
| private val variableName: String, | ||
| private val rawValue: String?, | ||
| private val sensitive: Boolean, | ||
| private val value: T | ||
| ) { | ||
| /** | ||
| * Requires [predicate] to accept the resolved value. | ||
| */ | ||
| fun require(predicate: (T) -> Boolean) { | ||
| require("the value did not satisfy the required condition", predicate) | ||
| } | ||
|
|
||
| /** | ||
| * Requires [predicate] to accept the resolved value and reports [message] if it does not. | ||
| */ | ||
| fun require(message: String, predicate: (T) -> Boolean) { | ||
| val accepted = try { | ||
| predicate(value) | ||
| } catch (exception: Exception) { | ||
| throw failure( | ||
| exception.message ?: "the validation rule threw ${exception::class.simpleName}", | ||
| exception | ||
|
Comment on lines
+28
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a sensitive validator throws an exception whose message includes its input—for example, a Useful? React with 👍 / 👎. |
||
| ) | ||
| } | ||
|
|
||
| if (!accepted) { | ||
| throw failure(message) | ||
| } | ||
| } | ||
|
|
||
| internal fun failure( | ||
| message: String, | ||
| cause: Throwable? = null | ||
| ): EnvironmentVariableValidationException { | ||
| return EnvironmentVariableValidationException( | ||
| variableName = variableName, | ||
| reason = message, | ||
| rawValue = rawValue.takeUnless { sensitive }, | ||
| sensitive = sensitive, | ||
| cause = cause | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Requires the resolved value to be inside the inclusive [range]. | ||
| */ | ||
| fun <T : Comparable<T>> EnvironmentVariableValidation<T>.requireIn(range: ClosedRange<T>) { | ||
| require("expected a value in the range $range") { it in range } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the resolver throws because a variable is missing, malformed, or fails validation, Kotlin's
lazyremains uninitialized and retries the resolver on every later property access. With a mutable customEnvironmentSource, the same delegate can therefore fail once and later return a value, while converters with side effects run repeatedly, contrary to the advertised once-only, non-refreshing delegate behavior; cache a success-or-failure result explicitly.Useful? React with 👍 / 👎.