Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/EnvironmentVariableValidationFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Tempest\Core;

use Exception;
use Tempest\Validation\FailingRule;
use Tempest\Validation\Validator;

use function Tempest\Support\arr;

final class EnvironmentVariableValidationFailed extends Exception
{
/**
* @param FailingRule[] $failingRules
*/
public function __construct(
private(set) string $name,
private(set) mixed $value,
private(set) array $failingRules,
private(set) Validator $validator,
) {
return parent::__construct(vsprintf("Environment variable [%s] is not valid:\n- %s", [
$name,
arr($failingRules)
->map(fn (FailingRule $failingRule) => $validator->getErrorMessage($failingRule, $name))
->implode("\n- ")
->toString(),
]));
}
}
35 changes: 27 additions & 8 deletions packages/core/src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}

/**
Expand Down
120 changes: 120 additions & 0 deletions packages/core/tests/EnvTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

namespace Tempest\Core\Tests;

use PHPUnit\Framework\Attributes\PreCondition;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Tempest\Container\GenericContainer;
use Tempest\Core\EnvironmentVariableValidationFailed;
use Tempest\Intl\Catalog\GenericCatalog;
use Tempest\Intl\GenericTranslator;
use Tempest\Intl\IntlConfig;
use Tempest\Intl\Locale;
use Tempest\Intl\MessageFormat\Formatter\MessageFormatter;
use Tempest\Intl\Translator;
use Tempest\Validation\Rules\IsBoolean;
use Tempest\Validation\Rules\IsNotNull;
use Tempest\Validation\Rules\IsNumeric;
use Tempest\Validation\Validator;

use function Tempest\env;

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),
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()]));
}
}
15 changes: 15 additions & 0 deletions packages/validation/src/Exceptions/TranslatorWasRequired.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Validation\Exceptions;

use Exception;

final class TranslatorWasRequired extends Exception
{
public function __construct()
{
parent::__construct('A translator instance is required to generate validation error messages, but none was provided.');
}
}
9 changes: 7 additions & 2 deletions packages/validation/src/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\PropertyReflector;
use Tempest\Support\Arr;
use Tempest\Validation\Exceptions\TranslatorWasRequired;
use Tempest\Validation\Exceptions\ValidationFailed;
use Tempest\Validation\Rules\IsBoolean;
use Tempest\Validation\Rules\IsEnum;
Expand All @@ -25,7 +26,7 @@
final readonly class Validator
{
public function __construct(
private Translator $translator,
private ?Translator $translator = null,
) {}

/**
Expand Down Expand Up @@ -213,6 +214,10 @@ public function validateValues(iterable $values, array $rules): array
*/
public function getErrorMessage(Rule|FailingRule $rule, ?string $field = null): string
{
if (is_null($this->translator)) {
throw new TranslatorWasRequired();
}

if ($rule instanceof HasErrorMessage) {
return $rule->getErrorMessage();
}
Expand Down Expand Up @@ -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';
Expand Down