Skip to content

Commit f36b43b

Browse files
authored
feat(core): support validating environment variables (#1836)
1 parent ad32ffe commit f36b43b

File tree

6 files changed

+203
-10
lines changed

6 files changed

+203
-10
lines changed

packages/core/composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"symfony/cache": "^7.3",
1414
"filp/whoops": "^2.15"
1515
},
16+
"require-dev": {
17+
"tempest/validation": "dev-main",
18+
"tempest/intl": "dev-main"
19+
},
1620
"autoload": {
1721
"psr-4": {
1822
"Tempest\\Core\\": "src"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Tempest\Core;
4+
5+
use Exception;
6+
use Tempest\Validation\FailingRule;
7+
use Tempest\Validation\Validator;
8+
9+
use function Tempest\Support\arr;
10+
11+
final class EnvironmentVariableValidationFailed extends Exception
12+
{
13+
/**
14+
* @param FailingRule[] $failingRules
15+
*/
16+
public function __construct(
17+
private(set) string $name,
18+
private(set) mixed $value,
19+
private(set) array $failingRules,
20+
private(set) Validator $validator,
21+
) {
22+
return parent::__construct(vsprintf("Environment variable [%s] is not valid:\n- %s", [
23+
$name,
24+
arr($failingRules)
25+
->map(fn (FailingRule $failingRule) => $validator->getErrorMessage($failingRule, $name))
26+
->implode("\n- ")
27+
->toString(),
28+
]));
29+
}
30+
}

packages/core/src/functions.php

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@
77
use Stringable;
88
use Tempest\Core\Composer;
99
use Tempest\Core\DeferredTasks;
10+
use Tempest\Core\EnvironmentVariableValidationFailed;
1011
use Tempest\Core\ExceptionReporter;
1112
use Tempest\Core\Kernel;
13+
use Tempest\Intl\Translator;
1214
use Tempest\Support\Namespace\PathCouldNotBeMappedToNamespace;
15+
use Tempest\Validation\Rule;
16+
use Tempest\Validation\Validator;
1317
use Throwable;
1418

1519
use function Tempest\Support\Namespace\to_psr4_namespace;
@@ -61,21 +65,36 @@ function src_namespace(Stringable|string ...$parts): string
6165

6266
/**
6367
* Retrieves the given `$key` from the environment variables. If `$key` is not defined, `$default` is returned instead.
68+
*
69+
* @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.
6470
*/
65-
function env(string $key, mixed $default = null): mixed
71+
function env(string $key, mixed $default = null, array $rules = []): mixed
6672
{
6773
$value = getenv($key);
68-
69-
if ($value === false) {
70-
return $default;
71-
}
72-
73-
return match (strtolower($value)) {
74+
$value = match (is_string($value) ? mb_strtolower($value) : $value) {
7475
'true' => true,
7576
'false' => false,
76-
'null', '' => null,
77+
false, 'null', '' => $default,
7778
default => $value,
7879
};
80+
81+
if ($rules === [] || ! class_exists(Validator::class) || ! class_exists(Translator::class)) {
82+
return $value;
83+
}
84+
85+
$validator = get(Validator::class);
86+
$failures = $validator->validateValue($value, $rules);
87+
88+
if ($failures === []) {
89+
return $value;
90+
}
91+
92+
throw new EnvironmentVariableValidationFailed(
93+
name: $key,
94+
value: $value,
95+
failingRules: $failures,
96+
validator: $validator,
97+
);
7998
}
8099

81100
/**

packages/core/tests/EnvTest.php

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
namespace Tempest\Core\Tests;
4+
5+
use PHPUnit\Framework\Attributes\PreCondition;
6+
use PHPUnit\Framework\Attributes\Test;
7+
use PHPUnit\Framework\Attributes\TestWith;
8+
use PHPUnit\Framework\TestCase;
9+
use Tempest\Container\GenericContainer;
10+
use Tempest\Core\EnvironmentVariableValidationFailed;
11+
use Tempest\Intl\Catalog\GenericCatalog;
12+
use Tempest\Intl\GenericTranslator;
13+
use Tempest\Intl\IntlConfig;
14+
use Tempest\Intl\Locale;
15+
use Tempest\Intl\MessageFormat\Formatter\MessageFormatter;
16+
use Tempest\Intl\Translator;
17+
use Tempest\Validation\Rules\IsBoolean;
18+
use Tempest\Validation\Rules\IsNotNull;
19+
use Tempest\Validation\Rules\IsNumeric;
20+
use Tempest\Validation\Validator;
21+
22+
use function Tempest\env;
23+
24+
final class EnvTest extends TestCase
25+
{
26+
#[PreCondition]
27+
protected function configure(): void
28+
{
29+
if (! class_exists(Translator::class)) {
30+
$this->markTestSkipped('`tempest/intl` is required for this test.');
31+
}
32+
33+
if (! class_exists(Validator::class)) {
34+
$this->markTestSkipped('`tempest/validation` is required for this test.');
35+
}
36+
37+
$container = new GenericContainer();
38+
$container->singleton(Translator::class, new GenericTranslator(
39+
config: new IntlConfig(currentLocale: Locale::ENGLISH, fallbackLocale: Locale::ENGLISH),
40+
catalog: new GenericCatalog([
41+
'en' => [
42+
'validation_error' => [
43+
'is_numeric' => '{{{$field} must be a numeric value}}',
44+
],
45+
],
46+
]),
47+
formatter: new MessageFormatter(),
48+
));
49+
50+
GenericContainer::setInstance($container);
51+
}
52+
53+
#[Test]
54+
#[TestWith([null, null])]
55+
#[TestWith(['', null])]
56+
#[TestWith(['null', null])]
57+
#[TestWith([false, null])]
58+
#[TestWith(['FALSE', false])]
59+
#[TestWith(['false', false])]
60+
#[TestWith(['TRUE', true])]
61+
#[TestWith(['true', true])]
62+
#[TestWith(['foo', 'foo'])]
63+
#[TestWith(['FOO', 'FOO'])]
64+
#[TestWith([1, '1'])]
65+
public function basic(mixed $value, mixed $expected): void
66+
{
67+
putenv("_ENV_TESTING_KEY={$value}");
68+
69+
$this->assertSame($expected, env('_ENV_TESTING_KEY'));
70+
}
71+
72+
#[Test]
73+
#[TestWith([null, 'fallback', 'fallback'])]
74+
#[TestWith([false, 'fallback', 'fallback'])]
75+
#[TestWith(['', 'fallback', 'fallback'])]
76+
#[TestWith(['false', 'fallback', false])]
77+
#[TestWith(['true', 'fallback', true])]
78+
#[TestWith([false, '', ''])]
79+
#[TestWith([null, '', ''])]
80+
#[TestWith(['', '', ''])]
81+
#[TestWith([false, false, false])]
82+
#[TestWith([null, false, false])]
83+
#[TestWith(['', false, false])]
84+
public function default(mixed $value, mixed $default, mixed $expected): void
85+
{
86+
putenv("_ENV_TESTING_KEY={$value}");
87+
88+
$this->assertSame($expected, env('_ENV_TESTING_KEY', default: $default));
89+
}
90+
91+
#[Test]
92+
public function fails_with_failing_rules(): void
93+
{
94+
$this->expectException(EnvironmentVariableValidationFailed::class);
95+
$this->expectExceptionMessageMatches('*_ENV_TESTING_KEY must be a numeric value*');
96+
97+
putenv('_ENV_TESTING_KEY=foo');
98+
env('_ENV_TESTING_KEY', rules: [new IsNumeric()]);
99+
}
100+
101+
#[Test]
102+
#[TestWith([null, null])]
103+
#[TestWith(['', null])]
104+
#[TestWith([false, null])]
105+
public function default_taken_into_account(mixed $value, mixed $default): void
106+
{
107+
$this->expectException(EnvironmentVariableValidationFailed::class);
108+
109+
putenv("_ENV_TESTING_KEY={$value}");
110+
env('_ENV_TESTING_KEY', default: $default, rules: [new IsNotNull()]);
111+
}
112+
113+
#[Test]
114+
public function can_pass(): void
115+
{
116+
putenv('_ENV_TESTING_KEY=true');
117+
118+
$this->assertSame(true, env('_ENV_TESTING_KEY', rules: [new IsBoolean()]));
119+
}
120+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Validation\Exceptions;
6+
7+
use Exception;
8+
9+
final class TranslatorWasRequired extends Exception
10+
{
11+
public function __construct()
12+
{
13+
parent::__construct('A translator instance is required to generate validation error messages, but none was provided.');
14+
}
15+
}

packages/validation/src/Validator.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Tempest\Reflection\ClassReflector;
1111
use Tempest\Reflection\PropertyReflector;
1212
use Tempest\Support\Arr;
13+
use Tempest\Validation\Exceptions\TranslatorWasRequired;
1314
use Tempest\Validation\Exceptions\ValidationFailed;
1415
use Tempest\Validation\Rules\IsBoolean;
1516
use Tempest\Validation\Rules\IsEnum;
@@ -25,7 +26,7 @@
2526
final readonly class Validator
2627
{
2728
public function __construct(
28-
private Translator $translator,
29+
private ?Translator $translator = null,
2930
) {}
3031

3132
/**
@@ -213,6 +214,10 @@ public function validateValues(iterable $values, array $rules): array
213214
*/
214215
public function getErrorMessage(Rule|FailingRule $rule, ?string $field = null): string
215216
{
217+
if (is_null($this->translator)) {
218+
throw new TranslatorWasRequired();
219+
}
220+
216221
if ($rule instanceof HasErrorMessage) {
217222
return $rule->getErrorMessage();
218223
}
@@ -261,7 +266,7 @@ private function getTranslationKey(Rule|FailingRule $rule): string
261266

262267
private function getFieldName(string $key, ?string $field = null): string
263268
{
264-
$translatedField = $this->translator->translate("validation_field.{$key}");
269+
$translatedField = $this->translator?->translate("validation_field.{$key}");
265270

266271
if ($translatedField === "validation_field.{$key}") {
267272
return $field ?? 'Value';

0 commit comments

Comments
 (0)