From 212aa55152cd7f5cb350966a6755db420478523f Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 28 Dec 2025 20:30:24 +0100 Subject: [PATCH 1/3] feat(core): support validating environment variables --- .../EnvironmentVariableValidationFailed.php | 30 +++++ packages/core/src/functions.php | 34 ++++-- packages/core/tests/EnvTest.php | 111 ++++++++++++++++++ .../src/Exceptions/TranslatorWasRequired.php | 15 +++ packages/validation/src/Validator.php | 9 +- 5 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/EnvironmentVariableValidationFailed.php create mode 100644 packages/core/tests/EnvTest.php create mode 100644 packages/validation/src/Exceptions/TranslatorWasRequired.php diff --git a/packages/core/src/EnvironmentVariableValidationFailed.php b/packages/core/src/EnvironmentVariableValidationFailed.php new file mode 100644 index 000000000..1bb4f4d22 --- /dev/null +++ b/packages/core/src/EnvironmentVariableValidationFailed.php @@ -0,0 +1,30 @@ +map(fn (FailingRule $failingRule) => $validator->getErrorMessage($failingRule, $name)) + ->implode("\n- ") + ->toString(), + ])); + } +} diff --git a/packages/core/src/functions.php b/packages/core/src/functions.php index 4c258d29f..c94bfee6f 100644 --- a/packages/core/src/functions.php +++ b/packages/core/src/functions.php @@ -7,9 +7,12 @@ use Stringable; use Tempest\Core\Composer; use Tempest\Core\DeferredTasks; + use Tempest\Core\EnvironmentVariableValidationFailed; use Tempest\Core\ExceptionReporter; use Tempest\Core\Kernel; use Tempest\Support\Namespace\PathCouldNotBeMappedToNamespace; + use Tempest\Validation\Rule; + use Tempest\Validation\Validator; use Throwable; use function Tempest\Support\Namespace\to_psr4_namespace; @@ -61,21 +64,36 @@ function src_namespace(Stringable|string ...$parts): string /** * Retrieves the given `$key` from the environment variables. If `$key` is not defined, `$default` is returned instead. + * + * @param Rule[] $rules Optional validation rules for the value of this environment variable. If one of the rules don't pass, an exception is thrown, preventing the application from booting. */ - function env(string $key, mixed $default = null): mixed + function env(string $key, mixed $default = null, array $rules = []): mixed { $value = getenv($key); - - if ($value === false) { - return $default; - } - - return match (strtolower($value)) { + $value = match (is_string($value) ? mb_strtolower($value) : $value) { 'true' => true, 'false' => false, - 'null', '' => null, + false, 'null', '' => $default, default => $value, }; + + if ($rules === []) { + return $value; + } + + $validator = get(Validator::class); + $failures = $validator->validateValue($value, $rules); + + if ($failures === []) { + return $value; + } + + throw new EnvironmentVariableValidationFailed( + name: $key, + value: $value, + failingRules: $failures, + validator: $validator, + ); } /** diff --git a/packages/core/tests/EnvTest.php b/packages/core/tests/EnvTest.php new file mode 100644 index 000000000..e72a3f35a --- /dev/null +++ b/packages/core/tests/EnvTest.php @@ -0,0 +1,111 @@ +singleton(Translator::class, new GenericTranslator( + config: new IntlConfig(currentLocale: Locale::ENGLISH, fallbackLocale: Locale::ENGLISH), + catalog: new GenericCatalog([ + 'en' => [ + 'validation_error' => [ + 'is_numeric' => '{{{$field} must be a numeric value}}', + ], + ], + ]), + formatter: new MessageFormatter(), + )); + + GenericContainer::setInstance($container); + } + + #[Test] + #[TestWith([null, null])] + #[TestWith(['', null])] + #[TestWith(['null', null])] + #[TestWith([false, null])] + #[TestWith(['FALSE', false])] + #[TestWith(['false', false])] + #[TestWith(['TRUE', true])] + #[TestWith(['true', true])] + #[TestWith(['foo', 'foo'])] + #[TestWith(['FOO', 'FOO'])] + #[TestWith([1, '1'])] + public function basic(mixed $value, mixed $expected): void + { + putenv("_ENV_TESTING_KEY={$value}"); + + $this->assertSame($expected, env('_ENV_TESTING_KEY')); + } + + #[Test] + #[TestWith([null, 'fallback', 'fallback'])] + #[TestWith([false, 'fallback', 'fallback'])] + #[TestWith(['', 'fallback', 'fallback'])] + #[TestWith(['false', 'fallback', false])] + #[TestWith(['true', 'fallback', true])] + #[TestWith([false, '', ''])] + #[TestWith([null, '', ''])] + #[TestWith(['', '', ''])] + #[TestWith([false, false, false])] + #[TestWith([null, false, false])] + #[TestWith(['', false, false])] + public function default(mixed $value, mixed $default, mixed $expected): void + { + putenv("_ENV_TESTING_KEY={$value}"); + + $this->assertSame($expected, env('_ENV_TESTING_KEY', default: $default)); + } + + #[Test] + public function fails_with_failing_rules(): void + { + $this->expectException(EnvironmentVariableValidationFailed::class); + $this->expectExceptionMessageMatches('*_ENV_TESTING_KEY must be a numeric value*'); + + putenv('_ENV_TESTING_KEY=foo'); + env('_ENV_TESTING_KEY', rules: [new IsNumeric()]); + } + + #[Test] + #[TestWith([null, null])] + #[TestWith(['', null])] + #[TestWith([false, null])] + public function default_taken_into_account(mixed $value, mixed $default): void + { + $this->expectException(EnvironmentVariableValidationFailed::class); + + putenv("_ENV_TESTING_KEY={$value}"); + env('_ENV_TESTING_KEY', default: $default, rules: [new IsNotNull()]); + } + + #[Test] + public function can_pass(): void + { + putenv('_ENV_TESTING_KEY=true'); + + $this->assertSame(true, env('_ENV_TESTING_KEY', rules: [new IsBoolean()])); + } +} diff --git a/packages/validation/src/Exceptions/TranslatorWasRequired.php b/packages/validation/src/Exceptions/TranslatorWasRequired.php new file mode 100644 index 000000000..0a5acc634 --- /dev/null +++ b/packages/validation/src/Exceptions/TranslatorWasRequired.php @@ -0,0 +1,15 @@ +translator)) { + throw new TranslatorWasRequired(); + } + if ($rule instanceof HasErrorMessage) { return $rule->getErrorMessage(); } @@ -261,7 +266,7 @@ private function getTranslationKey(Rule|FailingRule $rule): string private function getFieldName(string $key, ?string $field = null): string { - $translatedField = $this->translator->translate("validation_field.{$key}"); + $translatedField = $this->translator?->translate("validation_field.{$key}"); if ($translatedField === "validation_field.{$key}") { return $field ?? 'Value'; From 19fb4b8014052fcb3b36e581f5bcfad3a4ad7a94 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 28 Dec 2025 21:31:31 +0100 Subject: [PATCH 2/3] fix: add intl as core dev dependency --- packages/core/composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/composer.json b/packages/core/composer.json index 61182e788..0fec68fe2 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -13,6 +13,9 @@ "symfony/cache": "^7.3", "filp/whoops": "^2.15" }, + "require-dev": { + "tempest/intl": "dev-main" + }, "autoload": { "psr-4": { "Tempest\\Core\\": "src" From a39cb46ac1b3336ef69abe15990b59a2b601f87a Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 28 Dec 2025 22:36:23 +0100 Subject: [PATCH 3/3] fix: skip tests when dependencies are missing --- packages/core/composer.json | 1 + packages/core/src/functions.php | 3 ++- packages/core/tests/EnvTest.php | 9 +++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/composer.json b/packages/core/composer.json index 0fec68fe2..cf906e8d3 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -14,6 +14,7 @@ "filp/whoops": "^2.15" }, "require-dev": { + "tempest/validation": "dev-main", "tempest/intl": "dev-main" }, "autoload": { diff --git a/packages/core/src/functions.php b/packages/core/src/functions.php index c94bfee6f..ac94a15d2 100644 --- a/packages/core/src/functions.php +++ b/packages/core/src/functions.php @@ -10,6 +10,7 @@ use Tempest\Core\EnvironmentVariableValidationFailed; use Tempest\Core\ExceptionReporter; use Tempest\Core\Kernel; + use Tempest\Intl\Translator; use Tempest\Support\Namespace\PathCouldNotBeMappedToNamespace; use Tempest\Validation\Rule; use Tempest\Validation\Validator; @@ -77,7 +78,7 @@ function env(string $key, mixed $default = null, array $rules = []): mixed default => $value, }; - if ($rules === []) { + if ($rules === [] || ! class_exists(Validator::class) || ! class_exists(Translator::class)) { return $value; } diff --git a/packages/core/tests/EnvTest.php b/packages/core/tests/EnvTest.php index e72a3f35a..b11765fff 100644 --- a/packages/core/tests/EnvTest.php +++ b/packages/core/tests/EnvTest.php @@ -17,6 +17,7 @@ use Tempest\Validation\Rules\IsBoolean; use Tempest\Validation\Rules\IsNotNull; use Tempest\Validation\Rules\IsNumeric; +use Tempest\Validation\Validator; use function Tempest\env; @@ -25,6 +26,14 @@ final class EnvTest extends TestCase #[PreCondition] protected function configure(): void { + if (! class_exists(Translator::class)) { + $this->markTestSkipped('`tempest/intl` is required for this test.'); + } + + if (! class_exists(Validator::class)) { + $this->markTestSkipped('`tempest/validation` is required for this test.'); + } + $container = new GenericContainer(); $container->singleton(Translator::class, new GenericTranslator( config: new IntlConfig(currentLocale: Locale::ENGLISH, fallbackLocale: Locale::ENGLISH),