diff --git a/packages/core/composer.json b/packages/core/composer.json index 61182e788..cf906e8d3 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -13,6 +13,10 @@ "symfony/cache": "^7.3", "filp/whoops": "^2.15" }, + "require-dev": { + "tempest/validation": "dev-main", + "tempest/intl": "dev-main" + }, "autoload": { "psr-4": { "Tempest\\Core\\": "src" 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..ac94a15d2 100644 --- a/packages/core/src/functions.php +++ b/packages/core/src/functions.php @@ -7,9 +7,13 @@ use Stringable; use Tempest\Core\Composer; use Tempest\Core\DeferredTasks; + 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; use Throwable; use function Tempest\Support\Namespace\to_psr4_namespace; @@ -61,21 +65,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 === [] || ! class_exists(Validator::class) || ! class_exists(Translator::class)) { + 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..b11765fff --- /dev/null +++ b/packages/core/tests/EnvTest.php @@ -0,0 +1,120 @@ +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), + 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';