From 6f0a9d38d40b2ba2d256ea86e7f3087c7359f46e Mon Sep 17 00:00:00 2001 From: twisti <76837088+twisti-dev@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:21:25 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(environment):=20add=20type?= =?UTF-8?q?-safe=20environment=20variable=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 36 + .../surf-api-core/api/surf-api-core.api | 163 +++++ surf-api-core/surf-api-core/build.gradle.kts | 6 + .../api/core/environment/EnvironmentSource.kt | 14 + .../EnvironmentVariableDelegate.kt | 48 ++ .../EnvironmentVariableException.kt | 107 +++ .../EnvironmentVariableValidation.kt | 59 ++ .../core/environment/EnvironmentVariables.kt | 654 ++++++++++++++++++ .../environment/EnvironmentVariablesTest.kt | 301 ++++++++ 9 files changed, 1388 insertions(+) create mode 100644 surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentSource.kt create mode 100644 surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableDelegate.kt create mode 100644 surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableException.kt create mode 100644 surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableValidation.kt create mode 100644 surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariables.kt create mode 100644 surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariablesTest.kt diff --git a/README.md b/README.md index 20ac4157..f891265c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/surf-api-core/surf-api-core/api/surf-api-core.api b/surf-api-core/surf-api-core/api/surf-api-core.api index ea2c8297..dc3f17dc 100644 --- a/surf-api-core/surf-api-core/api/surf-api-core.api +++ b/surf-api-core/surf-api-core/api/surf-api-core.api @@ -729,6 +729,169 @@ public final class dev/slne/surf/api/core/config/type/number/IntOr$Disabled : de public final class dev/slne/surf/api/core/config/type/number/IntOr$Disabled$Companion { } +public abstract interface class dev/slne/surf/api/core/environment/EnvironmentSource { + public abstract fun get (Ljava/lang/String;)Ljava/lang/String; +} + +public final class dev/slne/surf/api/core/environment/EnvironmentVariableDelegate : kotlin/properties/PropertyDelegateProvider { + public final fun named (Ljava/lang/String;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public synthetic fun provideDelegate (Ljava/lang/Object;Lkotlin/reflect/KProperty;)Ljava/lang/Object; + public fun provideDelegate (Ljava/lang/Object;Lkotlin/reflect/KProperty;)Lkotlin/properties/ReadOnlyProperty; +} + +public abstract class dev/slne/surf/api/core/environment/EnvironmentVariableException : java/lang/IllegalStateException { + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getVariableName ()Ljava/lang/String; +} + +public final class dev/slne/surf/api/core/environment/EnvironmentVariableValidation { + public final fun require (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public final fun require (Lkotlin/jvm/functions/Function1;)V +} + +public final class dev/slne/surf/api/core/environment/EnvironmentVariableValidationException : dev/slne/surf/api/core/environment/EnvironmentVariableException { + public final fun getRawValue ()Ljava/lang/String; + public final fun getReason ()Ljava/lang/String; +} + +public final class dev/slne/surf/api/core/environment/EnvironmentVariableValidationKt { + public static final fun requireIn (Ldev/slne/surf/api/core/environment/EnvironmentVariableValidation;Lkotlin/ranges/ClosedRange;)V +} + +public final class dev/slne/surf/api/core/environment/EnvironmentVariables { + public static final field Companion Ldev/slne/surf/api/core/environment/EnvironmentVariables$Companion; + public static final field system Ldev/slne/surf/api/core/environment/EnvironmentVariables; + public final fun boolean (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Z + public final fun boolean (Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;)Z + public final fun boolean (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun boolean (ZLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun boolean$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Z + public static synthetic fun boolean$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Z + public static synthetic fun boolean$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun boolean$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final synthetic fun defaultedConverted (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public final synthetic fun delegate (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun delegate$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun double (DLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun double (Ljava/lang/String;DZLkotlin/jvm/functions/Function1;)D + public final fun double (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)D + public final fun double (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun double$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;DLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun double$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;DZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)D + public static synthetic fun double$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)D + public static synthetic fun double$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun duration (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun duration$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun duration-VtjQ1oo (JLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun duration-VtjQ1oo$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun duration-ZBGTal8 (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)J + public static synthetic fun duration-ZBGTal8$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)J + public final fun duration-x9oqsNc (Ljava/lang/String;JZLkotlin/jvm/functions/Function1;)J + public static synthetic fun duration-x9oqsNc$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;JZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)J + public final fun float (FLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun float (Ljava/lang/String;FZLkotlin/jvm/functions/Function1;)F + public final fun float (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)F + public final fun float (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun float$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;FLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun float$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;FZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)F + public static synthetic fun float$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)F + public static synthetic fun float$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static final fun from (Ldev/slne/surf/api/core/environment/EnvironmentSource;)Ldev/slne/surf/api/core/environment/EnvironmentVariables; + public static final fun from (Ljava/util/Map;)Ldev/slne/surf/api/core/environment/EnvironmentVariables; + public final fun int (ILkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun int (Ljava/lang/String;IZLkotlin/jvm/functions/Function1;)I + public final fun int (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)I + public final fun int (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun int$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun int$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;IZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)I + public static synthetic fun int$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)I + public static synthetic fun int$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun long (JLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun long (Ljava/lang/String;JZLkotlin/jvm/functions/Function1;)J + public final fun long (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)J + public final fun long (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun long$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun long$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;JZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)J + public static synthetic fun long$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)J + public static synthetic fun long$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun optional (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/String; + public final fun optional (ZLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun optional$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/String; + public static synthetic fun optional$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun optionalBoolean (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/Boolean; + public final fun optionalBoolean (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun optionalBoolean$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Boolean; + public static synthetic fun optionalBoolean$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final synthetic fun optionalConverted (Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public final fun optionalDouble (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/Double; + public final fun optionalDouble (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun optionalDouble$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Double; + public static synthetic fun optionalDouble$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun optionalDuration (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun optionalDuration$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun optionalDuration-f829m1k (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lkotlin/time/Duration; + public static synthetic fun optionalDuration-f829m1k$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/time/Duration; + public final fun optionalFloat (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/Float; + public final fun optionalFloat (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun optionalFloat$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Float; + public static synthetic fun optionalFloat$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun optionalInt (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/Integer; + public final fun optionalInt (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun optionalInt$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Integer; + public static synthetic fun optionalInt$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun optionalLong (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/Long; + public final fun optionalLong (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun optionalLong$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Long; + public static synthetic fun optionalLong$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun optionalUuid (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/util/UUID; + public final fun optionalUuid (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun optionalUuid$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/util/UUID; + public static synthetic fun optionalUuid$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun required (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/String; + public final fun required (ZLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun required$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/String; + public static synthetic fun required$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final synthetic fun requiredConverted (Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public final fun requiredNonBlank (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/String; + public final fun requiredNonBlank (ZLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun requiredNonBlank$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/String; + public static synthetic fun requiredNonBlank$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun requiredSecret (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/String; + public final fun requiredSecret (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun requiredSecret$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/String; + public static synthetic fun requiredSecret$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun string (Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/String; + public final fun string (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun string$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/String; + public static synthetic fun string$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun uuid (Ljava/lang/String;Ljava/util/UUID;ZLkotlin/jvm/functions/Function1;)Ljava/util/UUID; + public final fun uuid (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/util/UUID; + public final fun uuid (Ljava/util/UUID;Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public final fun uuid (Lkotlin/jvm/functions/Function1;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun uuid$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;Ljava/util/UUID;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/util/UUID; + public static synthetic fun uuid$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/util/UUID; + public static synthetic fun uuid$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Ljava/util/UUID;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; + public static synthetic fun uuid$default (Ldev/slne/surf/api/core/environment/EnvironmentVariables;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ldev/slne/surf/api/core/environment/EnvironmentVariableDelegate; +} + +public final class dev/slne/surf/api/core/environment/EnvironmentVariables$Companion { + public final fun from (Ldev/slne/surf/api/core/environment/EnvironmentSource;)Ldev/slne/surf/api/core/environment/EnvironmentVariables; + public final fun from (Ljava/util/Map;)Ldev/slne/surf/api/core/environment/EnvironmentVariables; +} + +public final class dev/slne/surf/api/core/environment/EnvironmentVariablesKt { + public static final field env Ldev/slne/surf/api/core/environment/EnvironmentVariables; +} + +public final class dev/slne/surf/api/core/environment/InvalidEnvironmentVariableException : dev/slne/surf/api/core/environment/EnvironmentVariableException { + public final fun getExpectedType ()Ljava/lang/String; + public final fun getRawValue ()Ljava/lang/String; +} + +public final class dev/slne/surf/api/core/environment/MissingEnvironmentVariableException : dev/slne/surf/api/core/environment/EnvironmentVariableException { + public fun (Ljava/lang/String;)V +} + public abstract class dev/slne/surf/api/core/event/SurfAsyncEvent : dev/slne/surf/api/core/event/SurfEvent { public fun ()V public final fun call (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/surf-api-core/surf-api-core/build.gradle.kts b/surf-api-core/surf-api-core/build.gradle.kts index 29b6817a..6fd98188 100644 --- a/surf-api-core/surf-api-core/build.gradle.kts +++ b/surf-api-core/surf-api-core/build.gradle.kts @@ -38,9 +38,15 @@ dependencies { api(libs.bundles.ktor.client) api(libs.datafixerupper) { isTransitive = false } + + testImplementation(kotlin("test-junit5")) } tasks { + test { + useJUnitPlatform() + } + shadowJar { val relocationPrefix: String by project relocate("com.mojang.serialization", "$relocationPrefix.mojang.serialization") diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentSource.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentSource.kt new file mode 100644 index 00000000..b6263144 --- /dev/null +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentSource.kt @@ -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? +} diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableDelegate.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableDelegate.kt new file mode 100644 index 00000000..db8753e4 --- /dev/null +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableDelegate.kt @@ -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 internal constructor( + private val explicitName: String?, + private val resolver: (String) -> T +) : PropertyDelegateProvider> { + /** + * 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 { + 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 { + val variableName = explicitName ?: property.name + val resolved = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + resolver(variableName) + } + + return ReadOnlyProperty { _, _ -> resolved.value } + } +} diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableException.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableException.kt new file mode 100644 index 00000000..8448918b --- /dev/null +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableException.kt @@ -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 diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableValidation.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableValidation.kt new file mode 100644 index 00000000..cef895d3 --- /dev/null +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariableValidation.kt @@ -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 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 + ) + } + + 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 > EnvironmentVariableValidation.requireIn(range: ClosedRange) { + require("expected a value in the range $range") { it in range } +} diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariables.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariables.kt new file mode 100644 index 00000000..51a5ca9e --- /dev/null +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariables.kt @@ -0,0 +1,654 @@ +package dev.slne.surf.api.core.environment + +import java.util.UUID +import kotlin.time.Duration + +/** + * Resolves, converts, and validates environment variables without a separate configuration schema. + * + * Direct functions resolve immediately. Overloads without a variable name return lazy, + * thread-safe delegates that infer the name from the property. Use [EnvironmentVariableDelegate.named] + * when a delegated property's environment-variable name differs from its Kotlin property name. + */ +class EnvironmentVariables private constructor( + private val source: EnvironmentSource +) { + /** + * Resolves a required string. Present blank values are returned unchanged. + */ + fun required( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): String = requiredConverted(name, "String", sensitive, { it }, validation) + + /** + * Resolves a required value using [converter]. + */ + inline fun required( + name: String, + noinline converter: (String) -> T, + sensitive: Boolean = false, + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): T = requiredConverted(name, targetTypeName(), sensitive, converter, validation) + + /** + * Resolves an optional string, returning `null` only when it is missing. + */ + fun optional( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): String? = optionalConverted(name, "String", sensitive, { it }, validation) + + /** + * Resolves an optional value using [converter], returning `null` when it is missing. + */ + inline fun optional( + name: String, + noinline converter: (String) -> T, + sensitive: Boolean = false, + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): T? = optionalConverted(name, targetTypeName(), sensitive, converter, validation) + + /** + * Resolves a required string and rejects blank values. + */ + fun requiredNonBlank( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): String = required(name, sensitive) { + require("expected a non-blank string") { it.isNotBlank() } + validation() + } + + /** + * Resolves a required sensitive string without including its value in failures. + * + * Like [required], this allows blank strings. Combine `requiredNonBlank` with + * `sensitive = true` when both guarantees are needed. + */ + fun requiredSecret( + name: String, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): String = required(name, sensitive = true, validation = validation) + + /** + * Resolves a string or returns [default] when it is missing. + */ + fun string( + name: String, + default: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): String = defaultedConverted(name, default, "String", sensitive, { it }, validation) + + /** Resolves a required [Int]. */ + fun int( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Int = requiredConverted(name, "Int", sensitive, String::toInt, validation) + + /** Resolves an optional [Int]. */ + fun optionalInt( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Int? = optionalConverted(name, "Int", sensitive, String::toInt, validation) + + /** Resolves an [Int] or returns [default] when it is missing. */ + fun int( + name: String, + default: Int, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Int = defaultedConverted(name, default, "Int", sensitive, String::toInt, validation) + + /** Resolves a required [Long]. */ + fun long( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Long = requiredConverted(name, "Long", sensitive, String::toLong, validation) + + /** Resolves an optional [Long]. */ + fun optionalLong( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Long? = optionalConverted(name, "Long", sensitive, String::toLong, validation) + + /** Resolves a [Long] or returns [default] when it is missing. */ + fun long( + name: String, + default: Long, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Long = defaultedConverted(name, default, "Long", sensitive, String::toLong, validation) + + /** Resolves a required [Double]. */ + fun double( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Double = requiredConverted(name, "Double", sensitive, String::toDouble, validation) + + /** Resolves an optional [Double]. */ + fun optionalDouble( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Double? = optionalConverted(name, "Double", sensitive, String::toDouble, validation) + + /** Resolves a [Double] or returns [default] when it is missing. */ + fun double( + name: String, + default: Double, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Double = defaultedConverted(name, default, "Double", sensitive, String::toDouble, validation) + + /** Resolves a required [Float]. */ + fun float( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Float = requiredConverted(name, "Float", sensitive, String::toFloat, validation) + + /** Resolves an optional [Float]. */ + fun optionalFloat( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Float? = optionalConverted(name, "Float", sensitive, String::toFloat, validation) + + /** Resolves a [Float] or returns [default] when it is missing. */ + fun float( + name: String, + default: Float, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Float = defaultedConverted(name, default, "Float", sensitive, String::toFloat, validation) + + /** + * Resolves a required strict boolean. + * + * Only `true` and `false` are accepted, case-insensitively. + */ + fun boolean( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Boolean = requiredConverted(name, "Boolean", sensitive, ::parseBoolean, validation) + + /** Resolves an optional strict boolean. */ + fun optionalBoolean( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Boolean? = optionalConverted(name, "Boolean", sensitive, ::parseBoolean, validation) + + /** Resolves a strict boolean or returns [default] when it is missing. */ + fun boolean( + name: String, + default: Boolean, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Boolean = defaultedConverted(name, default, "Boolean", sensitive, ::parseBoolean, validation) + + /** Resolves a required [UUID]. */ + fun uuid( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): UUID = requiredConverted(name, "UUID", sensitive, UUID::fromString, validation) + + /** Resolves an optional [UUID]. */ + fun optionalUuid( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): UUID? = optionalConverted(name, "UUID", sensitive, UUID::fromString, validation) + + /** Resolves a [UUID] or returns [default] when it is missing. */ + fun uuid( + name: String, + default: UUID, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): UUID = defaultedConverted(name, default, "UUID", sensitive, UUID::fromString, validation) + + /** Resolves a required Kotlin [Duration]. */ + fun duration( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Duration = requiredConverted(name, "Duration", sensitive, Duration::parse, validation) + + /** Resolves an optional Kotlin [Duration]. */ + fun optionalDuration( + name: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Duration? = optionalConverted(name, "Duration", sensitive, Duration::parse, validation) + + /** Resolves a Kotlin [Duration] or returns [default] when it is missing. */ + fun duration( + name: String, + default: Duration, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): Duration = defaultedConverted(name, default, "Duration", sensitive, Duration::parse, validation) + + /** Resolves a required enum constant by its exact constant name. */ + inline fun > enum( + name: String, + sensitive: Boolean = false, + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): T = required(name, { enumValueOf(it) }, sensitive, validation) + + /** Resolves an optional enum constant by its exact constant name. */ + inline fun > optionalEnum( + name: String, + sensitive: Boolean = false, + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): T? = optional(name, { enumValueOf(it) }, sensitive, validation) + + /** Resolves an enum constant or returns [default] when it is missing. */ + inline fun > enum( + name: String, + default: T, + sensitive: Boolean = false, + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): T = defaultedConverted( + name, + default, + targetTypeName(), + sensitive, + { enumValueOf(it) }, + validation + ) + + /** + * Creates a required string delegate using the property name as the variable name. + */ + fun required( + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + required(name, sensitive, validation) + } + + /** + * Creates a required converted-value delegate using the property name as the variable name. + */ + inline fun required( + noinline converter: (String) -> T, + sensitive: Boolean = false, + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + required(name, converter, sensitive, validation) + } + + /** + * Creates an optional string delegate using the property name as the variable name. + */ + fun optional( + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optional(name, sensitive, validation) + } + + /** + * Creates an optional converted-value delegate using the property name as the variable name. + */ + inline fun optional( + noinline converter: (String) -> T, + sensitive: Boolean = false, + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optional(name, converter, sensitive, validation) + } + + /** Creates a required non-blank string delegate. */ + fun requiredNonBlank( + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + requiredNonBlank(name, sensitive, validation) + } + + /** Creates a required sensitive string delegate. */ + fun requiredSecret( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = required(sensitive = true, validation) + + /** Creates a defaulted string delegate. */ + fun string( + default: String, + sensitive: Boolean = false, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + string(name, default, sensitive, validation) + } + + /** Creates a required [Int] delegate. */ + fun int( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> int(name, validation = validation) } + + /** Creates an optional [Int] delegate. */ + fun optionalInt( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optionalInt(name, validation = validation) + } + + /** Creates a defaulted [Int] delegate. */ + fun int( + default: Int, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> int(name, default, validation = validation) } + + /** Creates a required [Long] delegate. */ + fun long( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> long(name, validation = validation) } + + /** Creates an optional [Long] delegate. */ + fun optionalLong( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optionalLong(name, validation = validation) + } + + /** Creates a defaulted [Long] delegate. */ + fun long( + default: Long, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + long(name, default, validation = validation) + } + + /** Creates a required [Double] delegate. */ + fun double( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> double(name, validation = validation) } + + /** Creates an optional [Double] delegate. */ + fun optionalDouble( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optionalDouble(name, validation = validation) + } + + /** Creates a defaulted [Double] delegate. */ + fun double( + default: Double, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + double(name, default, validation = validation) + } + + /** Creates a required [Float] delegate. */ + fun float( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> float(name, validation = validation) } + + /** Creates an optional [Float] delegate. */ + fun optionalFloat( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optionalFloat(name, validation = validation) + } + + /** Creates a defaulted [Float] delegate. */ + fun float( + default: Float, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + float(name, default, validation = validation) + } + + /** Creates a required strict-boolean delegate. */ + fun boolean( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + boolean(name, validation = validation) + } + + /** Creates an optional strict-boolean delegate. */ + fun optionalBoolean( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optionalBoolean(name, validation = validation) + } + + /** Creates a defaulted strict-boolean delegate. */ + fun boolean( + default: Boolean, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + boolean(name, default, validation = validation) + } + + /** Creates a required [UUID] delegate. */ + fun uuid( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> uuid(name, validation = validation) } + + /** Creates an optional [UUID] delegate. */ + fun optionalUuid( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optionalUuid(name, validation = validation) + } + + /** Creates a defaulted [UUID] delegate. */ + fun uuid( + default: UUID, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + uuid(name, default, validation = validation) + } + + /** Creates a required Kotlin [Duration] delegate. */ + fun duration( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + duration(name, validation = validation) + } + + /** Creates an optional Kotlin [Duration] delegate. */ + fun optionalDuration( + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optionalDuration(name, validation = validation) + } + + /** Creates a defaulted Kotlin [Duration] delegate. */ + fun duration( + default: Duration, + validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + duration(name, default, validation = validation) + } + + /** Creates a required exact-name enum delegate. */ + inline fun > enum( + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> enum(name, validation = validation) } + + /** Creates an optional exact-name enum delegate. */ + inline fun > optionalEnum( + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + optionalEnum(name, validation = validation) + } + + /** Creates a defaulted exact-name enum delegate. */ + inline fun > enum( + default: T, + noinline validation: EnvironmentVariableValidation.() -> Unit = {} + ): EnvironmentVariableDelegate = delegate { name -> + enum(name, default, validation = validation) + } + + @PublishedApi + @JvmSynthetic + internal fun requiredConverted( + name: String, + expectedType: String, + sensitive: Boolean, + converter: (String) -> T, + validation: EnvironmentVariableValidation.() -> Unit + ): T { + validateName(name) + val rawValue = source[name] ?: throw MissingEnvironmentVariableException(name) + val value = convert(name, rawValue, expectedType, sensitive, converter) + validate(name, rawValue, sensitive, value, validation) + return value + } + + @PublishedApi + @JvmSynthetic + internal fun optionalConverted( + name: String, + expectedType: String, + sensitive: Boolean, + converter: (String) -> T, + validation: EnvironmentVariableValidation.() -> Unit + ): T? { + validateName(name) + val rawValue = source[name] ?: return null + val value = convert(name, rawValue, expectedType, sensitive, converter) + validate(name, rawValue, sensitive, value, validation) + return value + } + + @PublishedApi + @JvmSynthetic + internal fun defaultedConverted( + name: String, + default: T, + expectedType: String, + sensitive: Boolean, + converter: (String) -> T, + validation: EnvironmentVariableValidation.() -> Unit + ): T { + validateName(name) + val rawValue = source[name] + val value = if (rawValue == null) { + default + } else { + convert(name, rawValue, expectedType, sensitive, converter) + } + + validate(name, rawValue ?: default.toString(), sensitive, value, validation) + return value + } + + @PublishedApi + @JvmSynthetic + internal fun delegate( + explicitName: String? = null, + resolver: (String) -> T + ): EnvironmentVariableDelegate = EnvironmentVariableDelegate(explicitName, resolver) + + private fun convert( + name: String, + rawValue: String, + expectedType: String, + sensitive: Boolean, + converter: (String) -> T + ): T { + return try { + converter(rawValue) + } catch (exception: EnvironmentVariableException) { + throw exception + } catch (exception: Exception) { + throw InvalidEnvironmentVariableException( + variableName = name, + expectedType = expectedType, + rawValue = rawValue.takeUnless { sensitive }, + sensitive = sensitive, + cause = exception + ) + } + } + + private fun validate( + name: String, + rawValue: String, + sensitive: Boolean, + value: T, + validation: EnvironmentVariableValidation.() -> Unit + ) { + val scope = EnvironmentVariableValidation(name, rawValue, sensitive, value) + try { + scope.validation() + } catch (exception: EnvironmentVariableException) { + throw exception + } catch (exception: Exception) { + throw scope.failure( + exception.message ?: "validation threw ${exception::class.simpleName}", + exception + ) + } + } + + private fun validateName(name: String) { + require(name.isNotEmpty()) { "Environment-variable names must not be empty." } + require('\u0000' !in name && '=' !in name) { + "Environment-variable name '$name' contains an invalid character." + } + } + + /** + * Factory functions and the process-wide default environment. + */ + companion object { + /** + * Environment variables supplied by the current process. + */ + @JvmField + val system = EnvironmentVariables(EnvironmentSource(System::getenv)) + + /** + * Creates an environment backed by [source]. + */ + @JvmStatic + fun from(source: EnvironmentSource): EnvironmentVariables = EnvironmentVariables(source) + + /** + * Creates an environment backed by a stable copy of [values]. + */ + @JvmStatic + fun from(values: Map): EnvironmentVariables { + val snapshot = values.toMap() + return EnvironmentVariables(EnvironmentSource(snapshot::get)) + } + } +} + +/** + * Convenient process-wide environment-variable accessor. + */ +@JvmField +val env: EnvironmentVariables = EnvironmentVariables.system + +@PublishedApi +internal inline fun targetTypeName(): String { + return T::class.simpleName ?: "value" +} + +private fun parseBoolean(value: String): Boolean { + return when { + value.equals("true", ignoreCase = true) -> true + value.equals("false", ignoreCase = true) -> false + else -> throw IllegalArgumentException("Expected either 'true' or 'false'") + } +} diff --git a/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariablesTest.kt b/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariablesTest.kt new file mode 100644 index 00000000..ab174952 --- /dev/null +++ b/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariablesTest.kt @@ -0,0 +1,301 @@ +package dev.slne.surf.api.core.environment + +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds + +class EnvironmentVariablesTest { + @Test + fun `required string exists`() { + val environment = EnvironmentVariables.from(mapOf("HOST" to "localhost")) + + val host: String = environment.required("HOST") + + assertEquals("localhost", host) + } + + @Test + fun `required string may be blank`() { + val environment = EnvironmentVariables.from(mapOf("VALUE" to "")) + + assertEquals("", environment.required("VALUE")) + } + + @Test + fun `missing required string fails with its name`() { + val environment = EnvironmentVariables.from(emptyMap()) + + val exception = assertFailsWith { + environment.required("HOST") + } + + assertEquals("HOST", exception.variableName) + assertContains(exception.message.orEmpty(), "missing") + } + + @Test + fun `optional strings distinguish existing and missing values`() { + val environment = EnvironmentVariables.from(mapOf("PRESENT" to "value")) + + val present: String? = environment.optional("PRESENT") + val missing: String? = environment.optional("MISSING") + + assertEquals("value", present) + assertNull(missing) + } + + @Test + fun `default is used only for a missing value`() { + val environment = EnvironmentVariables.from( + mapOf( + "PRESENT" to "9000", + "INVALID" to "" + ) + ) + + assertEquals(9000, environment.int("PRESENT", default = 8080)) + assertEquals(8080, environment.int("MISSING", default = 8080)) + assertFailsWith { + environment.int("INVALID", default = 8080) + } + } + + @Test + fun `integer parsing is type safe and descriptive`() { + val environment = EnvironmentVariables.from( + mapOf( + "PORT" to "8080", + "INVALID_PORT" to "eight" + ) + ) + + val port: Int = environment.int("PORT") + val optionalPort: Int? = environment.optionalInt("OPTIONAL_PORT") + val exception = assertFailsWith { + environment.int("INVALID_PORT") + } + + assertEquals(8080, port) + assertNull(optionalPort) + assertEquals("Int", exception.expectedType) + assertContains(exception.message.orEmpty(), "INVALID_PORT") + assertContains(exception.message.orEmpty(), "eight") + assertIs(exception.cause) + } + + @Test + fun `boolean parsing accepts only true and false ignoring case`() { + val environment = EnvironmentVariables.from( + mapOf( + "TRUE" to "TrUe", + "FALSE" to "FALSE", + "INVALID" to "yes-maybe" + ) + ) + + assertTrue(environment.boolean("TRUE")) + assertFalse(environment.boolean("FALSE")) + + val exception = assertFailsWith { + environment.boolean("INVALID") + } + assertEquals("Boolean", exception.expectedType) + assertContains(exception.message.orEmpty(), "yes-maybe") + } + + @Test + fun `enum parsing accepts exact constant names and rejects others`() { + val environment = EnvironmentVariables.from( + mapOf( + "MODE" to "PRODUCTION", + "INVALID_MODE" to "production" + ) + ) + + assertEquals(Mode.PRODUCTION, environment.enum("MODE")) + + val exception = assertFailsWith { + environment.enum("INVALID_MODE") + } + assertEquals("Mode", exception.expectedType) + } + + @Test + fun `generic converters preserve static types and parser failures`() { + val id = UUID.randomUUID() + val environment = EnvironmentVariables.from( + mapOf( + "ID" to id.toString(), + "INVALID_ID" to "not-a-uuid", + "TIMEOUT" to "15s" + ) + ) + + val parsedId: UUID = environment.required("ID", UUID::fromString) + val optionalId: UUID? = environment.optional("OPTIONAL_ID", UUID::fromString) + + assertEquals(id, parsedId) + assertNull(optionalId) + assertEquals(15.seconds, environment.duration("TIMEOUT")) + + val exception = assertFailsWith { + environment.required("INVALID_ID", UUID::fromString) + } + assertEquals("UUID", exception.expectedType) + assertIs(exception.cause) + } + + @Test + fun `validation runs for parsed and default values`() { + val environment = EnvironmentVariables.from( + mapOf( + "PORT" to "443", + "INVALID_PORT" to "70000" + ) + ) + + assertEquals(443, environment.int("PORT") { requireIn(1..65535) }) + assertEquals(4, environment.int("THREADS", default = 4) { + require("expected a positive thread count") { it > 0 } + }) + + val rangeException = assertFailsWith { + environment.int("INVALID_PORT") { requireIn(1..65535) } + } + assertContains(rangeException.message.orEmpty(), "INVALID_PORT") + assertContains(rangeException.message.orEmpty(), "1..65535") + + assertFailsWith { + environment.int("THREADS", default = 0) { + require("expected a positive thread count") { it > 0 } + } + } + } + + @Test + fun `required non-blank strings reject blank values`() { + val environment = EnvironmentVariables.from(mapOf("TOKEN" to " ")) + + val exception = assertFailsWith { + environment.requiredNonBlank("TOKEN") + } + + assertContains(exception.message.orEmpty(), "non-blank") + } + + @Test + fun `sensitive failures do not retain or display raw values`() { + val secret = "top-secret-value" + val environment = EnvironmentVariables.from(mapOf("PASSWORD" to secret)) + + val exception = assertFailsWith { + environment.int("PASSWORD", sensitive = true) + } + + assertFalse(exception.message.orEmpty().contains(secret)) + assertNull(exception.rawValue) + assertNull(exception.cause) + } + + @Test + fun `delegates infer names and preserve nullability`() { + val environment = EnvironmentVariables.from( + mapOf( + "HOST" to "localhost", + "PORT" to "8080", + "ENABLED" to "true" + ) + ) + + val HOST: String by environment.required() + val PORT: Int by environment.int() + val OPTIONAL_PORT: Int? by environment.optionalInt() + val ENABLED: Boolean by environment.boolean(default = false) + + assertEquals("localhost", HOST) + assertEquals(8080, PORT) + assertNull(OPTIONAL_PORT) + assertTrue(ENABLED) + } + + @Test + fun `named delegates use the explicit variable name`() { + val environment = EnvironmentVariables.from(mapOf("DATABASE_HOST" to "database")) + + val databaseHost: String by environment.required().named("DATABASE_HOST") + + assertEquals("database", databaseHost) + } + + @Test + fun `delegates resolve lazily and cache null and non-null results`() { + val resolutions = AtomicInteger() + val environment = EnvironmentVariables.from(EnvironmentSource { name -> + resolutions.incrementAndGet() + if (name == "VALUE") "value" else null + }) + + val VALUE: String by environment.required() + val MISSING: String? by environment.optional() + + assertEquals(0, resolutions.get()) + assertEquals("value", VALUE) + assertEquals("value", VALUE) + assertNull(MISSING) + assertNull(MISSING) + assertEquals(2, resolutions.get()) + } + + @Test + fun `concurrent delegate access resolves exactly once`() { + val resolutions = AtomicInteger() + val environment = EnvironmentVariables.from(EnvironmentSource { + resolutions.incrementAndGet() + Thread.sleep(20) + "8080" + }) + val PORT: Int by environment.int() + val executor = Executors.newFixedThreadPool(8) + val start = CountDownLatch(1) + + try { + val futures = List(32) { + executor.submit { + start.await() + PORT + } + } + + start.countDown() + assertTrue(futures.all { it.get(5, TimeUnit.SECONDS) == 8080 }) + assertEquals(1, resolutions.get()) + } finally { + executor.shutdownNow() + } + } + + @Test + fun `map-backed sources are copied on creation`() { + val values = mutableMapOf("PORT" to "8080") + val environment = EnvironmentVariables.from(values) + values["PORT"] = "9000" + + assertEquals(8080, environment.int("PORT")) + } + + private enum class Mode { + DEVELOPMENT, + PRODUCTION + } +} From 169e555e8645ff7d198d84f6fa5601acc9fd0c8f Mon Sep 17 00:00:00 2001 From: twisti-dev <76837088+twisti-dev@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:00:39 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=A7=20chore:=20update=20version=20?= =?UTF-8?q?to=203.34.0=20in=20gradle.properties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 23423054..cc684ad4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 From b462b1f00d53a87e53685dd3e745784a5a00556d Mon Sep 17 00:00:00 2001 From: twisti-dev <76837088+twisti-dev@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:02:37 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(build):=20rem?= =?UTF-8?q?ove=20unused=20test=20configuration=20from=20build.gradle.kts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete testImplementation and test task configuration from build script --- surf-api-core/surf-api-core/build.gradle.kts | 6 - .../environment/EnvironmentVariablesTest.kt | 301 ------------------ 2 files changed, 307 deletions(-) delete mode 100644 surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariablesTest.kt diff --git a/surf-api-core/surf-api-core/build.gradle.kts b/surf-api-core/surf-api-core/build.gradle.kts index 6fd98188..29b6817a 100644 --- a/surf-api-core/surf-api-core/build.gradle.kts +++ b/surf-api-core/surf-api-core/build.gradle.kts @@ -38,15 +38,9 @@ dependencies { api(libs.bundles.ktor.client) api(libs.datafixerupper) { isTransitive = false } - - testImplementation(kotlin("test-junit5")) } tasks { - test { - useJUnitPlatform() - } - shadowJar { val relocationPrefix: String by project relocate("com.mojang.serialization", "$relocationPrefix.mojang.serialization") diff --git a/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariablesTest.kt b/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariablesTest.kt deleted file mode 100644 index ab174952..00000000 --- a/surf-api-core/surf-api-core/src/test/kotlin/dev/slne/surf/api/core/environment/EnvironmentVariablesTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package dev.slne.surf.api.core.environment - -import java.util.UUID -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.test.assertFailsWith -import kotlin.time.Duration.Companion.seconds - -class EnvironmentVariablesTest { - @Test - fun `required string exists`() { - val environment = EnvironmentVariables.from(mapOf("HOST" to "localhost")) - - val host: String = environment.required("HOST") - - assertEquals("localhost", host) - } - - @Test - fun `required string may be blank`() { - val environment = EnvironmentVariables.from(mapOf("VALUE" to "")) - - assertEquals("", environment.required("VALUE")) - } - - @Test - fun `missing required string fails with its name`() { - val environment = EnvironmentVariables.from(emptyMap()) - - val exception = assertFailsWith { - environment.required("HOST") - } - - assertEquals("HOST", exception.variableName) - assertContains(exception.message.orEmpty(), "missing") - } - - @Test - fun `optional strings distinguish existing and missing values`() { - val environment = EnvironmentVariables.from(mapOf("PRESENT" to "value")) - - val present: String? = environment.optional("PRESENT") - val missing: String? = environment.optional("MISSING") - - assertEquals("value", present) - assertNull(missing) - } - - @Test - fun `default is used only for a missing value`() { - val environment = EnvironmentVariables.from( - mapOf( - "PRESENT" to "9000", - "INVALID" to "" - ) - ) - - assertEquals(9000, environment.int("PRESENT", default = 8080)) - assertEquals(8080, environment.int("MISSING", default = 8080)) - assertFailsWith { - environment.int("INVALID", default = 8080) - } - } - - @Test - fun `integer parsing is type safe and descriptive`() { - val environment = EnvironmentVariables.from( - mapOf( - "PORT" to "8080", - "INVALID_PORT" to "eight" - ) - ) - - val port: Int = environment.int("PORT") - val optionalPort: Int? = environment.optionalInt("OPTIONAL_PORT") - val exception = assertFailsWith { - environment.int("INVALID_PORT") - } - - assertEquals(8080, port) - assertNull(optionalPort) - assertEquals("Int", exception.expectedType) - assertContains(exception.message.orEmpty(), "INVALID_PORT") - assertContains(exception.message.orEmpty(), "eight") - assertIs(exception.cause) - } - - @Test - fun `boolean parsing accepts only true and false ignoring case`() { - val environment = EnvironmentVariables.from( - mapOf( - "TRUE" to "TrUe", - "FALSE" to "FALSE", - "INVALID" to "yes-maybe" - ) - ) - - assertTrue(environment.boolean("TRUE")) - assertFalse(environment.boolean("FALSE")) - - val exception = assertFailsWith { - environment.boolean("INVALID") - } - assertEquals("Boolean", exception.expectedType) - assertContains(exception.message.orEmpty(), "yes-maybe") - } - - @Test - fun `enum parsing accepts exact constant names and rejects others`() { - val environment = EnvironmentVariables.from( - mapOf( - "MODE" to "PRODUCTION", - "INVALID_MODE" to "production" - ) - ) - - assertEquals(Mode.PRODUCTION, environment.enum("MODE")) - - val exception = assertFailsWith { - environment.enum("INVALID_MODE") - } - assertEquals("Mode", exception.expectedType) - } - - @Test - fun `generic converters preserve static types and parser failures`() { - val id = UUID.randomUUID() - val environment = EnvironmentVariables.from( - mapOf( - "ID" to id.toString(), - "INVALID_ID" to "not-a-uuid", - "TIMEOUT" to "15s" - ) - ) - - val parsedId: UUID = environment.required("ID", UUID::fromString) - val optionalId: UUID? = environment.optional("OPTIONAL_ID", UUID::fromString) - - assertEquals(id, parsedId) - assertNull(optionalId) - assertEquals(15.seconds, environment.duration("TIMEOUT")) - - val exception = assertFailsWith { - environment.required("INVALID_ID", UUID::fromString) - } - assertEquals("UUID", exception.expectedType) - assertIs(exception.cause) - } - - @Test - fun `validation runs for parsed and default values`() { - val environment = EnvironmentVariables.from( - mapOf( - "PORT" to "443", - "INVALID_PORT" to "70000" - ) - ) - - assertEquals(443, environment.int("PORT") { requireIn(1..65535) }) - assertEquals(4, environment.int("THREADS", default = 4) { - require("expected a positive thread count") { it > 0 } - }) - - val rangeException = assertFailsWith { - environment.int("INVALID_PORT") { requireIn(1..65535) } - } - assertContains(rangeException.message.orEmpty(), "INVALID_PORT") - assertContains(rangeException.message.orEmpty(), "1..65535") - - assertFailsWith { - environment.int("THREADS", default = 0) { - require("expected a positive thread count") { it > 0 } - } - } - } - - @Test - fun `required non-blank strings reject blank values`() { - val environment = EnvironmentVariables.from(mapOf("TOKEN" to " ")) - - val exception = assertFailsWith { - environment.requiredNonBlank("TOKEN") - } - - assertContains(exception.message.orEmpty(), "non-blank") - } - - @Test - fun `sensitive failures do not retain or display raw values`() { - val secret = "top-secret-value" - val environment = EnvironmentVariables.from(mapOf("PASSWORD" to secret)) - - val exception = assertFailsWith { - environment.int("PASSWORD", sensitive = true) - } - - assertFalse(exception.message.orEmpty().contains(secret)) - assertNull(exception.rawValue) - assertNull(exception.cause) - } - - @Test - fun `delegates infer names and preserve nullability`() { - val environment = EnvironmentVariables.from( - mapOf( - "HOST" to "localhost", - "PORT" to "8080", - "ENABLED" to "true" - ) - ) - - val HOST: String by environment.required() - val PORT: Int by environment.int() - val OPTIONAL_PORT: Int? by environment.optionalInt() - val ENABLED: Boolean by environment.boolean(default = false) - - assertEquals("localhost", HOST) - assertEquals(8080, PORT) - assertNull(OPTIONAL_PORT) - assertTrue(ENABLED) - } - - @Test - fun `named delegates use the explicit variable name`() { - val environment = EnvironmentVariables.from(mapOf("DATABASE_HOST" to "database")) - - val databaseHost: String by environment.required().named("DATABASE_HOST") - - assertEquals("database", databaseHost) - } - - @Test - fun `delegates resolve lazily and cache null and non-null results`() { - val resolutions = AtomicInteger() - val environment = EnvironmentVariables.from(EnvironmentSource { name -> - resolutions.incrementAndGet() - if (name == "VALUE") "value" else null - }) - - val VALUE: String by environment.required() - val MISSING: String? by environment.optional() - - assertEquals(0, resolutions.get()) - assertEquals("value", VALUE) - assertEquals("value", VALUE) - assertNull(MISSING) - assertNull(MISSING) - assertEquals(2, resolutions.get()) - } - - @Test - fun `concurrent delegate access resolves exactly once`() { - val resolutions = AtomicInteger() - val environment = EnvironmentVariables.from(EnvironmentSource { - resolutions.incrementAndGet() - Thread.sleep(20) - "8080" - }) - val PORT: Int by environment.int() - val executor = Executors.newFixedThreadPool(8) - val start = CountDownLatch(1) - - try { - val futures = List(32) { - executor.submit { - start.await() - PORT - } - } - - start.countDown() - assertTrue(futures.all { it.get(5, TimeUnit.SECONDS) == 8080 }) - assertEquals(1, resolutions.get()) - } finally { - executor.shutdownNow() - } - } - - @Test - fun `map-backed sources are copied on creation`() { - val values = mutableMapOf("PORT" to "8080") - val environment = EnvironmentVariables.from(values) - values["PORT"] = "9000" - - assertEquals(8080, environment.int("PORT")) - } - - private enum class Mode { - DEVELOPMENT, - PRODUCTION - } -}