Skip to content

✨ feat(environment): add type-safe environment variable API - #400

Merged
twisti-dev merged 3 commits into
version/26.2from
agent/type-safe-environment-variables
Jul 25, 2026
Merged

✨ feat(environment): add type-safe environment variable API#400
twisti-dev merged 3 commits into
version/26.2from
agent/type-safe-environment-variables

Conversation

@twisti-dev

Copy link
Copy Markdown
Contributor

Summary

Adds a small Kotlin-first environment-variable API to surf-api-core with direct typed reads and lazy property delegates.

What changed

  • adds required, optional, and defaulted access for strings, numeric types, strict booleans, UUIDs, enums, and Kotlin durations
  • adds reified custom converter access such as env.required("ID", UUID::fromString)
  • adds inline validation with require { ... } and requireIn(...)
  • distinguishes missing variables from present blank values and provides requiredNonBlank
  • adds sensitive reads that omit raw values and parser causes from exceptions
  • introduces EnvironmentSource plus map-backed environments for deterministic tests
  • adds lazy, thread-safe, once-only delegate resolution with exact property-name inference
  • supports explicit delegate names via env.required().named("DATABASE_HOST")
  • documents direct and delegated usage in the README
  • updates the Kotlin ABI reference

Design decisions

Direct calls resolve immediately. Delegates resolve on first access and cache their value using synchronized Kotlin lazy, including cached null results. Enum names are case-sensitive; booleans accept only true or false, case-insensitively. Required strings preserve blank values unless requiredNonBlank is used.

The .named(...) modifier avoids an ambiguous Kotlin overload between direct required("NAME") reads and delegates while keeping the implementation smaller than a duplicated named-delegate facade.

Deliberate omissions

  • no schema or global mutable registry
  • no automatic camelCase-to-SCREAMING_SNAKE_CASE normalization
  • no runtime refresh for delegated values
  • no new runtime dependency

Validation

  • ./gradlew :surf-api-core:surf-api-core:compileKotlin
  • ./gradlew :surf-api-core:surf-api-core:test — 17 tests passed
  • ./gradlew :surf-api-core:surf-api-core:updateKotlinAbi
  • ./gradlew :surf-api-core:surf-api-core:check — compilation, tests, and ABI check passed

@twisti-dev
twisti-dev marked this pull request as ready for review July 25, 2026 12:35
Copilot AI review requested due to automatic review settings July 25, 2026 12:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f0a9d38d4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +28 to +31
} catch (exception: Exception) {
throw failure(
exception.message ?: "the validation rule threw ${exception::class.simpleName}",
exception

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 👍 / 👎.

Comment on lines +42 to +44
val resolved = lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
resolver(variableName)
}

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 👍 / 👎.

Comment on lines +28 to +33
inline fun <reified T> required(
name: String,
noinline converter: (String) -> T,
sensitive: Boolean = false,
noinline validation: EnvironmentVariableValidation<T>.() -> Unit = {}
): T = requiredConverted(name, targetTypeName<T>(), sensitive, converter, validation)

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 Constrain required converter results to non-null types

The unconstrained type parameter permits calls such as required<String?>("TOKEN") { null }, so a present required variable can resolve to null even though the API documents required values as non-null. Constrain T to Any (and apply the same constraint to the delegated converter overload) or reject null converter results so required declarations retain their stated type guarantee.

Useful? React with 👍 / 👎.

@twisti-dev
twisti-dev merged commit 1782dd9 into version/26.2 Jul 25, 2026
4 of 7 checks passed
@twisti-dev
twisti-dev deleted the agent/type-safe-environment-variables branch July 25, 2026 14:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

} catch (exception: EnvironmentVariableException) {
throw exception

P2 Badge Redact domain exceptions from sensitive converters

When a custom converter for a sensitive variable throws an EnvironmentVariableException, this branch rethrows it without applying the sensitive-value redaction used for other converter failures. For example, required("SECRET", { throw MissingEnvironmentVariableException(it) }, sensitive = true) exposes the raw secret in the resulting exception message, contradicting the API's guarantee that sensitive values stay out of error details; wrap or sanitize these exceptions when sensitive is true.



P2 Badge Escape remaining control characters in raw values

When an invalid non-sensitive value contains a control character such as ESC (\u001b) or backspace, this fallback copies it directly into the exception message. If that exception is written to a terminal or text log, the value can alter displayed output or erase preceding characters even though newline, carriage return, and tab are already escaped; encode all control characters rather than appending the remainder verbatim.


fun uuid(
default: UUID,
validation: EnvironmentVariableValidation<UUID>.() -> Unit = {}
): EnvironmentVariableDelegate<UUID> = delegate { name ->
uuid(name, default, validation = validation)

P2 Badge Forward sensitivity through typed delegates

When a defaulted UUID delegate represents a sensitive value, this overload provides no sensitive parameter and invokes the direct overload with its default of false. Consequently, a malformed present credential is copied into InvalidEnvironmentVariableException even though the equivalent direct call supports sensitive = true; the same omission affects the other typed delegate overloads, so add and forward the sensitivity flag.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants