Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,42 @@ surf-api
> projects. `-server` modules contain implementations that are shaded into the final JAR and never
> exposed as a public API dependency.

## Environment Variables

`surf-api-core` provides small, type-safe environment-variable access without a separate schema or
lookup phase. Direct reads resolve immediately:

```kotlin
val DATABASE_HOST: String = env.required("DATABASE_HOST")
val DATABASE_PORT: Int = env.int("DATABASE_PORT", default = 5432) {
requireIn(1..65535)
}
val INSTANCE_ID: UUID? = env.optionalUuid("INSTANCE_ID")
```

Property delegates resolve lazily on first access, infer the variable name exactly from the property,
and cache the parsed value thread-safely:

```kotlin
object ServiceEnvironment {
val DATABASE_HOST by env.requiredNonBlank()
val DATABASE_PORT by env.int(default = 5432) {
requireIn(1..65535)
}
val DATABASE_PASSWORD by env.requiredSecret()
val DEBUG by env.boolean(default = false)
}
```

Use `env.required().named("DATABASE_HOST")` for an explicitly named delegate. Required values are
non-null, optional values are nullable, and defaulted values are non-null. Missing required values,
conversion failures, and validation failures throw dedicated exceptions containing the variable
name. Mark values as sensitive, or use `requiredSecret`, to keep raw values and parser causes out of
error details. Required strings preserve present blank values; `requiredNonBlank` rejects them.
Built-in converters cover strings, numeric types, strict booleans, UUIDs, enums, and Kotlin
durations. Custom converters can be passed directly, for example
`env.required("ID", UUID::fromString)`.

---

## Key Concepts
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
javaVersion=25
mcVersion=26.2
group=dev.slne.surf.api
version=3.33.5
version=3.34.0
relocationPrefix=dev.slne.surf.api.libs
snapshot=false
163 changes: 163 additions & 0 deletions surf-api-core/surf-api-core/api/surf-api-core.api

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)
}
Comment on lines +42 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cache failed delegate resolutions too

When the resolver throws because a variable is missing, malformed, or fails validation, Kotlin's lazy remains uninitialized and retries the resolver on every later property access. With a mutable custom EnvironmentSource, 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 👍 / 👎.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact exception messages from sensitive validators

When a sensitive validator throws an exception whose message includes its input—for example, a NumberFormatException from parsing the secret inside require—the message is copied into reason and then exposed through EnvironmentVariableValidationException.message. Although the raw-value and cause fields are redacted, this path can still leak credentials into logs; sensitive validation failures should use a generic reason rather than propagating the validator's message.

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 }
}
Loading