From 5fa1ed024fca14d3d3945700030e44c0e74e6597 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 12:55:13 +0100 Subject: [PATCH 1/9] Add environment file path concern / trait --- .../src/Env/Concerns/EnvironmentFilePath.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 packages/Support/src/Env/Concerns/EnvironmentFilePath.php diff --git a/packages/Support/src/Env/Concerns/EnvironmentFilePath.php b/packages/Support/src/Env/Concerns/EnvironmentFilePath.php new file mode 100644 index 000000000..434881cbf --- /dev/null +++ b/packages/Support/src/Env/Concerns/EnvironmentFilePath.php @@ -0,0 +1,68 @@ + + * @package Aedart\Support\Env\Concerns + */ +trait EnvironmentFilePath +{ + /** + * Path to environment file + * + * @var string|null + */ + protected string|null $environmentFilePath = null; + + /** + * Set environment file path + * + * @param string|null $path Path to environment file + * + * @return self + */ + public function setEnvironmentFilePath(string|null $path): static + { + $this->environmentFilePath = $path; + + return $this; + } + + /** + * Get environment file path + * + * @return string|null + */ + public function getEnvironmentFilePath(): string|null + { + if (!$this->hasEnvironmentFilePath()) { + $this->setEnvironmentFilePath($this->getDefaultEnvironmentFilePath()); + } + return $this->environmentFilePath; + } + + /** + * Check if environment file path has been set + * + * @return bool + */ + public function hasEnvironmentFilePath(): bool + { + return isset($this->environmentFilePath); + } + + /** + * Get a default environment file path value, if any is available + * + * @return string|null + */ + public function getDefaultEnvironmentFilePath(): string|null + { + return App::environmentFilePath(); + } +} From 54cb093c7f7fee308ca79ae9e7e4cd265b02e9d3 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 12:55:28 +0100 Subject: [PATCH 2/9] Add tests for env file path concern --- .../Env/Concerns/EnvironmentFilePathTest.php | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/Integration/Support/Env/Concerns/EnvironmentFilePathTest.php diff --git a/tests/Integration/Support/Env/Concerns/EnvironmentFilePathTest.php b/tests/Integration/Support/Env/Concerns/EnvironmentFilePathTest.php new file mode 100644 index 000000000..dce1b8622 --- /dev/null +++ b/tests/Integration/Support/Env/Concerns/EnvironmentFilePathTest.php @@ -0,0 +1,62 @@ + + * @package Aedart\Tests\Integration\Support\Env\Concerns + */ +class EnvironmentFilePathTest extends LaravelTestCase +{ + use GetterSetterTraitTester; + + /** + * @test + * + * @return void + * + * @throws ReflectionException + */ + public function canSetAndRetrieveEnvironmentFilePath(): void + { + $this->assertTraitMethods( + trait: EnvironmentFilePath::class, + assertDefaultIsNull: false + ); + } + + /** + * @test + * + * @return void + * + * @throws ReflectionException + */ + public function hasDefaultPath(): void + { + // Assert a default method + $tester = new TraitTester($this, EnvironmentFilePath::class, null); + $getMethod = $tester->getPropertyMethodName(); + $mock = $tester->getTraitMock(); + + $value = $mock->$getMethod(); + + ConsoleDebugger::output($value); + + $this->assertNotNull($value, 'Default path not set'); + } +} From 2fc2bc18b610c881af293ffa22989d2b2914f477 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 14:41:43 +0100 Subject: [PATCH 3/9] Add Env File related exceptions --- .../Env/Exceptions/EnvironmentFileException.php | 15 +++++++++++++++ .../Support/src/Env/Exceptions/FileNotFound.php | 15 +++++++++++++++ .../Support/src/Env/Exceptions/KeyNotFound.php | 13 +++++++++++++ .../src/Env/Exceptions/UnableToReadContents.php | 13 +++++++++++++ .../src/Env/Exceptions/UnableToWriteContents.php | 13 +++++++++++++ 5 files changed, 69 insertions(+) create mode 100644 packages/Support/src/Env/Exceptions/EnvironmentFileException.php create mode 100644 packages/Support/src/Env/Exceptions/FileNotFound.php create mode 100644 packages/Support/src/Env/Exceptions/KeyNotFound.php create mode 100644 packages/Support/src/Env/Exceptions/UnableToReadContents.php create mode 100644 packages/Support/src/Env/Exceptions/UnableToWriteContents.php diff --git a/packages/Support/src/Env/Exceptions/EnvironmentFileException.php b/packages/Support/src/Env/Exceptions/EnvironmentFileException.php new file mode 100644 index 000000000..2e0f128fc --- /dev/null +++ b/packages/Support/src/Env/Exceptions/EnvironmentFileException.php @@ -0,0 +1,15 @@ + + * @package Aedart\Support\Env\Exceptions + */ +class EnvironmentFileException extends RuntimeException +{ +} diff --git a/packages/Support/src/Env/Exceptions/FileNotFound.php b/packages/Support/src/Env/Exceptions/FileNotFound.php new file mode 100644 index 000000000..2ea3eb439 --- /dev/null +++ b/packages/Support/src/Env/Exceptions/FileNotFound.php @@ -0,0 +1,15 @@ + + * @package Aedart\Support\Env\Exceptions + */ +class FileNotFound extends EnvironmentFileException +{ +} diff --git a/packages/Support/src/Env/Exceptions/KeyNotFound.php b/packages/Support/src/Env/Exceptions/KeyNotFound.php new file mode 100644 index 000000000..936346adf --- /dev/null +++ b/packages/Support/src/Env/Exceptions/KeyNotFound.php @@ -0,0 +1,13 @@ + + * @package Aedart\Support\Env\Exceptions + */ +class KeyNotFound extends EnvironmentFileException +{ +} diff --git a/packages/Support/src/Env/Exceptions/UnableToReadContents.php b/packages/Support/src/Env/Exceptions/UnableToReadContents.php new file mode 100644 index 000000000..f36b36379 --- /dev/null +++ b/packages/Support/src/Env/Exceptions/UnableToReadContents.php @@ -0,0 +1,13 @@ + + * @package Aedart\Support\Env\Exceptions + */ +class UnableToReadContents extends EnvironmentFileException +{ +} diff --git a/packages/Support/src/Env/Exceptions/UnableToWriteContents.php b/packages/Support/src/Env/Exceptions/UnableToWriteContents.php new file mode 100644 index 000000000..ac3c7102e --- /dev/null +++ b/packages/Support/src/Env/Exceptions/UnableToWriteContents.php @@ -0,0 +1,13 @@ + + * @package Aedart\Support\Env\Exceptions + */ +class UnableToWriteContents extends EnvironmentFileException +{ +} From 24cb62ece68eeb5f5b40d869d10e0d1b75b4c718 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 14:41:58 +0100 Subject: [PATCH 4/9] Add Env File utility --- packages/Support/src/EnvFile.php | 208 +++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 packages/Support/src/EnvFile.php diff --git a/packages/Support/src/EnvFile.php b/packages/Support/src/EnvFile.php new file mode 100644 index 000000000..a63f999a2 --- /dev/null +++ b/packages/Support/src/EnvFile.php @@ -0,0 +1,208 @@ + + * @package Aedart\Support + */ +class EnvFile +{ + use EnvironmentFilePath; + + /** + * The contents of the environment file + * + * @var string + */ + protected string $contents = ''; + + /** + * Create a new Environment File instance + * + * @param string|null $path [optional] Path to env file. Defaults to Laravel's environment file path. + * @param bool $load [optional] If true, the contents of the environment file is loaded into memory. + * + * @throws EnvironmentFileException + */ + public function __construct(string|null $path = null, bool $load = false) + { + $this->setEnvironmentFilePath($path); + + if ($load) { + $this->refresh(); + } + } + + /** + * Load environment file content into memory + * + * @param string|null $path [optional] Path to env file. Defaults to Laravel's environment file path. + * + * @return static + * + * @throws EnvironmentFileException + */ + public static function load(string|null $path = null): static + { + return new static($path, true); + } + + /** + * Reloads the contents of the environment file. + * + * @return self + * + * @throws EnvironmentFileException + */ + public function refresh(): static + { + $path = $this->getEnvironmentFilePath(); + + if (!file_exists($path)) { + throw new FileNotFound("Environment file '{$path}' does not exist."); + } + + $contents = file_get_contents($path); + if ($contents === false) { + throw new UnableToReadContents("Unable to read contents of '{$path}'."); + } + + $this->contents = $contents; + + return $this; + } + + /** + * Write the contents into the environment file + * + * @return self + */ + public function save(): static + { + $path = $this->getEnvironmentFilePath(); + + $bytes = file_put_contents($path, $this->contents); + if ($bytes === false) { + throw new UnableToReadContents("Unable to write contents for '{$path}'."); + } + + return $this; + } + + /** + * Determine if key exists in environment file + * + * @param string $key + * + * @return bool + */ + public function has(string $key): bool + { + $key = $this->resolveKey($key); + + $result = preg_match( + pattern: $this->makeKeySearchPattern($key), + subject: $this->contents + ); + + return $result === 1; + } + + /** + * Append a new key-value pair + * + * @param string $key + * @param string|int $value + * @param string|null $comment [optional] Evt. comment for the key-value pair. + * + * @return self + */ + public function append(string $key, string|int $value, string|null $comment = null): static + { + $key = $this->resolveKey($key); + + $prefix = ''; + if ($comment) { + $prefix = "\n# {$comment}"; + } + + $this->contents .= "{$prefix}\n{$key}={$value}"; + + return $this; + } + + /** + * Replace the value of an existing key in the environment file + * + * @param string $key + * @param string|int $value + * + * @return self + * + * @throws EnvironmentFileException + */ + public function replace(string $key, string|int $value): static + { + if (!$this->has($key)) { + throw new KeyNotFound(sprintf('Key %s does not exist, in %s', $key, $this->getEnvironmentFilePath())); + } + + $key = $this->resolveKey($key); + + $this->contents = preg_replace( + pattern: $this->makeKeySearchPattern($key), + replacement: "{$key}={$value}", + subject: $this->contents, + limit: 1, + ); + + return $this; + } + + /** + * Returns the loaded environment file's contents + * + * @return string + */ + public function contents(): string + { + return $this->contents; + } + + /***************************************************************** + * Internals + ****************************************************************/ + + /** + * Creates a RegEx search pattern for a key inside the environment file + * + * @param string $key + * + * @return string + */ + protected function makeKeySearchPattern(string $key): string + { + return "/^{$key}=(.)*/m"; + } + + /** + * Resolves given key + * + * @param string $key + * + * @return string + */ + protected function resolveKey(string $key): string + { + return strtolower($key); + } +} From f11615cda59f2979c1473358fbfbe7513f62a682 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 15:07:38 +0100 Subject: [PATCH 5/9] Fix resolveKey() and simplify has() Resolve key lower-cased the key, when it should had been upper-cased. Also, the has() method didn't need a "compelx" regex search pattern - the str_contains does the job! --- packages/Support/src/EnvFile.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/Support/src/EnvFile.php b/packages/Support/src/EnvFile.php index a63f999a2..33ee4a946 100644 --- a/packages/Support/src/EnvFile.php +++ b/packages/Support/src/EnvFile.php @@ -109,12 +109,7 @@ public function has(string $key): bool { $key = $this->resolveKey($key); - $result = preg_match( - pattern: $this->makeKeySearchPattern($key), - subject: $this->contents - ); - - return $result === 1; + return str_contains($this->contents, "\n{$key}="); } /** @@ -203,6 +198,6 @@ protected function makeKeySearchPattern(string $key): string */ protected function resolveKey(string $key): string { - return strtolower($key); + return strtoupper($key); } } From f176db4c5689bde4d62dfdb7c74e89ad325f7674 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 15:23:07 +0100 Subject: [PATCH 6/9] Fix incorrect "key not found" exception thrown --- packages/Support/src/EnvFile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/Support/src/EnvFile.php b/packages/Support/src/EnvFile.php index 33ee4a946..28efc7214 100644 --- a/packages/Support/src/EnvFile.php +++ b/packages/Support/src/EnvFile.php @@ -2,10 +2,10 @@ namespace Aedart\Support; -use Aedart\Collections\Exceptions\KeyNotFound; use Aedart\Support\Env\Concerns\EnvironmentFilePath; use Aedart\Support\Env\Exceptions\EnvironmentFileException; use Aedart\Support\Env\Exceptions\FileNotFound; +use Aedart\Support\Env\Exceptions\KeyNotFound; use Aedart\Support\Env\Exceptions\UnableToReadContents; /** From 5fb297763ce416dca7a0ea60628cb8c307a3b550 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 15:30:10 +0100 Subject: [PATCH 7/9] Add test-case for env file component tests --- tests/TestCases/Support/EnvFileTestCase.php | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/TestCases/Support/EnvFileTestCase.php diff --git a/tests/TestCases/Support/EnvFileTestCase.php b/tests/TestCases/Support/EnvFileTestCase.php new file mode 100644 index 000000000..bc972d991 --- /dev/null +++ b/tests/TestCases/Support/EnvFileTestCase.php @@ -0,0 +1,69 @@ + + * @package Aedart\Tests\TestCases\Support + */ +abstract class EnvFileTestCase extends LaravelTestCase +{ + use FileTrait; + + /***************************************************************** + * Setup + ****************************************************************/ + + /** + * @inheritDoc + */ + protected function _before() + { + parent::_before(); + + // Clean some directories + $fs = $this->getFile(); + $outputDir = $this->outputDir(); + + $fs->ensureDirectoryExists($outputDir); + $fs->cleanDirectory($outputDir); + + // Copy existing .env file into output dir. + $fs->copy(getcwd() . '/.testing.example', $this->envFilePath()); + } + + /***************************************************************** + * Helpers + ****************************************************************/ + + /** + * Returns path to output directory + * + * @return string + * + * @throws ConfigurationException + */ + public function outputDir(): string + { + return Configuration::outputDir() . 'support/env'; + } + + /** + * Returns path to environment file + * + * @return string + * + * @throws ConfigurationException + */ + public function envFilePath(): string + { + return $this->outputDir() . '/.env'; + } +} From 721d0f2b78d71488b236297e405bb1fef435a720 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 15:30:27 +0100 Subject: [PATCH 8/9] Add tests for env file component --- tests/Integration/Support/Env/EnvFileTest.php | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/Integration/Support/Env/EnvFileTest.php diff --git a/tests/Integration/Support/Env/EnvFileTest.php b/tests/Integration/Support/Env/EnvFileTest.php new file mode 100644 index 000000000..6d46d4a0e --- /dev/null +++ b/tests/Integration/Support/Env/EnvFileTest.php @@ -0,0 +1,193 @@ + + * @package Aedart\Tests\Integration\Support\Env + */ +class EnvFileTest extends EnvFileTestCase +{ + /** + * @test + * + * @return void + */ + public function failsLoadingIfEnvironmentFileDoesNotExist(): void + { + $this->expectException(FileNotFound::class); + + EnvFile::load('/my_unknown_env_file'); + } + + /** + * @test + * + * @return void + * + * @throws ConfigurationException + */ + public function canLoadEnvironmentFileContents(): void + { + $contents = EnvFile::load($this->envFilePath())->contents(); + + ConsoleDebugger::output($contents); + + $this->assertNotEmpty($contents); + } + + /** + * @test + * + * @return void + * + * @throws ConfigurationException + */ + public function canDetermineIfKeyExists(): void + { + $env = EnvFile::load($this->envFilePath()); + + $hasAppName = $env->has('APP_NAME'); + $hasUnknownKey = $env->has('UNKNOWN_KEY'); + + $this->assertTrue($hasAppName, 'APP_NAME should exist'); + $this->assertFalse($hasUnknownKey, 'UNKNOWN_KEY should NOT exist'); + } + + /** + * @test + * + * @return void + * + * @throws ConfigurationException + */ + public function canAppendKeyValuePair(): void + { + $env = EnvFile::load($this->envFilePath()); + + $key = 'FOO'; + $value = 'bar'; + + // -------------------------------------------------------------------- // + + $contents = $env + ->append($key, $value) + ->contents(); + + ConsoleDebugger::output($contents); + + $this->assertTrue(str_ends_with($contents, "\n{$key}={$value}"), 'Key-value pair not appended'); + } + + /** + * @test + * + * @return void + * + * @throws ConfigurationException + */ + public function canAppendKeyValuePairWithComment(): void + { + $env = EnvFile::load($this->envFilePath()); + + $key = 'MY_APP_SECRET'; + $value = Str::random(); + $comment = 'Custom application secret...'; + + // -------------------------------------------------------------------- // + + $contents = $env + ->append($key, $value, $comment) + ->contents(); + + ConsoleDebugger::output($contents); + + $this->assertTrue(str_ends_with($contents, "\n# {$comment}\n{$key}={$value}"), 'Key-value pair with comment not appended'); + } + + /** + * @test + * + * @return void + * + * @throws ConfigurationException + */ + public function canReplaceKeyValuePair(): void + { + $env = EnvFile::load($this->envFilePath()); + + $key = 'REDMINE_TOKEN'; + $value = Str::random(); + + // -------------------------------------------------------------------- // + + $contents = $env + ->replace($key, $value) + ->contents(); + + ConsoleDebugger::output($contents); + + $this->assertTrue(str_contains($contents, "\n{$key}={$value}"), 'Key-value pair not replaced'); + } + + /** + * @test + * + * @return void + * + * @throws ConfigurationException + */ + public function failsReplacingKeyValueIfKeyDoesNotExist(): void + { + $this->expectException(KeyNotFound::class); + + $key = 'UNKNOWN_KEY'; + $value = Str::random(); + + EnvFile::load($this->envFilePath()) + ->replace($key, $value) + ->contents(); + } + + /** + * @test + * + * @return void + * + * @throws ConfigurationException + */ + public function canSaveChangesToFile(): void + { + $env = EnvFile::load($this->envFilePath()); + + $key = 'REDMINE_TOKEN'; + $value = Str::random(); + + // -------------------------------------------------------------------- // + + $contents = $env + ->replace($key, $value) + ->save() + ->refresh() // Important here - forces the contents to be reloaded + ->contents(); + + ConsoleDebugger::output($contents); + + $this->assertTrue(str_contains($contents, "\n{$key}={$value}"), 'Changes to environment file were not written'); + } +} From 0274c21c2c53bdc1a746b16680812f17f3794198 Mon Sep 17 00:00:00 2001 From: alin Date: Tue, 25 Feb 2025 15:31:54 +0100 Subject: [PATCH 9/9] Change release notes --- CHANGELOG_v9x.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG_v9x.md b/CHANGELOG_v9x.md index f59bd05b7..c609fe5fc 100644 --- a/CHANGELOG_v9x.md +++ b/CHANGELOG_v9x.md @@ -11,6 +11,7 @@ TODO: Temporary changelog file for the upcoming major version `9.x`. * `Json::isValid()` now accepts `$depth` and `$options` parameters. * Custom `FailedPasswordResetLinkApiResponse` in `aedart/athenaeum-auth` package. * `FailedLoginAttempt` and `PasswordResetLinkFailure` exceptions in `aedart/athenaeum-auth` package. +* `EnvFile` component, in `Aedart\Support`. [#219](https://github.com/aedart/athenaeum/issues/219). * `ConcurrencyManager`, `LogContextRepository`, `DateFactory`, `ExceptionHandler`, `ParallelTesting`, `ProcessFactory`, `RateLimiter`, `Schedule` and `Vite` aware-of helpers, in `Aedart\Support\Helpers`. * Configuration for `composer-bin-plugin` (_in root `composer.json`_).