Skip to content

Commit 212aa55

Browse files
committed
feat(core): support validating environment variables
1 parent 1af376a commit 212aa55

File tree

5 files changed

+189
-10
lines changed

5 files changed

+189
-10
lines changed
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: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
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;
1213
use Tempest\Support\Namespace\PathCouldNotBeMappedToNamespace;
14+
use Tempest\Validation\Rule;
15+
use Tempest\Validation\Validator;
1316
use Throwable;
1417

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

6265
/**
6366
* Retrieves the given `$key` from the environment variables. If `$key` is not defined, `$default` is returned instead.
67+
*
68+
* @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.
6469
*/
65-
function env(string $key, mixed $default = null): mixed
70+
function env(string $key, mixed $default = null, array $rules = []): mixed
6671
{
6772
$value = getenv($key);
68-
69-
if ($value === false) {
70-
return $default;
71-
}
72-
73-
return match (strtolower($value)) {
73+
$value = match (is_string($value) ? mb_strtolower($value) : $value) {
7474
'true' => true,
7575
'false' => false,
76-
'null', '' => null,
76+
false, 'null', '' => $default,
7777
default => $value,
7878
};
79+
80+
if ($rules === []) {
81+
return $value;
82+
}
83+
84+
$validator = get(Validator::class);
85+
$failures = $validator->validateValue($value, $rules);
86+
87+
if ($failures === []) {
88+
return $value;
89+
}
90+
91+
throw new EnvironmentVariableValidationFailed(
92+
name: $key,
93+
value: $value,
94+
failingRules: $failures,
95+
validator: $validator,
96+
);
7997
}
8098

8199
/**

packages/core/tests/EnvTest.php

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
21+
use function Tempest\env;
22+
23+
final class EnvTest extends TestCase
24+
{
25+
#[PreCondition]
26+
protected function configure(): void
27+
{
28+
$container = new GenericContainer();
29+
$container->singleton(Translator::class, new GenericTranslator(
30+
config: new IntlConfig(currentLocale: Locale::ENGLISH, fallbackLocale: Locale::ENGLISH),
31+
catalog: new GenericCatalog([
32+
'en' => [
33+
'validation_error' => [
34+
'is_numeric' => '{{{$field} must be a numeric value}}',
35+
],
36+
],
37+
]),
38+
formatter: new MessageFormatter(),
39+
));
40+
41+
GenericContainer::setInstance($container);
42+
}
43+
44+
#[Test]
45+
#[TestWith([null, null])]
46+
#[TestWith(['', null])]
47+
#[TestWith(['null', null])]
48+
#[TestWith([false, null])]
49+
#[TestWith(['FALSE', false])]
50+
#[TestWith(['false', false])]
51+
#[TestWith(['TRUE', true])]
52+
#[TestWith(['true', true])]
53+
#[TestWith(['foo', 'foo'])]
54+
#[TestWith(['FOO', 'FOO'])]
55+
#[TestWith([1, '1'])]
56+
public function basic(mixed $value, mixed $expected): void
57+
{
58+
putenv("_ENV_TESTING_KEY={$value}");
59+
60+
$this->assertSame($expected, env('_ENV_TESTING_KEY'));
61+
}
62+
63+
#[Test]
64+
#[TestWith([null, 'fallback', 'fallback'])]
65+
#[TestWith([false, 'fallback', 'fallback'])]
66+
#[TestWith(['', 'fallback', 'fallback'])]
67+
#[TestWith(['false', 'fallback', false])]
68+
#[TestWith(['true', 'fallback', true])]
69+
#[TestWith([false, '', ''])]
70+
#[TestWith([null, '', ''])]
71+
#[TestWith(['', '', ''])]
72+
#[TestWith([false, false, false])]
73+
#[TestWith([null, false, false])]
74+
#[TestWith(['', false, false])]
75+
public function default(mixed $value, mixed $default, mixed $expected): void
76+
{
77+
putenv("_ENV_TESTING_KEY={$value}");
78+
79+
$this->assertSame($expected, env('_ENV_TESTING_KEY', default: $default));
80+
}
81+
82+
#[Test]
83+
public function fails_with_failing_rules(): void
84+
{
85+
$this->expectException(EnvironmentVariableValidationFailed::class);
86+
$this->expectExceptionMessageMatches('*_ENV_TESTING_KEY must be a numeric value*');
87+
88+
putenv('_ENV_TESTING_KEY=foo');
89+
env('_ENV_TESTING_KEY', rules: [new IsNumeric()]);
90+
}
91+
92+
#[Test]
93+
#[TestWith([null, null])]
94+
#[TestWith(['', null])]
95+
#[TestWith([false, null])]
96+
public function default_taken_into_account(mixed $value, mixed $default): void
97+
{
98+
$this->expectException(EnvironmentVariableValidationFailed::class);
99+
100+
putenv("_ENV_TESTING_KEY={$value}");
101+
env('_ENV_TESTING_KEY', default: $default, rules: [new IsNotNull()]);
102+
}
103+
104+
#[Test]
105+
public function can_pass(): void
106+
{
107+
putenv('_ENV_TESTING_KEY=true');
108+
109+
$this->assertSame(true, env('_ENV_TESTING_KEY', rules: [new IsBoolean()]));
110+
}
111+
}
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)