From 77571510696eef6fc06396352fcb300301d69941 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Mon, 4 Dec 2023 11:30:48 -0500 Subject: [PATCH 01/11] [Cookie] New Component & Contract (#181) ## Description Closes #125 The cookie class should not be responsible for sending the headers or pulling name/value from `$_COOKIE`. This would be the job of the cookie manager. Cookie manager uses `$_COOKIE` to grab cookies. If a cookie does not exist, it uses the factory to create one. Sending the Set-Cookie header should be done be the cookie manager as well. The Cookie class should be about to output the header value. Should be able to do something like: ```php header('Set-Cookie: ' . (string) $cookie); ``` ## Checklist - [x] Updated CHANGELOG files - [x] Updated Documentation - [x] Unit Tests Created - [x] php-cs-fixer --- .github/CODEOWNERS | 1 + .github/labeler.yml | 4 + CHANGELOG.md | 1 + Makefile | 6 + bard.json | 8 + composer.json | 13 +- docs/components/cookie/index.md | 33 +++ docs/contracts/cookie/index.md | 9 + mkdocs.yml | 2 + phpunit.xml.dist | 4 + src/SonsOfPHP/Component/Cookie/.gitattributes | 4 + src/SonsOfPHP/Component/Cookie/.gitignore | 3 + src/SonsOfPHP/Component/Cookie/Cookie.php | 201 ++++++++++++++++++ .../Component/Cookie/CookieManager.php | 43 ++++ .../Cookie/Exception/CookieException.php | 12 ++ src/SonsOfPHP/Component/Cookie/LICENSE | 19 ++ src/SonsOfPHP/Component/Cookie/README.md | 16 ++ .../Component/Cookie/Tests/CookieTest.php | 174 +++++++++++++++ src/SonsOfPHP/Component/Cookie/composer.json | 54 +++++ src/SonsOfPHP/Component/Pager/README.md | 10 +- src/SonsOfPHP/Contract/Cookie/.gitattributes | 2 + src/SonsOfPHP/Contract/Cookie/.gitignore | 2 + .../Cookie/CookieExceptionInterface.php | 10 + .../Contract/Cookie/CookieInterface.php | 103 +++++++++ .../Cookie/CookieManagerInterface.php | 43 ++++ src/SonsOfPHP/Contract/Cookie/LICENSE | 19 ++ src/SonsOfPHP/Contract/Cookie/README.md | 16 ++ src/SonsOfPHP/Contract/Cookie/composer.json | 52 +++++ 28 files changed, 856 insertions(+), 8 deletions(-) create mode 100644 docs/components/cookie/index.md create mode 100644 docs/contracts/cookie/index.md create mode 100644 src/SonsOfPHP/Component/Cookie/.gitattributes create mode 100644 src/SonsOfPHP/Component/Cookie/.gitignore create mode 100644 src/SonsOfPHP/Component/Cookie/Cookie.php create mode 100644 src/SonsOfPHP/Component/Cookie/CookieManager.php create mode 100644 src/SonsOfPHP/Component/Cookie/Exception/CookieException.php create mode 100644 src/SonsOfPHP/Component/Cookie/LICENSE create mode 100644 src/SonsOfPHP/Component/Cookie/README.md create mode 100644 src/SonsOfPHP/Component/Cookie/Tests/CookieTest.php create mode 100644 src/SonsOfPHP/Component/Cookie/composer.json create mode 100644 src/SonsOfPHP/Contract/Cookie/.gitattributes create mode 100644 src/SonsOfPHP/Contract/Cookie/.gitignore create mode 100644 src/SonsOfPHP/Contract/Cookie/CookieExceptionInterface.php create mode 100644 src/SonsOfPHP/Contract/Cookie/CookieInterface.php create mode 100644 src/SonsOfPHP/Contract/Cookie/CookieManagerInterface.php create mode 100644 src/SonsOfPHP/Contract/Cookie/LICENSE create mode 100644 src/SonsOfPHP/Contract/Cookie/README.md create mode 100644 src/SonsOfPHP/Contract/Cookie/composer.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d2df5560..f152e365 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,7 @@ docs/ @JoshuaEstes # Each component/contract needs a Team /src/SonsOfPHP/**/Cache @JoshuaEstes /src/SonsOfPHP/**/Clock @JoshuaEstes +/src/SonsOfPHP/**/Cookie @JoshuaEstes /src/SonsOfPHP/**/Common @JoshuaEstes /src/SonsOfPHP/**/Cqrs @JoshuaEstes /src/SonsOfPHP/**/EventDispatcher @JoshuaEstes diff --git a/.github/labeler.yml b/.github/labeler.yml index fb9d35e6..022aa9c3 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -10,6 +10,10 @@ Clock: - docs/components/clock/* - src/SonsOfPHP/**/Clock/* +Cookie: + - docs/components/cookie/* + - src/SonsOfPHP/**/Cookie/* + Common: - docs/components/common/* - src/SonsOfPHP/**/Common/* diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a29d4d..35aaed74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ To get the diff between two versions, go to https://github.com/SonsOfPHP/sonsofp * [PR #134](https://github.com/SonsOfPHP/sonsofphp/pull/134) [Pager] New Component * [PR #170](https://github.com/SonsOfPHP/sonsofphp/pull/170) [Link] New Component (PSR-13) * [PR #173](https://github.com/SonsOfPHP/sonsofphp/pull/173) [Money] Twig Bridge +* [PR #181](https://github.com/SonsOfPHP/sonsofphp/pull/181) [Cookie] New Component and Contract ## [0.3.8] diff --git a/Makefile b/Makefile index 84a42c12..5cfa9d25 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,9 @@ test-cache: phpunit test-clock: PHPUNIT_TESTSUITE=clock test-clock: phpunit +test-cookie: PHPUNIT_TESTSUITE=cookie +test-cookie: phpunit + test-cqrs: PHPUNIT_TESTSUITE=cqrs test-cqrs: phpunit @@ -102,6 +105,9 @@ coverage-cache: coverage coverage-clock: PHPUNIT_TESTSUITE=clock coverage-clock: coverage +coverage-cookie: PHPUNIT_TESTSUITE=cookie +coverage-cookie: coverage + coverage-cqrs: PHPUNIT_TESTSUITE=cqrs coverage-cqrs: coverage diff --git a/bard.json b/bard.json index 8837d7fa..a3d28056 100644 --- a/bard.json +++ b/bard.json @@ -13,6 +13,10 @@ "path": "src/SonsOfPHP/Component/Clock", "repository": "git@github.com:SonsOfPHP/clock.git" }, + { + "path": "src/SonsOfPHP/Component/Cookie", + "repository": "git@github.com:SonsOfPHP/cookie.git" + }, { "path": "src/SonsOfPHP/Component/Cqrs", "repository": "git@github.com:SonsOfPHP/cqrs.git" @@ -101,6 +105,10 @@ "path": "src/SonsOfPHP/Contract/Common", "repository": "git@github.com:SonsOfPHP/common-contract.git" }, + { + "path": "src/SonsOfPHP/Contract/Cookie", + "repository": "git@github.com:SonsOfPHP/cookie-contract.git" + }, { "path": "src/SonsOfPHP/Contract/Cqrs", "repository": "git@github.com:SonsOfPHP/cqrs-contract.git" diff --git a/composer.json b/composer.json index d42f8f7f..40bf6b78 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,8 @@ "psr/log-implementation": "^1.0 || ^2.0 || ^3.0", "sonsofphp/logger-implementation": "0.3.x-dev", "sonsofphp/pager-implementation": "0.3.x-dev", - "psr/link-implementation": "^1.0 || ^2.0" + "psr/link-implementation": "^1.0 || ^2.0", + "sonsofphp/cookie-implementation": "0.3.x-dev" }, "require": { "php": ">=8.1", @@ -74,7 +75,8 @@ "twig/twig": "^3.0", "ext-intl": "*", "doctrine/collections": "^2", - "doctrine/orm": "^2" + "doctrine/orm": "^2", + "sonsofphp/cookie-contract": "0.3.x-dev" }, "replace": { "sonsofphp/bard": "self.version", @@ -110,13 +112,16 @@ "sonsofphp/money-twig": "self.version", "sonsofphp/pager-doctrine-collections": "self.version", "sonsofphp/pager-doctrine-dbal": "self.version", - "sonsofphp/pager-doctrine-orm": "self.version" + "sonsofphp/pager-doctrine-orm": "self.version", + "sonsofphp/cookie": "self.version", + "sonsofphp/cookie-contract": "self.version" }, "autoload": { "psr-4": { "SonsOfPHP\\Bard\\": "src/SonsOfPHP/Bard/src", "SonsOfPHP\\Component\\Cache\\": "src/SonsOfPHP/Component/Cache", "SonsOfPHP\\Component\\Clock\\": "src/SonsOfPHP/Component/Clock", + "SonsOfPHP\\Component\\Cookie\\": "src/SonsOfPHP/Component/Cookie", "SonsOfPHP\\Component\\Cqrs\\": "src/SonsOfPHP/Component/Cqrs", "SonsOfPHP\\Bundle\\Cqrs\\": "src/SonsOfPHP/Bundle/Cqrs", "SonsOfPHP\\Bridge\\Symfony\\Cqrs\\": "src/SonsOfPHP/Bridge/Symfony/Cqrs", @@ -139,6 +144,7 @@ "SonsOfPHP\\Bridge\\Doctrine\\ORM\\Pager\\": "src/SonsOfPHP/Bridge/Doctrine/ORM/Pager", "SonsOfPHP\\Component\\Version\\": "src/SonsOfPHP/Component/Version", "SonsOfPHP\\Contract\\Common\\": "src/SonsOfPHP/Contract/Common", + "SonsOfPHP\\Contract\\Cookie\\": "src/SonsOfPHP/Contract/Cookie", "SonsOfPHP\\Contract\\Cqrs\\": "src/SonsOfPHP/Contract/Cqrs", "SonsOfPHP\\Contract\\EventSourcing\\": "src/SonsOfPHP/Contract/EventSourcing", "SonsOfPHP\\Contract\\FeatureToggle\\": "src/SonsOfPHP/Contract/FeatureToggle", @@ -152,6 +158,7 @@ "src/SonsOfPHP/Bard/Tests", "src/SonsOfPHP/Component/Cache/Tests", "src/SonsOfPHP/Component/Clock/Tests", + "src/SonsOfPHP/Component/Cookie/Tests", "src/SonsOfPHP/Component/Cqrs/Tests", "src/SonsOfPHP/Bundle/Cqrs/Tests", "src/SonsOfPHP/Bridge/Symfony/Cqrs/Tests", diff --git a/docs/components/cookie/index.md b/docs/components/cookie/index.md new file mode 100644 index 00000000..19aa6f17 --- /dev/null +++ b/docs/components/cookie/index.md @@ -0,0 +1,33 @@ +--- +title: Cookie +--- + +## Installation + +```shell +composer require sonsofphp/cookie +``` + +## Usage + +A Cookie is treated as a value object. This means that if two cookie objects +have the same name and value, they will be considered equal. They are also +considered to be immutable. + +```php +getHeaderValue()); +// OR +// header('Set-Cookie: ' . (string) $cookie); + +// Set various attributes +$cookie = $cookie + ->withPath('/') + ->withDomain('docs.sonsofphp.com') +; +``` diff --git a/docs/contracts/cookie/index.md b/docs/contracts/cookie/index.md new file mode 100644 index 00000000..77157f49 --- /dev/null +++ b/docs/contracts/cookie/index.md @@ -0,0 +1,9 @@ +--- +title: Cookie +--- + +## Installation + +```shell +composer require sonsofphp/cookie-contract +``` diff --git a/mkdocs.yml b/mkdocs.yml index b8b29ee1..9c0fc364 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,7 @@ nav: - components/index.md - Cache: components/cache/index.md - Clock: components/clock/index.md + - Cookie: components/cookie/index.md - CQRS: - components/cqrs/index.md - Event Dispatcher: components/event-dispatcher/index.md @@ -118,5 +119,6 @@ nav: - Contracts: - contracts/index.md - Common: contracts/common/index.md + - Cookie: contracts/cookie/index.md - Cqrs: contracts/cqrs/index.md - Pager: contracts/pager/index.md diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5ab54b33..db3a9659 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -30,6 +30,10 @@ src/SonsOfPHP/Component/Clock/Tests + + src/SonsOfPHP/Component/Cookie/Tests + + src/SonsOfPHP/Bridge/*/Cqrs/Tests diff --git a/src/SonsOfPHP/Component/Cookie/.gitattributes b/src/SonsOfPHP/Component/Cookie/.gitattributes new file mode 100644 index 00000000..84c7add0 --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/SonsOfPHP/Component/Cookie/.gitignore b/src/SonsOfPHP/Component/Cookie/.gitignore new file mode 100644 index 00000000..5414c2c6 --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/SonsOfPHP/Component/Cookie/Cookie.php b/src/SonsOfPHP/Component/Cookie/Cookie.php new file mode 100644 index 00000000..baf08af6 --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/Cookie.php @@ -0,0 +1,201 @@ + + */ +final class Cookie implements CookieInterface +{ + public function __construct( + private string $name, + private string $value = '', + private array $attributes = [], + ) {} + + public function __toString(): string + { + return $this->getHeaderValue(); + } + + /** + * {@inheritdoc} + */ + public function getHeaderValue(): string + { + $cookie = $this->name . '=' . $this->value; + + foreach ($this->attributes as $key => $val) { + if (is_bool($val) && true === $val) { + $cookie .= '; ' . $key; + } + + if (!is_bool($val)) { + $cookie .= '; ' . $key . '=' . $val; + } + } + + return $cookie; + } + + /** + * {@inheritdoc} + */ + public function withName(string $name): static + { + if ($name === $this->name) { + return $this; + } + + $that = clone $this; + $that->name = $name; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withValue(string $value): static + { + if ($value === $this->value) { + return $this; + } + + $that = clone $this; + $that->value = $value; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withPath(string $path): static + { + if (array_key_exists('Path', $this->attributes) && $path === $this->attributes['Path']) { + return $this; + } + + $that = clone $this; + $that->attributes['Path'] = $path; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withDomain(string $domain): static + { + if (array_key_exists('Domain', $this->attributes) && $domain === $this->attributes['Domain']) { + return $this; + } + + $that = clone $this; + $that->attributes['Domain'] = $domain; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withSecure(bool $secure): static + { + if (array_key_exists('Secure', $this->attributes) && $secure === $this->attributes['Secure']) { + return $this; + } + + $that = clone $this; + $that->attributes['Secure'] = $secure; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withHttpOnly(bool $httpOnly): static + { + if (array_key_exists('HttpOnly', $this->attributes) && $httpOnly === $this->attributes['HttpOnly']) { + return $this; + } + + $that = clone $this; + $that->attributes['HttpOnly'] = $httpOnly; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withSameSite(string $sameSite): static + { + if (array_key_exists('SameSite', $this->attributes) && $sameSite === $this->attributes['SameSite']) { + return $this; + } + + if (!in_array(strtolower($sameSite), ['none', 'lax', 'strict'])) { + throw new CookieException('Invalid value for $sameSite'); + } + + $that = clone $this; + $that->attributes['SameSite'] = $sameSite; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withPartitioned(bool $partitioned): static + { + if (array_key_exists('Partitioned', $this->attributes) && $partitioned === $this->attributes['Partitioned']) { + return $this; + } + + $that = clone $this; + $that->attributes['Partitioned'] = $partitioned; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withExpires(\DateTimeImmutable $expires): static + { + $expires = $expires->format('r'); + + if (array_key_exists('Expires', $this->attributes) && $expires === $this->attributes['Expires']) { + return $this; + } + + $that = clone $this; + $that->attributes['Expires'] = $expires; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function withMaxAge(int $maxAge): static + { + if (array_key_exists('Max-Age', $this->attributes) && $maxAge === $this->attributes['Max-Age']) { + return $this; + } + + $that = clone $this; + $that->attributes['Max-Age'] = $maxAge; + + return $that; + } +} diff --git a/src/SonsOfPHP/Component/Cookie/CookieManager.php b/src/SonsOfPHP/Component/Cookie/CookieManager.php new file mode 100644 index 00000000..1357bf85 --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/CookieManager.php @@ -0,0 +1,43 @@ + + */ +final class CookieManager implements CookieManagerInterface +{ + /** + * {@inheritdoc} + */ + public function get(string $name): CookieInterface + { + $cookie = new Cookie($name); + + if ($this->has($name)) { + $cookie = $cookie->withValue($_COOKIE[$name]); + } + + return $cookie; + } + + /** + * {@inheritdoc} + */ + public function has(string $name): CookieInterface + { + return array_key_exists($name, $_COOKIE); + } + + /** + * {@inheritdoc} + */ + //public function remove(string $name): CookieInterface + //{ + //} +} diff --git a/src/SonsOfPHP/Component/Cookie/Exception/CookieException.php b/src/SonsOfPHP/Component/Cookie/Exception/CookieException.php new file mode 100644 index 00000000..9c454982 --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/Exception/CookieException.php @@ -0,0 +1,12 @@ + + */ +class CookieException extends \Exception implements CookieExceptionInterface {} diff --git a/src/SonsOfPHP/Component/Cookie/LICENSE b/src/SonsOfPHP/Component/Cookie/LICENSE new file mode 100644 index 00000000..39238382 --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/LICENSE @@ -0,0 +1,19 @@ +Copyright 2022 to Present Joshua Estes + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/SonsOfPHP/Component/Cookie/README.md b/src/SonsOfPHP/Component/Cookie/README.md new file mode 100644 index 00000000..2245d139 --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/README.md @@ -0,0 +1,16 @@ +Sons of PHP - Cookie +==================== + +## Learn More + +* [Documentation][docs] +* [Contributing][contributing] +* [Report Issues][issues] and [Submit Pull Requests][pull-requests] in the [Mother Repository][mother-repo] +* Get Help & Support using [Discussions][discussions] + +[discussions]: https://github.com/orgs/SonsOfPHP/discussions +[mother-repo]: https://github.com/SonsOfPHP/sonsofphp +[contributing]: https://docs.sonsofphp.com/contributing/ +[docs]: https://docs.sonsofphp.com/components/cookie/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3ACookie +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3ACookie diff --git a/src/SonsOfPHP/Component/Cookie/Tests/CookieTest.php b/src/SonsOfPHP/Component/Cookie/Tests/CookieTest.php new file mode 100644 index 00000000..6bde7a6a --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/Tests/CookieTest.php @@ -0,0 +1,174 @@ +assertInstanceOf(CookieInterface::class, $cookie); + } + + /** + * @covers ::withName + */ + public function testWithName(): void + { + $cookie = new Cookie('test'); + + $this->assertSame($cookie, $cookie->withName('test')); + $this->assertNotSame($cookie, $cookie->withName('test2')); + } + + /** + * @covers ::withValue + */ + public function testWithValue(): void + { + $cookie = new Cookie('test', 'value'); + + $this->assertSame($cookie, $cookie->withValue('value')); + $this->assertNotSame($cookie, $cookie->withValue('value2')); + } + + /** + * @covers ::withPath + */ + public function testWithPath(): void + { + $cookie = (new Cookie('test'))->withPath('/'); + + $this->assertSame($cookie, $cookie->withPath('/')); + $this->assertNotSame($cookie, $cookie->withPath('/testing')); + } + + /** + * @covers ::withDomain + */ + public function testWithDomain(): void + { + $cookie = (new Cookie('test'))->withDomain('sonsofphp.com'); + + $this->assertSame($cookie, $cookie->withDomain('sonsofphp.com')); + $this->assertNotSame($cookie, $cookie->withDomain('docs.sonsofphp.com')); + } + + /** + * @covers ::withSecure + */ + public function testWithSecure(): void + { + $cookie = (new Cookie('test'))->withSecure(false); + + $this->assertSame($cookie, $cookie->withSecure(false)); + $this->assertNotSame($cookie, $cookie->withSecure(true)); + } + + /** + * @covers ::withHttpOnly + */ + public function testWithHttpOnly(): void + { + $cookie = (new Cookie('test'))->withHttpOnly(false); + + $this->assertSame($cookie, $cookie->withHttpOnly(false)); + $this->assertNotSame($cookie, $cookie->withHttpOnly(true)); + } + + /** + * @covers ::withSameSite + */ + public function testWithSameSite(): void + { + $cookie = (new Cookie('test'))->withSameSite('none'); + + $this->assertSame($cookie, $cookie->withSameSite('none')); + $this->assertNotSame($cookie, $cookie->withSameSite('strict')); + } + + /** + * @covers ::withSameSite + */ + public function testWithSameSiteWithThrowExceptionOnInvalidArgument(): void + { + $cookie = new Cookie('test'); + + $this->expectException(CookieExceptionInterface::class); + $cookie->withSameSite('not valid'); + } + + /** + * @covers ::withPartitioned + */ + public function testWithPartitioned(): void + { + $cookie = (new Cookie('test'))->withPartitioned(false); + + $this->assertSame($cookie, $cookie->withPartitioned(false)); + $this->assertNotSame($cookie, $cookie->withPartitioned(true)); + } + + /** + * @covers ::getHeaderValue + */ + public function testHeaderValue(): void + { + $cookie = (new Cookie('name', 'value'))->withPath('/')->withPartitioned(false)->withHttpOnly(true); + + $this->assertSame('name=value; Path=/; HttpOnly', $cookie->getHeaderValue()); + } + + /** + * @covers ::__toString + */ + public function testToString(): void + { + $cookie = (new Cookie('name', 'value'))->withPath('/')->withPartitioned(false)->withHttpOnly(true); + + $this->assertSame($cookie->getHeaderValue(), (string) $cookie); + } + + /** + * @covers ::withMaxAge + */ + public function testMaxAge(): void + { + $cookie = (new Cookie('name', 'value'))->withMaxAge(0); + + $this->assertSame($cookie, $cookie->withMaxAge(0)); + $this->assertNotSame($cookie, $cookie->withMaxAge(420)); + + $this->assertStringContainsString('Max-Age=', $cookie->getHeaderValue()); + } + + /** + * @covers ::withExpires + */ + public function testExpires(): void + { + $timestamp = new \DateTimeImmutable('2020-04-20 04:20:00'); + $cookie = (new Cookie('name', 'value'))->withExpires($timestamp); + + $this->assertSame($cookie, $cookie->withExpires($timestamp)); + $this->assertNotSame($cookie, $cookie->withExpires(new \DateTimeImmutable())); + + $this->assertStringContainsString('Expires=Mon, 20 Apr 2020 04:20:00 +0000', $cookie->getHeaderValue()); + } +} diff --git a/src/SonsOfPHP/Component/Cookie/composer.json b/src/SonsOfPHP/Component/Cookie/composer.json new file mode 100644 index 00000000..915ec3e9 --- /dev/null +++ b/src/SonsOfPHP/Component/Cookie/composer.json @@ -0,0 +1,54 @@ +{ + "name": "sonsofphp/cookie", + "type": "library", + "description": "Manage Cookies with ease", + "keywords": [ + "cookie" + ], + "homepage": "https://github.com/SonsOfPHP/cookie", + "license": "MIT", + "authors": [ + { + "name": "Joshua Estes", + "email": "joshua@sonsofphp.com" + } + ], + "support": { + "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", + "forum": "https://github.com/orgs/SonsOfPHP/discussions", + "docs": "https://docs.sonsofphp.com/components/cookie" + }, + "autoload": { + "psr-4": { + "SonsOfPHP\\Component\\Cookie\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.1", + "sonsofphp/cookie-contract": "0.3.x-dev" + }, + "provide": { + "sonsofphp/cookie-implementation": "0.3.x-dev" + }, + "extra": { + "sort-packages": true, + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/JoshuaEstes" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" + } + ] +} diff --git a/src/SonsOfPHP/Component/Pager/README.md b/src/SonsOfPHP/Component/Pager/README.md index 79b97e64..94b314db 100644 --- a/src/SonsOfPHP/Component/Pager/README.md +++ b/src/SonsOfPHP/Component/Pager/README.md @@ -1,5 +1,5 @@ -Sons of PHP - Logger -==================== +Sons of PHP - Pager +=================== ## Learn More @@ -11,6 +11,6 @@ Sons of PHP - Logger [discussions]: https://github.com/orgs/SonsOfPHP/discussions [mother-repo]: https://github.com/SonsOfPHP/sonsofphp [contributing]: https://docs.sonsofphp.com/contributing/ -[docs]: https://docs.sonsofphp.com/components/logger/ -[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3ALogger -[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3ALogger +[docs]: https://docs.sonsofphp.com/components/pager/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3APager +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3APager diff --git a/src/SonsOfPHP/Contract/Cookie/.gitattributes b/src/SonsOfPHP/Contract/Cookie/.gitattributes new file mode 100644 index 00000000..3a01b372 --- /dev/null +++ b/src/SonsOfPHP/Contract/Cookie/.gitattributes @@ -0,0 +1,2 @@ +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/SonsOfPHP/Contract/Cookie/.gitignore b/src/SonsOfPHP/Contract/Cookie/.gitignore new file mode 100644 index 00000000..d8a7996a --- /dev/null +++ b/src/SonsOfPHP/Contract/Cookie/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor/ diff --git a/src/SonsOfPHP/Contract/Cookie/CookieExceptionInterface.php b/src/SonsOfPHP/Contract/Cookie/CookieExceptionInterface.php new file mode 100644 index 00000000..90c06107 --- /dev/null +++ b/src/SonsOfPHP/Contract/Cookie/CookieExceptionInterface.php @@ -0,0 +1,10 @@ + + */ +interface CookieExceptionInterface {} diff --git a/src/SonsOfPHP/Contract/Cookie/CookieInterface.php b/src/SonsOfPHP/Contract/Cookie/CookieInterface.php new file mode 100644 index 00000000..a000228e --- /dev/null +++ b/src/SonsOfPHP/Contract/Cookie/CookieInterface.php @@ -0,0 +1,103 @@ + + */ +interface CookieInterface extends \Stringable +{ + /** + * Returns the Header Value for "Set-Cookie" + * + * __toString and this method MUST return the same value + */ + public function getHeaderValue(): string; + + /** + * Set the cookie name + * + * If the $name is the same, it will return the same object, however if the + * $name is different than the current $name, it will return a new instance + * of cookie + * + * @throws CookieExceptionInterface if $name is invalid + */ + public function withName(string $name): static; + + /** + * Set the cookie value + * + * @throws CookieExceptionInterface if $value is invalid + */ + public function withValue(string $value): static; + + /** + * Set the "Path=" + * + * If path has the same value as the existing path, this will not return a + * new object + */ + public function withPath(string $path): static; + + /** + * Set the "Domain=" + * + * If domain has the same value as the existing domain, this will not return a + * new object + */ + public function withDomain(string $domain): static; + + /** + * Set the "SameSize=" + * + * If sameSite has the same value as the existing sameSite, this will not return a + * new object + * + * Only valid arguments allowed: + * - Strict + * - Lax + * - None + * + * @throws CookieExceptionInterface if argument is invalid + */ + public function withSameSite(string $sameSite): static; + + /** + * Set "Expires=" + * + * If expires has the same value as the existing expires, this will not return a + * new object + * + * @throws CookieExceptionInterface when $expires is invalid + */ + public function withExpires(\DateTimeImmutable $expires): static; + + /** + * Set "Max-Age=" + * + * This is the number of seconds before the cookie will expire. For + * example, if "69" is passed in, it will expire in one minute and + * 9 seconds. + * + * @throws CookieExceptionInterface when $maxAge is invalid + */ + public function withMaxAge(int $maxAge): static; + + /** + * Set "Secure" + */ + public function withSecure(bool $secure): static; + + /** + * Set "HttpOnly" + */ + public function withHttpOnly(bool $httpOnly): static; + + /** + * Set "Partitioned" + */ + public function withPartitioned(bool $partitioned): static; +} diff --git a/src/SonsOfPHP/Contract/Cookie/CookieManagerInterface.php b/src/SonsOfPHP/Contract/Cookie/CookieManagerInterface.php new file mode 100644 index 00000000..2fa6df7e --- /dev/null +++ b/src/SonsOfPHP/Contract/Cookie/CookieManagerInterface.php @@ -0,0 +1,43 @@ + + */ +interface CookieManagerInterface +{ + /** + * If a cookie does not exists, this will create a new Cookie object and + * return that. + * + * Example: + * $cookie = $manager->get('PHPSESSID'); + */ + public function get(string $name): CookieInterface; + + /** + * Checks to see if "$name" exists in the request cookies + * + * Example: + * if ($manager->has('PHPSESSID')) { + * // ... + * } + */ + public function has(string $name): bool; + + /** + * Removes the cookie, this will remove from the browser as well + * + * If this return true, everything went ok, if it returns false, something + * is broken. If thise throws an exception, something really fucked up + * happened + * + * @throws CookieExceptionInterface + */ + //public function remove(string $name): bool; +} diff --git a/src/SonsOfPHP/Contract/Cookie/LICENSE b/src/SonsOfPHP/Contract/Cookie/LICENSE new file mode 100644 index 00000000..39238382 --- /dev/null +++ b/src/SonsOfPHP/Contract/Cookie/LICENSE @@ -0,0 +1,19 @@ +Copyright 2022 to Present Joshua Estes + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/SonsOfPHP/Contract/Cookie/README.md b/src/SonsOfPHP/Contract/Cookie/README.md new file mode 100644 index 00000000..4f25aaa6 --- /dev/null +++ b/src/SonsOfPHP/Contract/Cookie/README.md @@ -0,0 +1,16 @@ +Sons of PHP - Cookie Contract +============================= + +## Learn More + +* [Documentation][docs] +* [Contributing][contributing] +* [Report Issues][issues] and [Submit Pull Requests][pull-requests] in the [Mother Repository][mother-repo] +* Get Help & Support using [Discussions][discussions] + +[discussions]: https://github.com/orgs/SonsOfPHP/discussions +[mother-repo]: https://github.com/SonsOfPHP/sonsofphp +[contributing]: https://docs.sonsofphp.com/contributing/ +[docs]: https://docs.sonsofphp.com/contracts/cookie/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3ACookie +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3ACookie diff --git a/src/SonsOfPHP/Contract/Cookie/composer.json b/src/SonsOfPHP/Contract/Cookie/composer.json new file mode 100644 index 00000000..410d0c89 --- /dev/null +++ b/src/SonsOfPHP/Contract/Cookie/composer.json @@ -0,0 +1,52 @@ +{ + "name": "sonsofphp/cookie-contract", + "type": "library", + "description": "", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "homepage": "https://github.com/SonsOfPHP/cookie-contract", + "license": "MIT", + "authors": [ + { + "name": "Joshua Estes", + "email": "joshua@sonsofphp.com" + } + ], + "support": { + "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", + "forum": "https://github.com/orgs/SonsOfPHP/discussions", + "docs": "https://docs.sonsofphp.com/contracts/cookie" + }, + "autoload": { + "psr-4": { + "SonsOfPHP\\Contract\\Cookie\\": "" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.1" + }, + "extra": { + "sort-packages": true, + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/JoshuaEstes" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" + } + ] +} From 4842439eb72ffb348ee4f6b2ae96a82a513644ea Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Wed, 6 Dec 2023 15:38:24 -0500 Subject: [PATCH 02/11] [Container] New Component (#182) ## Description Simple light weight container ## Checklist - [x] Updated CHANGELOG files - [x] Updated Documentation - [x] Unit Tests Created - [x] php-cs-fixer --- .github/CODEOWNERS | 1 + .github/labeler.yml | 4 + CHANGELOG.md | 1 + Makefile | 6 ++ bard.json | 4 + composer.json | 8 +- docs/components/container/index.md | 34 ++++++ mkdocs.yml | 1 + phpunit.xml.dist | 4 + .../Component/Container/.gitattributes | 4 + src/SonsOfPHP/Component/Container/.gitignore | 3 + .../Component/Container/Container.php | 60 +++++++++++ .../Exception/ContainerException.php | 9 ++ .../Container/Exception/NotFoundException.php | 9 ++ src/SonsOfPHP/Component/Container/LICENSE | 19 ++++ src/SonsOfPHP/Component/Container/README.md | 16 +++ .../Container/Tests/ContainerTest.php | 100 ++++++++++++++++++ .../Component/Container/composer.json | 56 ++++++++++ src/SonsOfPHP/Component/Cookie/composer.json | 4 +- src/SonsOfPHP/Contract/Cookie/composer.json | 4 +- 20 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 docs/components/container/index.md create mode 100644 src/SonsOfPHP/Component/Container/.gitattributes create mode 100644 src/SonsOfPHP/Component/Container/.gitignore create mode 100644 src/SonsOfPHP/Component/Container/Container.php create mode 100644 src/SonsOfPHP/Component/Container/Exception/ContainerException.php create mode 100644 src/SonsOfPHP/Component/Container/Exception/NotFoundException.php create mode 100644 src/SonsOfPHP/Component/Container/LICENSE create mode 100644 src/SonsOfPHP/Component/Container/README.md create mode 100644 src/SonsOfPHP/Component/Container/Tests/ContainerTest.php create mode 100644 src/SonsOfPHP/Component/Container/composer.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f152e365..19d8609b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,7 @@ docs/ @JoshuaEstes # Each component/contract needs a Team /src/SonsOfPHP/**/Cache @JoshuaEstes /src/SonsOfPHP/**/Clock @JoshuaEstes +/src/SonsOfPHP/**/Container @JoshuaEstes /src/SonsOfPHP/**/Cookie @JoshuaEstes /src/SonsOfPHP/**/Common @JoshuaEstes /src/SonsOfPHP/**/Cqrs @JoshuaEstes diff --git a/.github/labeler.yml b/.github/labeler.yml index 022aa9c3..a406aa7a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -10,6 +10,10 @@ Clock: - docs/components/clock/* - src/SonsOfPHP/**/Clock/* +Container: + - docs/components/container/* + - src/SonsOfPHP/**/Container/* + Cookie: - docs/components/cookie/* - src/SonsOfPHP/**/Cookie/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 35aaed74..e5996b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ To get the diff between two versions, go to https://github.com/SonsOfPHP/sonsofp * [PR #170](https://github.com/SonsOfPHP/sonsofphp/pull/170) [Link] New Component (PSR-13) * [PR #173](https://github.com/SonsOfPHP/sonsofphp/pull/173) [Money] Twig Bridge * [PR #181](https://github.com/SonsOfPHP/sonsofphp/pull/181) [Cookie] New Component and Contract +* [PR #182](https://github.com/SonsOfPHP/sonsofphp/pull/182) [Container] New Component (PSR-11) ## [0.3.8] diff --git a/Makefile b/Makefile index 5cfa9d25..25fac580 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,9 @@ test-cache: phpunit test-clock: PHPUNIT_TESTSUITE=clock test-clock: phpunit +test-container: PHPUNIT_TESTSUITE=container +test-container: phpunit + test-cookie: PHPUNIT_TESTSUITE=cookie test-cookie: phpunit @@ -105,6 +108,9 @@ coverage-cache: coverage coverage-clock: PHPUNIT_TESTSUITE=clock coverage-clock: coverage +coverage-container: PHPUNIT_TESTSUITE=container +coverage-container: coverage + coverage-cookie: PHPUNIT_TESTSUITE=cookie coverage-cookie: coverage diff --git a/bard.json b/bard.json index a3d28056..bb7cafb0 100644 --- a/bard.json +++ b/bard.json @@ -13,6 +13,10 @@ "path": "src/SonsOfPHP/Component/Clock", "repository": "git@github.com:SonsOfPHP/clock.git" }, + { + "path": "src/SonsOfPHP/Component/Container", + "repository": "git@github.com:SonsOfPHP/container.git" + }, { "path": "src/SonsOfPHP/Component/Cookie", "repository": "git@github.com:SonsOfPHP/cookie.git" diff --git a/composer.json b/composer.json index 40bf6b78..64a15ad6 100644 --- a/composer.json +++ b/composer.json @@ -76,7 +76,8 @@ "ext-intl": "*", "doctrine/collections": "^2", "doctrine/orm": "^2", - "sonsofphp/cookie-contract": "0.3.x-dev" + "sonsofphp/cookie-contract": "0.3.x-dev", + "psr/container": "^1.0 || ^2.0" }, "replace": { "sonsofphp/bard": "self.version", @@ -114,13 +115,15 @@ "sonsofphp/pager-doctrine-dbal": "self.version", "sonsofphp/pager-doctrine-orm": "self.version", "sonsofphp/cookie": "self.version", - "sonsofphp/cookie-contract": "self.version" + "sonsofphp/cookie-contract": "self.version", + "sonsofphp/container": "self.version" }, "autoload": { "psr-4": { "SonsOfPHP\\Bard\\": "src/SonsOfPHP/Bard/src", "SonsOfPHP\\Component\\Cache\\": "src/SonsOfPHP/Component/Cache", "SonsOfPHP\\Component\\Clock\\": "src/SonsOfPHP/Component/Clock", + "SonsOfPHP\\Component\\Container\\": "src/SonsOfPHP/Component/Container", "SonsOfPHP\\Component\\Cookie\\": "src/SonsOfPHP/Component/Cookie", "SonsOfPHP\\Component\\Cqrs\\": "src/SonsOfPHP/Component/Cqrs", "SonsOfPHP\\Bundle\\Cqrs\\": "src/SonsOfPHP/Bundle/Cqrs", @@ -158,6 +161,7 @@ "src/SonsOfPHP/Bard/Tests", "src/SonsOfPHP/Component/Cache/Tests", "src/SonsOfPHP/Component/Clock/Tests", + "src/SonsOfPHP/Component/Container/Tests", "src/SonsOfPHP/Component/Cookie/Tests", "src/SonsOfPHP/Component/Cqrs/Tests", "src/SonsOfPHP/Bundle/Cqrs/Tests", diff --git a/docs/components/container/index.md b/docs/components/container/index.md new file mode 100644 index 00000000..56fee6e0 --- /dev/null +++ b/docs/components/container/index.md @@ -0,0 +1,34 @@ +--- +title: Container +--- + +This is a very simple lightweight PSR-11 Container implementation. + +## Installation + +```shell +composer require sonsofphp/container +``` + +## Usage + +```php +set('service.id.one', function (ContainerInterface $container) { + return new Service(); +}); +$container->set('service.id.two', function (ContainerInterface $container) { + return new Service($container->get('service.id.one')); +}); + +// Services will not be created until they are called, once called, they will +// always return the same instance of the service. That means that in the +// following code, the "service.id.two" is only constructed once. +$service = $container->get('service.id.two'); +$service2 = $container->get('service.id.two'); +``` diff --git a/mkdocs.yml b/mkdocs.yml index 9c0fc364..e1e0a3db 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,7 @@ nav: - components/index.md - Cache: components/cache/index.md - Clock: components/clock/index.md + - Container: components/container/index.md - Cookie: components/cookie/index.md - CQRS: - components/cqrs/index.md diff --git a/phpunit.xml.dist b/phpunit.xml.dist index db3a9659..300729be 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -30,6 +30,10 @@ src/SonsOfPHP/Component/Clock/Tests + + src/SonsOfPHP/Component/Container/Tests + + src/SonsOfPHP/Component/Cookie/Tests diff --git a/src/SonsOfPHP/Component/Container/.gitattributes b/src/SonsOfPHP/Component/Container/.gitattributes new file mode 100644 index 00000000..84c7add0 --- /dev/null +++ b/src/SonsOfPHP/Component/Container/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/SonsOfPHP/Component/Container/.gitignore b/src/SonsOfPHP/Component/Container/.gitignore new file mode 100644 index 00000000..5414c2c6 --- /dev/null +++ b/src/SonsOfPHP/Component/Container/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/SonsOfPHP/Component/Container/Container.php b/src/SonsOfPHP/Component/Container/Container.php new file mode 100644 index 00000000..4a7b37fd --- /dev/null +++ b/src/SonsOfPHP/Component/Container/Container.php @@ -0,0 +1,60 @@ +set('service.id', function (ContainerInterface $container) { + * return new Service($container->get('another.service_id')); + * }); + */ +class Container implements ContainerInterface +{ + private array $services = []; + private array $cachedServices = []; + + /** + * {@inheritdoc} + */ + public function get(string $id) + { + if (false === $this->has($id)) { + throw new NotFoundException(sprintf('Service "%s" not found.', $id)); + } + + if (!array_key_exists($id, $this->cachedServices)) { + $this->cachedServices[$id] = call_user_func($this->services[$id], $this); + } + + return $this->cachedServices[$id]; + } + + /** + * {@inheritdoc} + */ + public function has(string $id): bool + { + return array_key_exists($id, $this->services); + } + + /** + * @param callable $callable + */ + public function set(string $id, $callable): self + { + if (!is_callable($callable)) { + throw new ContainerException('MUST pass in a callable'); + } + + $this->services[$id] = $callable; + + return $this; + } +} diff --git a/src/SonsOfPHP/Component/Container/Exception/ContainerException.php b/src/SonsOfPHP/Component/Container/Exception/ContainerException.php new file mode 100644 index 00000000..3be7e636 --- /dev/null +++ b/src/SonsOfPHP/Component/Container/Exception/ContainerException.php @@ -0,0 +1,9 @@ +assertInstanceOf(ContainerInterface::class, $container); + } + + /** + * @covers ::has + */ + public function testhHas(): void + { + $container = new Container(); + + $this->assertFalse($container->has('service.id')); + $container->set('service.id', function (): void {}); + $this->assertTrue($container->has('service.id')); + } + + /** + * @covers ::get + */ + public function testGetWhenServiceNotFound(): void + { + $container = new Container(); + + $this->expectException(NotFoundExceptionInterface::class); + $container->get('nope'); + } + + /** + * @covers ::get + */ + public function testGetAlwaysReturnSameInstance(): void + { + $container = new Container(); + $container->set('service.id', fn() => new \stdClass()); + $service = $container->get('service.id'); + $this->assertSame($service, $container->get('service.id')); + } + + /** + * @covers ::get + */ + public function testGetWillCacheService(): void + { + $container = new Container(); + $cached = new \ReflectionProperty($container, 'cachedServices'); + + $container->set('service.id', fn() => new \stdClass()); + $service = $container->get('service.id'); + $this->assertCount(1, $cached->getValue($container)); + } + + /** + * @covers ::set + */ + public function testSet(): void + { + $container = new Container(); + $services = new \ReflectionProperty($container, 'services'); + $this->assertCount(0, $services->getValue($container)); + + $container->set('service.id', function (): void {}); + $this->assertCount(1, $services->getValue($container)); + } + + /** + * @covers ::set + */ + public function testSetWhenNotCallable(): void + { + $container = new Container(); + + $this->expectException(ContainerExceptionInterface::class); + $container->set('service.id', 'this is not callable'); + } +} diff --git a/src/SonsOfPHP/Component/Container/composer.json b/src/SonsOfPHP/Component/Container/composer.json new file mode 100644 index 00000000..cdf98458 --- /dev/null +++ b/src/SonsOfPHP/Component/Container/composer.json @@ -0,0 +1,56 @@ +{ + "name": "sonsofphp/container", + "type": "library", + "description": "Lightweight PSR-11 Container", + "keywords": [ + "psr11", + "psr-11", + "container" + ], + "homepage": "https://github.com/SonsOfPHP/container", + "license": "MIT", + "authors": [ + { + "name": "Joshua Estes", + "email": "joshua@sonsofphp.com" + } + ], + "support": { + "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", + "forum": "https://github.com/orgs/SonsOfPHP/discussions", + "docs": "https://docs.sonsofphp.com" + }, + "autoload": { + "psr-4": { + "SonsOfPHP\\Component\\Container\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.1", + "psr/container": "^1.0 || ^2.0" + }, + "provide": { + "psr/container": "2.0" + }, + "extra": { + "sort-packages": true, + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/JoshuaEstes" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" + } + ] +} diff --git a/src/SonsOfPHP/Component/Cookie/composer.json b/src/SonsOfPHP/Component/Cookie/composer.json index 915ec3e9..96bc1774 100644 --- a/src/SonsOfPHP/Component/Cookie/composer.json +++ b/src/SonsOfPHP/Component/Cookie/composer.json @@ -16,7 +16,7 @@ "support": { "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", "forum": "https://github.com/orgs/SonsOfPHP/discussions", - "docs": "https://docs.sonsofphp.com/components/cookie" + "docs": "https://docs.sonsofphp.com" }, "autoload": { "psr-4": { @@ -51,4 +51,4 @@ "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" } ] -} +} \ No newline at end of file diff --git a/src/SonsOfPHP/Contract/Cookie/composer.json b/src/SonsOfPHP/Contract/Cookie/composer.json index 410d0c89..6aed6a6c 100644 --- a/src/SonsOfPHP/Contract/Cookie/composer.json +++ b/src/SonsOfPHP/Contract/Cookie/composer.json @@ -21,7 +21,7 @@ "support": { "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", "forum": "https://github.com/orgs/SonsOfPHP/discussions", - "docs": "https://docs.sonsofphp.com/contracts/cookie" + "docs": "https://docs.sonsofphp.com" }, "autoload": { "psr-4": { @@ -49,4 +49,4 @@ "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" } ] -} +} \ No newline at end of file From c81c9a38e4e39c5a6cf8fc9c7d241b85d83904d8 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Thu, 7 Dec 2023 10:20:23 -0500 Subject: [PATCH 03/11] [HttpHandler] Component (#187) ## Description ## Checklist - [x] Updated CHANGELOG files - [x] Updated Documentation - [x] Unit Tests Created - [x] php-cs-fixer --- .github/CODEOWNERS | 1 + .github/labeler.yml | 4 + CHANGELOG.md | 1 + Makefile | 6 + bard.json | 8 + composer.json | 21 ++- docs/components/http-handler/index.md | 56 ++++++ mkdocs.yml | 1 + phpunit.xml.dist | 4 + src/SonsOfPHP/Component/Container/README.md | 10 +- .../Component/Container/composer.json | 4 +- .../Tests/Test/CountEventsRaisedTest.php | 2 + .../Tests/Test/EventRaisedTest.php | 2 + .../Component/HttpHandler/.gitattributes | 4 + .../Component/HttpHandler/.gitignore | 3 + .../Exception/HttpHandlerException.php | 12 ++ .../Component/HttpHandler/HttpHandler.php | 31 ++++ src/SonsOfPHP/Component/HttpHandler/LICENSE | 19 ++ .../Component/HttpHandler/MiddlewareStack.php | 65 +++++++ src/SonsOfPHP/Component/HttpHandler/README.md | 16 ++ .../HttpHandler/Tests/HttpHandlerTest.php | 60 +++++++ .../HttpHandler/Tests/MiddlewareStackTest.php | 164 ++++++++++++++++++ .../Component/HttpHandler/composer.json | 63 +++++++ .../Contract/HttpHandler/.gitattributes | 2 + src/SonsOfPHP/Contract/HttpHandler/.gitignore | 2 + .../HttpHandlerExceptionInterface.php | 10 ++ src/SonsOfPHP/Contract/HttpHandler/LICENSE | 19 ++ .../HttpHandler/MiddlewareStackInterface.php | 21 +++ src/SonsOfPHP/Contract/HttpHandler/README.md | 18 ++ .../Contract/HttpHandler/composer.json | 54 ++++++ 30 files changed, 672 insertions(+), 11 deletions(-) create mode 100644 docs/components/http-handler/index.md create mode 100644 src/SonsOfPHP/Component/HttpHandler/.gitattributes create mode 100644 src/SonsOfPHP/Component/HttpHandler/.gitignore create mode 100644 src/SonsOfPHP/Component/HttpHandler/Exception/HttpHandlerException.php create mode 100644 src/SonsOfPHP/Component/HttpHandler/HttpHandler.php create mode 100644 src/SonsOfPHP/Component/HttpHandler/LICENSE create mode 100644 src/SonsOfPHP/Component/HttpHandler/MiddlewareStack.php create mode 100644 src/SonsOfPHP/Component/HttpHandler/README.md create mode 100644 src/SonsOfPHP/Component/HttpHandler/Tests/HttpHandlerTest.php create mode 100644 src/SonsOfPHP/Component/HttpHandler/Tests/MiddlewareStackTest.php create mode 100644 src/SonsOfPHP/Component/HttpHandler/composer.json create mode 100644 src/SonsOfPHP/Contract/HttpHandler/.gitattributes create mode 100644 src/SonsOfPHP/Contract/HttpHandler/.gitignore create mode 100644 src/SonsOfPHP/Contract/HttpHandler/HttpHandlerExceptionInterface.php create mode 100644 src/SonsOfPHP/Contract/HttpHandler/LICENSE create mode 100644 src/SonsOfPHP/Contract/HttpHandler/MiddlewareStackInterface.php create mode 100644 src/SonsOfPHP/Contract/HttpHandler/README.md create mode 100644 src/SonsOfPHP/Contract/HttpHandler/composer.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 19d8609b..27a3cc09 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,6 +18,7 @@ docs/ @JoshuaEstes /src/SonsOfPHP/**/FeatureToggle @JoshuaEstes /src/SonsOfPHP/**/Filesystem @JoshuaEstes /src/SonsOfPHP/**/HttpFactory @JoshuaEstes +/src/SonsOfPHP/**/HttpHandler @JoshuaEstes /src/SonsOfPHP/**/HttpMessage @JoshuaEstes /src/SonsOfPHP/**/Json @JoshuaEstes /src/SonsOfPHP/**/Link @JoshuaEstes diff --git a/.github/labeler.yml b/.github/labeler.yml index a406aa7a..d666764d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -46,6 +46,10 @@ HttpFactory: - docs/components/http-factory/* - src/SonsOfPHP/**/HttpFactory/* +HttpHandler: + - docs/components/http-handler/* + - src/SonsOfPHP/**/HttpHandler/* + HttpMessage: - docs/components/http-message/* - src/SonsOfPHP/**/HttpMessage/* diff --git a/CHANGELOG.md b/CHANGELOG.md index e5996b58..53b2981f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ To get the diff between two versions, go to https://github.com/SonsOfPHP/sonsofp * [PR #173](https://github.com/SonsOfPHP/sonsofphp/pull/173) [Money] Twig Bridge * [PR #181](https://github.com/SonsOfPHP/sonsofphp/pull/181) [Cookie] New Component and Contract * [PR #182](https://github.com/SonsOfPHP/sonsofphp/pull/182) [Container] New Component (PSR-11) +* [PR #187](https://github.com/SonsOfPHP/sonsofphp/pull/187) [HttpHandler] New Component (PSR-15) and Contract ## [0.3.8] diff --git a/Makefile b/Makefile index 25fac580..e4ccb23e 100644 --- a/Makefile +++ b/Makefile @@ -64,6 +64,9 @@ test-cqrs: phpunit test-http-factory: PHPUNIT_TESTSUITE=http-factory test-http-factory: phpunit +test-http-handler: PHPUNIT_TESTSUITE=http-handler +test-http-handler: phpunit + test-link: PHPUNIT_TESTSUITE=link test-link: phpunit @@ -132,6 +135,9 @@ coverage-filesystem: coverage-http-factory: XDEBUG_MODE=coverage $(PHP) -dxdebug.mode=coverage $(PHPUNIT) --testsuite http-factory --coverage-html $(COVERAGE_DIR) +coverage-http-handler: PHPUNIT_TESTSUITE=http-handler +coverage-http-handler: coverage + coverage-http-message: XDEBUG_MODE=coverage $(PHP) -dxdebug.mode=coverage $(PHPUNIT) --testsuite http-message --coverage-html $(COVERAGE_DIR) diff --git a/bard.json b/bard.json index bb7cafb0..2a13bfc4 100644 --- a/bard.json +++ b/bard.json @@ -65,6 +65,14 @@ "path": "src/SonsOfPHP/Component/HttpFactory", "repository": "git@github.com:SonsOfPHP/http-factory.git" }, + { + "path": "src/SonsOfPHP/Component/HttpHandler", + "repository": "git@github.com:SonsOfPHP/http-handler.git" + }, + { + "path": "src/SonsOfPHP/Contract/HttpHandler", + "repository": "git@github.com:SonsOfPHP/http-handler-contract.git" + }, { "path": "src/SonsOfPHP/Component/HttpMessage", "repository": "git@github.com:SonsOfPHP/http-message.git" diff --git a/composer.json b/composer.json index 64a15ad6..7b41fd73 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,11 @@ "sonsofphp/logger-implementation": "0.3.x-dev", "sonsofphp/pager-implementation": "0.3.x-dev", "psr/link-implementation": "^1.0 || ^2.0", - "sonsofphp/cookie-implementation": "0.3.x-dev" + "sonsofphp/cookie-implementation": "0.3.x-dev", + "psr/container-implementation": "^1.0 || ^2.0", + "psr/http-server-handler-implementation": "^1.0", + "psr/http-server-middleware-implementation": "^1.0", + "sonsofphp/http-handler-implementation": "0.3.x-dev" }, "require": { "php": ">=8.1", @@ -77,7 +81,10 @@ "doctrine/collections": "^2", "doctrine/orm": "^2", "sonsofphp/cookie-contract": "0.3.x-dev", - "psr/container": "^1.0 || ^2.0" + "psr/container": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "sonsofphp/http-handler-contract": "0.3.x-dev" }, "replace": { "sonsofphp/bard": "self.version", @@ -116,7 +123,9 @@ "sonsofphp/pager-doctrine-orm": "self.version", "sonsofphp/cookie": "self.version", "sonsofphp/cookie-contract": "self.version", - "sonsofphp/container": "self.version" + "sonsofphp/container": "self.version", + "sonsofphp/http-handler": "self.version", + "sonsofphp/http-handler-contract": "self.version" }, "autoload": { "psr-4": { @@ -136,6 +145,8 @@ "SonsOfPHP\\Component\\FeatureToggle\\": "src/SonsOfPHP/Component/FeatureToggle", "SonsOfPHP\\Component\\Filesystem\\": "src/SonsOfPHP/Component/Filesystem", "SonsOfPHP\\Component\\HttpFactory\\": "src/SonsOfPHP/Component/HttpFactory", + "SonsOfPHP\\Component\\HttpHandler\\": "src/SonsOfPHP/Component/HttpHandler", + "SonsOfPHP\\Contract\\HttpHandler\\": "src/SonsOfPHP/Contract/HttpHandler", "SonsOfPHP\\Component\\HttpMessage\\": "src/SonsOfPHP/Component/HttpMessage", "SonsOfPHP\\Component\\Json\\": "src/SonsOfPHP/Component/Json", "SonsOfPHP\\Component\\Link\\": "src/SonsOfPHP/Component/Link", @@ -174,6 +185,7 @@ "src/SonsOfPHP/Component/FeatureToggle/Tests", "src/SonsOfPHP/Component/Filesystem/Tests", "src/SonsOfPHP/Component/HttpFactory/Tests", + "src/SonsOfPHP/Component/HttpHandler/Tests", "src/SonsOfPHP/Component/HttpMessage/Tests", "src/SonsOfPHP/Component/Json/Tests", "src/SonsOfPHP/Component/Link/Tests", @@ -199,7 +211,8 @@ "symfony/error-handler": "^6", "symfony/messenger": "^5 || ^6", "phpunit/phpunit": "^10.4", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "sonsofphp/http-message": "0.3.x-dev" }, "autoload-dev": { "psr-4": { diff --git a/docs/components/http-handler/index.md b/docs/components/http-handler/index.md new file mode 100644 index 00000000..4f36db66 --- /dev/null +++ b/docs/components/http-handler/index.md @@ -0,0 +1,56 @@ +--- +title: HttpHandler +--- + +Simple PSR-15 Http Handler + +## Installation + +```shell +composer require sonsofphp/http-handler +``` + +## Usage + +Usage is pretty simple. + +```php +add(new RouterMiddleware()); +$stack->add(new CookieMiddleware()); +$stack->add(function ($request, $handler) { + // ... +}); +// ... + +$app = new HttpHandler($stack); +$response = $app->handle($request); +``` + +The `MiddlewareStack` accepts objects that implement `Psr\Http\Server\MiddlewareInterface` +and anonymous functions. + +### Middleware Priorities + +An optional second argument may be passed to the `MiddlewareStack` which is for +the priority of the middleware. Priorities are ordered in ascending order. + +```php +add(new NotFoundMiddleware(), 1025); +$stack->add(new RouterMiddleware(), 255); +$stack->add(new CookieMiddleware(), -255); +$stack->add(new DefaultMiddleware()); +``` + +In the above example, the `CookieMiddleware` will be processed first and +`NotFoundMiddleware` will be processed last. diff --git a/mkdocs.yml b/mkdocs.yml index e1e0a3db..193d6276 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,6 +106,7 @@ nav: - components/filesystem/index.md - Adapters: components/filesystem/adapters.md - HttpFactory: components/http-factory/index.md + - HttpHandler: components/http-handler/index.md - HttpMessage: components/http-message/index.md - JSON: components/json/index.md - Link: components/link/index.md diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 300729be..c0695fe4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -64,6 +64,10 @@ src/SonsOfPHP/Component/HttpFactory/Tests + + src/SonsOfPHP/Component/HttpHandler/Tests + + src/SonsOfPHP/Component/HttpMessage/Tests diff --git a/src/SonsOfPHP/Component/Container/README.md b/src/SonsOfPHP/Component/Container/README.md index 2245d139..e09bb4c0 100644 --- a/src/SonsOfPHP/Component/Container/README.md +++ b/src/SonsOfPHP/Component/Container/README.md @@ -1,5 +1,5 @@ -Sons of PHP - Cookie -==================== +Sons of PHP - Container +======================= ## Learn More @@ -11,6 +11,6 @@ Sons of PHP - Cookie [discussions]: https://github.com/orgs/SonsOfPHP/discussions [mother-repo]: https://github.com/SonsOfPHP/sonsofphp [contributing]: https://docs.sonsofphp.com/contributing/ -[docs]: https://docs.sonsofphp.com/components/cookie/ -[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3ACookie -[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3ACookie +[docs]: https://docs.sonsofphp.com/components/container/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3AContainer +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3AContainer diff --git a/src/SonsOfPHP/Component/Container/composer.json b/src/SonsOfPHP/Component/Container/composer.json index cdf98458..31ef2458 100644 --- a/src/SonsOfPHP/Component/Container/composer.json +++ b/src/SonsOfPHP/Component/Container/composer.json @@ -35,7 +35,7 @@ "psr/container": "^1.0 || ^2.0" }, "provide": { - "psr/container": "2.0" + "psr/container-implementation": "^1.0 || ^2.0" }, "extra": { "sort-packages": true, @@ -53,4 +53,4 @@ "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" } ] -} +} \ No newline at end of file diff --git a/src/SonsOfPHP/Component/EventSourcing/Tests/Test/CountEventsRaisedTest.php b/src/SonsOfPHP/Component/EventSourcing/Tests/Test/CountEventsRaisedTest.php index 69a764ca..9e118635 100644 --- a/src/SonsOfPHP/Component/EventSourcing/Tests/Test/CountEventsRaisedTest.php +++ b/src/SonsOfPHP/Component/EventSourcing/Tests/Test/CountEventsRaisedTest.php @@ -10,6 +10,8 @@ /** * @coversDefaultClass \SonsOfPHP\Component\EventSourcing\Test\CountEventsRaised + * + * @uses \SonsOfPHP\Component\EventSourcing\Test\CountEventsRaised */ final class CountEventsRaisedTest extends TestCase { diff --git a/src/SonsOfPHP/Component/EventSourcing/Tests/Test/EventRaisedTest.php b/src/SonsOfPHP/Component/EventSourcing/Tests/Test/EventRaisedTest.php index 006792c4..7b1dd465 100644 --- a/src/SonsOfPHP/Component/EventSourcing/Tests/Test/EventRaisedTest.php +++ b/src/SonsOfPHP/Component/EventSourcing/Tests/Test/EventRaisedTest.php @@ -10,6 +10,8 @@ /** * @coversDefaultClass \SonsOfPHP\Component\EventSourcing\Test\EventRaised + * + * @uses \SonsOfPHP\Component\EventSourcing\Test\EventRaised */ final class EventRaisedTest extends TestCase { diff --git a/src/SonsOfPHP/Component/HttpHandler/.gitattributes b/src/SonsOfPHP/Component/HttpHandler/.gitattributes new file mode 100644 index 00000000..84c7add0 --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/SonsOfPHP/Component/HttpHandler/.gitignore b/src/SonsOfPHP/Component/HttpHandler/.gitignore new file mode 100644 index 00000000..5414c2c6 --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/SonsOfPHP/Component/HttpHandler/Exception/HttpHandlerException.php b/src/SonsOfPHP/Component/HttpHandler/Exception/HttpHandlerException.php new file mode 100644 index 00000000..f2dbb6ef --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/Exception/HttpHandlerException.php @@ -0,0 +1,12 @@ + + */ +class HttpHandlerException extends \Exception implements HttpHandlerExceptionInterface {} diff --git a/src/SonsOfPHP/Component/HttpHandler/HttpHandler.php b/src/SonsOfPHP/Component/HttpHandler/HttpHandler.php new file mode 100644 index 00000000..dba8c823 --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/HttpHandler.php @@ -0,0 +1,31 @@ + + */ +class HttpHandler implements RequestHandlerInterface +{ + public function __construct(private MiddlewareStack $stack) {} + + /** + * {@inheritdoc} + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + if (0 === $this->stack->count()) { + throw new \Exception('No Middleware in the queue.'); + } + + $middleware = $this->stack->next(); + + return $middleware->process($request, $this); + } +} diff --git a/src/SonsOfPHP/Component/HttpHandler/LICENSE b/src/SonsOfPHP/Component/HttpHandler/LICENSE new file mode 100644 index 00000000..39238382 --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/LICENSE @@ -0,0 +1,19 @@ +Copyright 2022 to Present Joshua Estes + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/SonsOfPHP/Component/HttpHandler/MiddlewareStack.php b/src/SonsOfPHP/Component/HttpHandler/MiddlewareStack.php new file mode 100644 index 00000000..cc5dbbee --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/MiddlewareStack.php @@ -0,0 +1,65 @@ + + */ +class MiddlewareStack implements MiddlewareStackInterface +{ + private array $middlewares = []; + //private $resolver; + + //public function __construct($resolver) + //{ + // $this->resolver = $resolver; + //} + + /** + * Adds a new middleware to the stack. Middlewares can be prioritized and + * will be ordered from the lowest number to the highest number (ascending + * order). + */ + public function add(MiddlewareInterface|\Closure $middleware, int $priority = 0): self + { + $this->middlewares[$priority][] = $middleware; + ksort($this->middlewares); + + return $this; + } + + public function next(): MiddlewareInterface + { + $priorityStack = array_shift($this->middlewares); + $middleware = array_shift($priorityStack); + if (0 !== count($priorityStack)) { + array_unshift($this->middlewares, $priorityStack); + } + + if ($middleware instanceof \Closure) { + return new class ($middleware) implements MiddlewareInterface { + public function __construct(private \Closure $closure) {} + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $this->closure($request, $handler); + } + }; + } + + return $middleware; + } + + public function count(): int + { + return count($this->middlewares); + } +} diff --git a/src/SonsOfPHP/Component/HttpHandler/README.md b/src/SonsOfPHP/Component/HttpHandler/README.md new file mode 100644 index 00000000..9e0547d0 --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/README.md @@ -0,0 +1,16 @@ +Sons of PHP - HttpHandler +========================= + +## Learn More + +* [Documentation][docs] +* [Contributing][contributing] +* [Report Issues][issues] and [Submit Pull Requests][pull-requests] in the [Mother Repository][mother-repo] +* Get Help & Support using [Discussions][discussions] + +[discussions]: https://github.com/orgs/SonsOfPHP/discussions +[mother-repo]: https://github.com/SonsOfPHP/sonsofphp +[contributing]: https://docs.sonsofphp.com/contributing/ +[docs]: https://docs.sonsofphp.com/components/http-handler/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3AHttpHandler +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3AHttpHandler diff --git a/src/SonsOfPHP/Component/HttpHandler/Tests/HttpHandlerTest.php b/src/SonsOfPHP/Component/HttpHandler/Tests/HttpHandlerTest.php new file mode 100644 index 00000000..ce9ff9d0 --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/Tests/HttpHandlerTest.php @@ -0,0 +1,60 @@ +request = $this->createMock(ServerRequestInterface::class); + $this->response = $this->createMock(ResponseInterface::class); + $this->stack = new MiddlewareStack(); + } + + /** + * @covers ::__construct + */ + public function testItHasTheCorrectInterface(): void + { + $handler = new HttpHandler($this->stack); + + $this->assertInstanceOf(RequestHandlerInterface::class, $handler); + } + + /** + * @covers ::handle + */ + public function testHandle(): void + { + $this->stack->add(new class () implements MiddlewareInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return new Response(); + } + }); + $handler = new HttpHandler($this->stack); + + $this->assertNotNull($handler->handle($this->request)); + } +} diff --git a/src/SonsOfPHP/Component/HttpHandler/Tests/MiddlewareStackTest.php b/src/SonsOfPHP/Component/HttpHandler/Tests/MiddlewareStackTest.php new file mode 100644 index 00000000..cd1a5652 --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/Tests/MiddlewareStackTest.php @@ -0,0 +1,164 @@ +assertInstanceOf(MiddlewareStackInterface::class, $stack); + } + + /** + * @covers ::add + */ + public function testAdd(): void + { + $stack = new MiddlewareStack(); + $middlewares = new \ReflectionProperty($stack, 'middlewares'); + $this->assertCount(0, $middlewares->getValue($stack)); + + $stack->add(function (): void {}); + $this->assertCount(1, $middlewares->getValue($stack)); + } + + /** + * @covers ::count + */ + public function testCount(): void + { + $stack = new MiddlewareStack(); + $this->assertCount(0, $stack); + + $stack->add(function (): void {}); + $this->assertCount(1, $stack); + } + + /** + * @covers ::add + */ + public function testAddWillPrioritizeCorrectly(): void + { + $stack = new MiddlewareStack(); + $middlewares = new \ReflectionProperty($stack, 'middlewares'); + $this->assertCount(0, $middlewares->getValue($stack)); + + $one = function (): void {}; + $two = function (): void {}; + $three = function (): void {}; + + $stack->add($three, 255); + $stack->add($two); // default + $stack->add($one, -255); + + $middlewareStack = $middlewares->getValue($stack); + $this->assertCount(3, $middlewareStack); + $this->assertSame($one, $middlewareStack[-255][0]); + $this->assertSame($two, $middlewareStack[0][0]); + $this->assertSame($three, $middlewareStack[255][0]); + } + + /** + * @covers ::next + */ + public function testNextReturnsMiddlewareIfClosure(): void + { + $stack = new MiddlewareStack(); + + $stack->add(function (): void {}); + + $this->assertInstanceOf(MiddlewareInterface::class, $stack->next()); + } + + /** + * @covers ::next + */ + public function testNextReturnsCorrectlyWhenMultipleMiddlewareHasSamePriority(): void + { + $stack = new MiddlewareStack(); + + $one = new class () implements MiddlewareInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $handler->handle($request); + } + }; + $two = new class () implements MiddlewareInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $handler->handle($request); + } + }; + $three = new class () implements MiddlewareInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $handler->handle($request); + } + }; + + + $stack->add($one); + $stack->add($two); + $stack->add($three); + + $this->assertSame($one, $stack->next()); + $this->assertSame($two, $stack->next()); + $this->assertSame($three, $stack->next()); + } + + /** + * @covers ::next + */ + public function testNextReturnsMiddlewareInCorrectOrder(): void + { + $stack = new MiddlewareStack(); + + $one = new class () implements MiddlewareInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $handler->handle($request); + } + }; + $two = new class () implements MiddlewareInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $handler->handle($request); + } + }; + $three = new class () implements MiddlewareInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $handler->handle($request); + } + }; + + + $stack->add($three, 255); + $stack->add($two); + $stack->add($one, -255); + + $this->assertSame($one, $stack->next()); + $this->assertSame($two, $stack->next()); + $this->assertSame($three, $stack->next()); + } +} diff --git a/src/SonsOfPHP/Component/HttpHandler/composer.json b/src/SonsOfPHP/Component/HttpHandler/composer.json new file mode 100644 index 00000000..6df3c765 --- /dev/null +++ b/src/SonsOfPHP/Component/HttpHandler/composer.json @@ -0,0 +1,63 @@ +{ + "name": "sonsofphp/http-handler", + "type": "library", + "description": "", + "keywords": [ + "psr15", + "psr-15", + "http-handler" + ], + "homepage": "https://github.com/SonsOfPHP/http-handler", + "license": "MIT", + "authors": [ + { + "name": "Joshua Estes", + "email": "joshua@sonsofphp.com" + } + ], + "support": { + "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", + "forum": "https://github.com/orgs/SonsOfPHP/discussions", + "docs": "https://docs.sonsofphp.com" + }, + "autoload": { + "psr-4": { + "SonsOfPHP\\Component\\HttpHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.1", + "sonsofphp/http-handler-contract": "0.3.x-dev", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "sonsofphp/http-message": "0.3.x-dev" + }, + "provide": { + "sonsofphp/http-handler-implementation": "0.3.x-dev", + "psr/http-server-handler-implementation": "^1.0", + "psr/http-server-middleware-implementation": "^1.0" + }, + "extra": { + "sort-packages": true, + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/JoshuaEstes" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" + } + ] +} \ No newline at end of file diff --git a/src/SonsOfPHP/Contract/HttpHandler/.gitattributes b/src/SonsOfPHP/Contract/HttpHandler/.gitattributes new file mode 100644 index 00000000..3a01b372 --- /dev/null +++ b/src/SonsOfPHP/Contract/HttpHandler/.gitattributes @@ -0,0 +1,2 @@ +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/SonsOfPHP/Contract/HttpHandler/.gitignore b/src/SonsOfPHP/Contract/HttpHandler/.gitignore new file mode 100644 index 00000000..d8a7996a --- /dev/null +++ b/src/SonsOfPHP/Contract/HttpHandler/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor/ diff --git a/src/SonsOfPHP/Contract/HttpHandler/HttpHandlerExceptionInterface.php b/src/SonsOfPHP/Contract/HttpHandler/HttpHandlerExceptionInterface.php new file mode 100644 index 00000000..076fbeee --- /dev/null +++ b/src/SonsOfPHP/Contract/HttpHandler/HttpHandlerExceptionInterface.php @@ -0,0 +1,10 @@ + + */ +interface HttpHandlerExceptionInterface {} diff --git a/src/SonsOfPHP/Contract/HttpHandler/LICENSE b/src/SonsOfPHP/Contract/HttpHandler/LICENSE new file mode 100644 index 00000000..39238382 --- /dev/null +++ b/src/SonsOfPHP/Contract/HttpHandler/LICENSE @@ -0,0 +1,19 @@ +Copyright 2022 to Present Joshua Estes + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/SonsOfPHP/Contract/HttpHandler/MiddlewareStackInterface.php b/src/SonsOfPHP/Contract/HttpHandler/MiddlewareStackInterface.php new file mode 100644 index 00000000..dda2ea9b --- /dev/null +++ b/src/SonsOfPHP/Contract/HttpHandler/MiddlewareStackInterface.php @@ -0,0 +1,21 @@ + + */ +interface MiddlewareStackInterface extends \Countable +{ + /** + * Returns the next Middleware in the stack + * + * @throws \SonsOfPHP\Contract\HttpHandler\HttpHandlerExceptionInterface + * When there is some type of error + */ + public function next(): MiddlewareInterface; +} diff --git a/src/SonsOfPHP/Contract/HttpHandler/README.md b/src/SonsOfPHP/Contract/HttpHandler/README.md new file mode 100644 index 00000000..f6f34ae3 --- /dev/null +++ b/src/SonsOfPHP/Contract/HttpHandler/README.md @@ -0,0 +1,18 @@ +Sons of PHP - HttpHandler Contract +================================== + +Expands on the PSR-15 interfaces + +## Learn More + +* [Documentation][docs] +* [Contributing][contributing] +* [Report Issues][issues] and [Submit Pull Requests][pull-requests] in the [Mother Repository][mother-repo] +* Get Help & Support using [Discussions][discussions] + +[discussions]: https://github.com/orgs/SonsOfPHP/discussions +[mother-repo]: https://github.com/SonsOfPHP/sonsofphp +[contributing]: https://docs.sonsofphp.com/contributing/ +[docs]: https://docs.sonsofphp.com/contracts/http-handler/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3AHttpHandler +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3AHttpHandler diff --git a/src/SonsOfPHP/Contract/HttpHandler/composer.json b/src/SonsOfPHP/Contract/HttpHandler/composer.json new file mode 100644 index 00000000..e4e91617 --- /dev/null +++ b/src/SonsOfPHP/Contract/HttpHandler/composer.json @@ -0,0 +1,54 @@ +{ + "name": "sonsofphp/http-handler-contract", + "type": "library", + "description": "", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "homepage": "https://github.com/SonsOfPHP/http-handler-contract", + "license": "MIT", + "authors": [ + { + "name": "Joshua Estes", + "email": "joshua@sonsofphp.com" + } + ], + "support": { + "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", + "forum": "https://github.com/orgs/SonsOfPHP/discussions", + "docs": "https://docs.sonsofphp.com" + }, + "autoload": { + "psr-4": { + "SonsOfPHP\\Contract\\HttpHandler\\": "" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.1", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "extra": { + "sort-packages": true, + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/JoshuaEstes" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" + } + ] +} \ No newline at end of file From 13da62883dc9f00a23fd151f06db7a9fe1678a42 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Thu, 7 Dec 2023 11:10:06 -0500 Subject: [PATCH 04/11] House Keeping (#188) ## Description Various updates and changes to codebase ## Checklist - [ ] Updated CHANGELOG files - [ ] Updated Documentation - [ ] Unit Tests Created - [ ] php-cs-fixer --- Makefile | 1 + composer.json | 4 +--- src/SonsOfPHP/Component/Cookie/CookieManager.php | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index e4ccb23e..845b2d15 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ purge: # Purge vendor and lock files rm -rf src/SonsOfPHP/Bundle/*/vendor/ src/SonsOfPHP/Bundle/*/composer.lock rm -rf src/SonsOfPHP/Component/*/vendor/ src/SonsOfPHP/Component/*/composer.lock rm -rf src/SonsOfPHP/Contract/*/vendor/ src/SonsOfPHP/Contract/*/composer.lock + rm -rf src/tools/*/vendor/ src/tools/*/composer.lock test: phpunit ## Run PHPUnit Tests diff --git a/composer.json b/composer.json index 7b41fd73..63f6baba 100644 --- a/composer.json +++ b/composer.json @@ -80,11 +80,9 @@ "ext-intl": "*", "doctrine/collections": "^2", "doctrine/orm": "^2", - "sonsofphp/cookie-contract": "0.3.x-dev", "psr/container": "^1.0 || ^2.0", "psr/http-server-handler": "^1.0", - "psr/http-server-middleware": "^1.0", - "sonsofphp/http-handler-contract": "0.3.x-dev" + "psr/http-server-middleware": "^1.0" }, "replace": { "sonsofphp/bard": "self.version", diff --git a/src/SonsOfPHP/Component/Cookie/CookieManager.php b/src/SonsOfPHP/Component/Cookie/CookieManager.php index 1357bf85..8b6b4ff1 100644 --- a/src/SonsOfPHP/Component/Cookie/CookieManager.php +++ b/src/SonsOfPHP/Component/Cookie/CookieManager.php @@ -29,7 +29,7 @@ public function get(string $name): CookieInterface /** * {@inheritdoc} */ - public function has(string $name): CookieInterface + public function has(string $name): bool { return array_key_exists($name, $_COOKIE); } From e2180133f94da55291ee888763c68ee88130d879 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Thu, 7 Dec 2023 12:42:43 -0500 Subject: [PATCH 05/11] [Mailer] New Component and Contract (#189) ## Description ## Checklist - [ ] Updated CHANGELOG files - [ ] Updated Documentation - [ ] Unit Tests Created - [ ] php-cs-fixer --- .github/CODEOWNERS | 1 + .github/labeler.yml | 4 ++ bard.json | 8 +++ composer.json | 15 ++++-- docs/components/mailer/index.md | 11 ++++ docs/contracts/mailer/index.md | 9 ++++ mkdocs.yml | 2 + src/SonsOfPHP/Component/Mailer/.gitattributes | 4 ++ src/SonsOfPHP/Component/Mailer/.gitignore | 3 ++ src/SonsOfPHP/Component/Mailer/LICENSE | 19 +++++++ src/SonsOfPHP/Component/Mailer/README.md | 16 ++++++ src/SonsOfPHP/Component/Mailer/composer.json | 54 +++++++++++++++++++ src/SonsOfPHP/Contract/Mailer/.gitattributes | 2 + src/SonsOfPHP/Contract/Mailer/.gitignore | 2 + src/SonsOfPHP/Contract/Mailer/LICENSE | 19 +++++++ src/SonsOfPHP/Contract/Mailer/README.md | 16 ++++++ src/SonsOfPHP/Contract/Mailer/composer.json | 52 ++++++++++++++++++ 17 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 docs/components/mailer/index.md create mode 100644 docs/contracts/mailer/index.md create mode 100644 src/SonsOfPHP/Component/Mailer/.gitattributes create mode 100644 src/SonsOfPHP/Component/Mailer/.gitignore create mode 100644 src/SonsOfPHP/Component/Mailer/LICENSE create mode 100644 src/SonsOfPHP/Component/Mailer/README.md create mode 100644 src/SonsOfPHP/Component/Mailer/composer.json create mode 100644 src/SonsOfPHP/Contract/Mailer/.gitattributes create mode 100644 src/SonsOfPHP/Contract/Mailer/.gitignore create mode 100644 src/SonsOfPHP/Contract/Mailer/LICENSE create mode 100644 src/SonsOfPHP/Contract/Mailer/README.md create mode 100644 src/SonsOfPHP/Contract/Mailer/composer.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 27a3cc09..47caa7eb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,6 +23,7 @@ docs/ @JoshuaEstes /src/SonsOfPHP/**/Json @JoshuaEstes /src/SonsOfPHP/**/Link @JoshuaEstes /src/SonsOfPHP/**/Logger @JoshuaEstes +/src/SonsOfPHP/**/Mailer @JoshuaEstes /src/SonsOfPHP/**/Money @JoshuaEstes /src/SonsOfPHP/**/Pager @JoshuaEstes /src/SonsOfPHP/**/Version @JoshuaEstes diff --git a/.github/labeler.yml b/.github/labeler.yml index d666764d..e0ece348 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -66,6 +66,10 @@ Logger: - docs/components/logger/* - src/SonsOfPHP/**/Logger/* +Mailer: + - docs/components/mailer/* + - src/SonsOfPHP/**/Mailer/* + Money: - docs/components/money/* - src/SonsOfPHP/**/Money/* diff --git a/bard.json b/bard.json index 2a13bfc4..d70c02bc 100644 --- a/bard.json +++ b/bard.json @@ -5,6 +5,14 @@ "path": "src/SonsOfPHP/Bard", "repository": "git@github.com:SonsOfPHP/bard.git" }, + { + "path": "src/SonsOfPHP/Contract/Mailer", + "repository": "git@github.com:SonsOfPHP/mailer-contract.git" + }, + { + "path": "src/SonsOfPHP/Component/Mailer", + "repository": "git@github.com:SonsOfPHP/mailer.git" + }, { "path": "src/SonsOfPHP/Component/Cache", "repository": "git@github.com:SonsOfPHP/cache.git" diff --git a/composer.json b/composer.json index 63f6baba..cf9d1765 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,8 @@ "psr/container-implementation": "^1.0 || ^2.0", "psr/http-server-handler-implementation": "^1.0", "psr/http-server-middleware-implementation": "^1.0", - "sonsofphp/http-handler-implementation": "0.3.x-dev" + "sonsofphp/http-handler-implementation": "0.3.x-dev", + "sonsofphp/mailer-implementation": "0.3.x-dev" }, "require": { "php": ">=8.1", @@ -123,11 +124,15 @@ "sonsofphp/cookie-contract": "self.version", "sonsofphp/container": "self.version", "sonsofphp/http-handler": "self.version", - "sonsofphp/http-handler-contract": "self.version" + "sonsofphp/http-handler-contract": "self.version", + "sonsofphp/mailer-contract": "self.version", + "sonsofphp/mailer": "self.version" }, "autoload": { "psr-4": { "SonsOfPHP\\Bard\\": "src/SonsOfPHP/Bard/src", + "SonsOfPHP\\Contract\\Mailer\\": "src/SonsOfPHP/Contract/Mailer", + "SonsOfPHP\\Component\\Mailer\\": "src/SonsOfPHP/Component/Mailer", "SonsOfPHP\\Component\\Cache\\": "src/SonsOfPHP/Component/Cache", "SonsOfPHP\\Component\\Clock\\": "src/SonsOfPHP/Component/Clock", "SonsOfPHP\\Component\\Container\\": "src/SonsOfPHP/Component/Container", @@ -168,6 +173,7 @@ }, "exclude-from-classmap": [ "src/SonsOfPHP/Bard/Tests", + "src/SonsOfPHP/Component/Mailer/Tests", "src/SonsOfPHP/Component/Cache/Tests", "src/SonsOfPHP/Component/Clock/Tests", "src/SonsOfPHP/Component/Container/Tests", @@ -209,8 +215,7 @@ "symfony/error-handler": "^6", "symfony/messenger": "^5 || ^6", "phpunit/phpunit": "^10.4", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "sonsofphp/http-message": "0.3.x-dev" + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "autoload-dev": { "psr-4": { @@ -219,4 +224,4 @@ "SonsOfPHP\\Bridge\\Symfony\\Cqrs\\Tests\\": "src/SonsOfPHP/Bridge/Symfony/Cqrs/Tests" } } -} \ No newline at end of file +} diff --git a/docs/components/mailer/index.md b/docs/components/mailer/index.md new file mode 100644 index 00000000..b1b1efc0 --- /dev/null +++ b/docs/components/mailer/index.md @@ -0,0 +1,11 @@ +--- +title: Mailer +--- + +Simple PHP Mailer + +## Installation + +```shell +composer require sonsofphp/mailer +``` diff --git a/docs/contracts/mailer/index.md b/docs/contracts/mailer/index.md new file mode 100644 index 00000000..dff51e6e --- /dev/null +++ b/docs/contracts/mailer/index.md @@ -0,0 +1,9 @@ +--- +title: Mailer Contract +--- + +## Installation + +```shell +composer require sonsofphp/mailer-contract +``` diff --git a/mkdocs.yml b/mkdocs.yml index 193d6276..74519622 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -111,6 +111,7 @@ nav: - JSON: components/json/index.md - Link: components/link/index.md - Logger: components/logger/index.md + - Mailer: components/mailer/index.md - Money: - Overview: components/money/index.md - Currency Providers: components/money/currency-providers.md @@ -123,4 +124,5 @@ nav: - Common: contracts/common/index.md - Cookie: contracts/cookie/index.md - Cqrs: contracts/cqrs/index.md + - Mailer: contracts/mailer/index.md - Pager: contracts/pager/index.md diff --git a/src/SonsOfPHP/Component/Mailer/.gitattributes b/src/SonsOfPHP/Component/Mailer/.gitattributes new file mode 100644 index 00000000..84c7add0 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/SonsOfPHP/Component/Mailer/.gitignore b/src/SonsOfPHP/Component/Mailer/.gitignore new file mode 100644 index 00000000..5414c2c6 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/SonsOfPHP/Component/Mailer/LICENSE b/src/SonsOfPHP/Component/Mailer/LICENSE new file mode 100644 index 00000000..39238382 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/LICENSE @@ -0,0 +1,19 @@ +Copyright 2022 to Present Joshua Estes + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/SonsOfPHP/Component/Mailer/README.md b/src/SonsOfPHP/Component/Mailer/README.md new file mode 100644 index 00000000..4662eb3f --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/README.md @@ -0,0 +1,16 @@ +Sons of PHP - Mailer +==================== + +## Learn More + +* [Documentation][docs] +* [Contributing][contributing] +* [Report Issues][issues] and [Submit Pull Requests][pull-requests] in the [Mother Repository][mother-repo] +* Get Help & Support using [Discussions][discussions] + +[discussions]: https://github.com/orgs/SonsOfPHP/discussions +[mother-repo]: https://github.com/SonsOfPHP/sonsofphp +[contributing]: https://docs.sonsofphp.com/contributing/ +[docs]: https://docs.sonsofphp.com/components/mailer/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3AMailer +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3AMailer diff --git a/src/SonsOfPHP/Component/Mailer/composer.json b/src/SonsOfPHP/Component/Mailer/composer.json new file mode 100644 index 00000000..477553d9 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/composer.json @@ -0,0 +1,54 @@ +{ + "name": "sonsofphp/mailer", + "type": "library", + "description": "", + "keywords": [ + "mailer" + ], + "homepage": "https://github.com/SonsOfPHP/mailer", + "license": "MIT", + "authors": [ + { + "name": "Joshua Estes", + "email": "joshua@sonsofphp.com" + } + ], + "support": { + "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", + "forum": "https://github.com/orgs/SonsOfPHP/discussions", + "docs": "https://docs.sonsofphp.com" + }, + "autoload": { + "psr-4": { + "SonsOfPHP\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.1", + "sonsofphp/mailer-contract": "0.3.x-dev" + }, + "provide": { + "sonsofphp/mailer-implementation": "0.3.x-dev" + }, + "extra": { + "sort-packages": true, + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/JoshuaEstes" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" + } + ] +} \ No newline at end of file diff --git a/src/SonsOfPHP/Contract/Mailer/.gitattributes b/src/SonsOfPHP/Contract/Mailer/.gitattributes new file mode 100644 index 00000000..3a01b372 --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/.gitattributes @@ -0,0 +1,2 @@ +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/SonsOfPHP/Contract/Mailer/.gitignore b/src/SonsOfPHP/Contract/Mailer/.gitignore new file mode 100644 index 00000000..d8a7996a --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor/ diff --git a/src/SonsOfPHP/Contract/Mailer/LICENSE b/src/SonsOfPHP/Contract/Mailer/LICENSE new file mode 100644 index 00000000..39238382 --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/LICENSE @@ -0,0 +1,19 @@ +Copyright 2022 to Present Joshua Estes + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/SonsOfPHP/Contract/Mailer/README.md b/src/SonsOfPHP/Contract/Mailer/README.md new file mode 100644 index 00000000..db89e21a --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/README.md @@ -0,0 +1,16 @@ +Sons of PHP - Mailer Contract +============================= + +## Learn More + +* [Documentation][docs] +* [Contributing][contributing] +* [Report Issues][issues] and [Submit Pull Requests][pull-requests] in the [Mother Repository][mother-repo] +* Get Help & Support using [Discussions][discussions] + +[discussions]: https://github.com/orgs/SonsOfPHP/discussions +[mother-repo]: https://github.com/SonsOfPHP/sonsofphp +[contributing]: https://docs.sonsofphp.com/contributing/ +[docs]: https://docs.sonsofphp.com/contracts/mailer/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3AMailer +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3AMailer diff --git a/src/SonsOfPHP/Contract/Mailer/composer.json b/src/SonsOfPHP/Contract/Mailer/composer.json new file mode 100644 index 00000000..1bff7fec --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/composer.json @@ -0,0 +1,52 @@ +{ + "name": "sonsofphp/mailer-contract", + "type": "library", + "description": "", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "homepage": "https://github.com/SonsOfPHP/mailer-contract", + "license": "MIT", + "authors": [ + { + "name": "Joshua Estes", + "email": "joshua@sonsofphp.com" + } + ], + "support": { + "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", + "forum": "https://github.com/orgs/SonsOfPHP/discussions", + "docs": "https://docs.sonsofphp.com" + }, + "autoload": { + "psr-4": { + "SonsOfPHP\\Contract\\Mailer\\": "" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.1" + }, + "extra": { + "sort-packages": true, + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/JoshuaEstes" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" + } + ] +} \ No newline at end of file From 1fd79bbe367ba4e5175ef734f362273595656de0 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 8 Dec 2023 13:56:25 -0500 Subject: [PATCH 06/11] [Mailer] New Component and Contract (#190) ## Description Closes #65 ## Checklist - [x] Updated CHANGELOG files - [x] Updated Documentation - [x] Unit Tests Created - [x] php-cs-fixer --- CHANGELOG.md | 1 + Makefile | 6 + docs/components/mailer/index.md | 35 ++++++ docs/contracts/mailer/index.md | 9 ++ phpunit.xml.dist | 4 + src/SonsOfPHP/Component/Mailer/Address.php | 78 +++++++++++++ src/SonsOfPHP/Component/Mailer/Mailer.php | 34 ++++++ src/SonsOfPHP/Component/Mailer/Message.php | 107 ++++++++++++++++++ .../Component/Mailer/MiddlewareHandler.php | 38 +++++++ .../Component/Mailer/MiddlewareStack.php | 31 +++++ .../Component/Mailer/Tests/AddressTest.php | 77 +++++++++++++ .../Component/Mailer/Tests/MailerTest.php | 52 +++++++++ .../Component/Mailer/Tests/MessageTest.php | 96 ++++++++++++++++ .../Mailer/Tests/MiddlewareHandlerTest.php | 81 +++++++++++++ .../Mailer/Tests/MiddlewareStackTest.php | 84 ++++++++++++++ .../Mailer/Transport/NativeMailTransport.php | 27 +++++ .../Mailer/Transport/NullTransport.php | 22 ++++ .../Contract/Mailer/AddressInterface.php | 45 ++++++++ .../Mailer/MailerExceptionInterface.php | 10 ++ .../Contract/Mailer/MailerInterface.php | 15 +++ .../Contract/Mailer/MessageInterface.php | 54 +++++++++ .../Mailer/MiddlewareHandlerInterface.php | 13 +++ .../Contract/Mailer/MiddlewareInterface.php | 13 +++ .../Mailer/MiddlewareStackInterface.php | 16 +++ .../Contract/Mailer/TransportInterface.php | 19 ++++ 25 files changed, 967 insertions(+) create mode 100644 src/SonsOfPHP/Component/Mailer/Address.php create mode 100644 src/SonsOfPHP/Component/Mailer/Mailer.php create mode 100644 src/SonsOfPHP/Component/Mailer/Message.php create mode 100644 src/SonsOfPHP/Component/Mailer/MiddlewareHandler.php create mode 100644 src/SonsOfPHP/Component/Mailer/MiddlewareStack.php create mode 100644 src/SonsOfPHP/Component/Mailer/Tests/AddressTest.php create mode 100644 src/SonsOfPHP/Component/Mailer/Tests/MailerTest.php create mode 100644 src/SonsOfPHP/Component/Mailer/Tests/MessageTest.php create mode 100644 src/SonsOfPHP/Component/Mailer/Tests/MiddlewareHandlerTest.php create mode 100644 src/SonsOfPHP/Component/Mailer/Tests/MiddlewareStackTest.php create mode 100644 src/SonsOfPHP/Component/Mailer/Transport/NativeMailTransport.php create mode 100644 src/SonsOfPHP/Component/Mailer/Transport/NullTransport.php create mode 100644 src/SonsOfPHP/Contract/Mailer/AddressInterface.php create mode 100644 src/SonsOfPHP/Contract/Mailer/MailerExceptionInterface.php create mode 100644 src/SonsOfPHP/Contract/Mailer/MailerInterface.php create mode 100644 src/SonsOfPHP/Contract/Mailer/MessageInterface.php create mode 100644 src/SonsOfPHP/Contract/Mailer/MiddlewareHandlerInterface.php create mode 100644 src/SonsOfPHP/Contract/Mailer/MiddlewareInterface.php create mode 100644 src/SonsOfPHP/Contract/Mailer/MiddlewareStackInterface.php create mode 100644 src/SonsOfPHP/Contract/Mailer/TransportInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b2981f..691a6733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ To get the diff between two versions, go to https://github.com/SonsOfPHP/sonsofp * [PR #181](https://github.com/SonsOfPHP/sonsofphp/pull/181) [Cookie] New Component and Contract * [PR #182](https://github.com/SonsOfPHP/sonsofphp/pull/182) [Container] New Component (PSR-11) * [PR #187](https://github.com/SonsOfPHP/sonsofphp/pull/187) [HttpHandler] New Component (PSR-15) and Contract +* [PR #190](https://github.com/SonsOfPHP/sonsofphp/pull/190) [Mailer] New Component and Contract ## [0.3.8] diff --git a/Makefile b/Makefile index 845b2d15..f3fdb73b 100644 --- a/Makefile +++ b/Makefile @@ -74,6 +74,9 @@ test-link: phpunit test-logger: PHPUNIT_TESTSUITE=logger test-logger: phpunit +test-mailer: PHPUNIT_TESTSUITE=mailer +test-mailer: phpunit + test-money: PHPUNIT_TESTSUITE=money test-money: phpunit @@ -151,6 +154,9 @@ coverage-link: coverage coverage-logger: PHPUNIT_TESTSUITE=logger coverage-logger: coverage +coverage-mailer: PHPUNIT_TESTSUITE=mailer +coverage-mailer: coverage + coverage-money: PHPUNIT_TESTSUITE=money coverage-money: coverage diff --git a/docs/components/mailer/index.md b/docs/components/mailer/index.md index b1b1efc0..6c134bdd 100644 --- a/docs/components/mailer/index.md +++ b/docs/components/mailer/index.md @@ -9,3 +9,38 @@ Simple PHP Mailer ```shell composer require sonsofphp/mailer ``` + +## Usage + +```php +setTo('joshua@sonsofphp.com') + ->setFrom('From', 'joshua@sonsofphp.com') + ->setSubject('Subject', 'Test Subject') + ->setBody($body) +; + +$mailer = new Mailer(new NullTransport()); +$mailer->send($message); +``` + +### Middleware + +The `Mailer` class supports various middleware as well. + +```php +addMiddleware($middleware); +``` diff --git a/docs/contracts/mailer/index.md b/docs/contracts/mailer/index.md index dff51e6e..40eeb45d 100644 --- a/docs/contracts/mailer/index.md +++ b/docs/contracts/mailer/index.md @@ -7,3 +7,12 @@ title: Mailer Contract ```shell composer require sonsofphp/mailer-contract ``` + +## Definitions + +- **Message** - The email message that will be sent. +- **Mailer** - Primary API used to send *messages*. +- **Transport** - Transports are what will actually send the message. This could + be SMTP, Null, SendGrid, Amazon SES, or anything like that. +- **Middleware** - Before the message is sent to the transport to be sent, + various middlewares have the ability to modify the message. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c0695fe4..544af877 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -84,6 +84,10 @@ src/SonsOfPHP/Component/Logger/Tests + + src/SonsOfPHP/Component/Mailer/Tests + + src/SonsOfPHP/Bridge/*/Money/Tests src/SonsOfPHP/Component/Money/Tests diff --git a/src/SonsOfPHP/Component/Mailer/Address.php b/src/SonsOfPHP/Component/Mailer/Address.php new file mode 100644 index 00000000..00e7542d --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Address.php @@ -0,0 +1,78 @@ + + */ +final class Address implements AddressInterface +{ + public function __construct(private string $email, private ?string $name = null) {} + + public function __toString(): string + { + if (null === $this->name) { + return $this->email; + } + + return sprintf('%s <%s>', $this->name, $this->email); + } + + /** + * {@inheritdoc} + */ + public static function from(string $address): self + { + return new self($address); + } + + /** + * {@inheritdoc} + */ + public function getEmail(): string + { + return $this->email; + } + + /** + * {@inheritdoc} + */ + public function withEmail(string $email): static + { + if ($email === $this->email) { + return $this; + } + + $that = clone $this; + $that->email = $email; + + return $that; + } + + /** + * {@inheritdoc} + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * {@inheritdoc} + */ + public function withName(?string $name): static + { + if ($name === $this->name) { + return $this; + } + + $that = clone $this; + $that->name = $name; + + return $that; + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Mailer.php b/src/SonsOfPHP/Component/Mailer/Mailer.php new file mode 100644 index 00000000..df56acbb --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Mailer.php @@ -0,0 +1,34 @@ + + */ +final class Mailer implements MailerInterface +{ + public function __construct( + private TransportInterface $transport, + private MiddlewareHandlerInterface $handler = new MiddlewareHandler(), + ) {} + + public function addMiddleware(MiddlewareInterface $middleware): void + { + $this->handler->getMiddlewareStack()->add($middleware); + } + + public function send(MessageInterface $message): void + { + $message = $this->handler->handle($message); + + $this->transport->send($message); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Message.php b/src/SonsOfPHP/Component/Mailer/Message.php new file mode 100644 index 00000000..a8e617fa --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Message.php @@ -0,0 +1,107 @@ + + */ +class Message implements MessageInterface +{ + private array $headers = []; + private string $body; + + public function setSubject(string $subject): self + { + $this->addHeader('subject', $subject); + } + + public function getSubject(): ?string + { + return $this->getHeader('subject'); + } + + public function setFrom(AddressInterface|string $address): self + { + $this->addHeader('from', $address); + } + + public function getFrom(): ?string + { + return $this->getHeader('from'); + } + + public function setTo(AddressInterface|string $address): self + { + $this->addHeader('to', $address); + } + + public function getTo(): ?string + { + return $this->getHeader('to'); + } + + /** + * {@inheritdoc} + */ + public function getBody(): ?string + { + return $this->body ?? null; + } + + /** + * {@inheritdoc} + */ + public function setBody(string $body): self + { + $this->body = $body; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * {@inheritdoc} + */ + public function getHeader(string $name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } + + /** + * {@inheritdoc} + */ + public function hasHeader(string $name): bool + { + return array_key_exists(strtolower($name), $this->headers); + } + + /** + * {@inheritdoc} + */ + public function addHeader(string $name, AddressInterface|string $value): self + { + if ($value instanceof AddressInterface) { + $value = (string) $value; + } + + if ($this->hasHeader($name)) { + $value = $this->headers[strtolower($name)] . ', ' . $value; + } + + $this->headers[strtolower($name)] = $value; + + return $this; + } +} diff --git a/src/SonsOfPHP/Component/Mailer/MiddlewareHandler.php b/src/SonsOfPHP/Component/Mailer/MiddlewareHandler.php new file mode 100644 index 00000000..21c96cbe --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/MiddlewareHandler.php @@ -0,0 +1,38 @@ + + */ +class MiddlewareHandler implements MiddlewareHandlerInterface +{ + public function __construct( + private MiddlewareStackInterface $stack = new MiddlewareStack() + ) {} + + public function getMiddlewareStack(): MiddlewareStackInterface + { + return $this->stack; + } + + /** + * {@inheritdoc} + */ + public function handle(MessageInterface $message): MessageInterface + { + if (0 === count($this->stack)) { + return $message; + } + + $next = $this->stack->next(); + + return $next($message, $this); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/MiddlewareStack.php b/src/SonsOfPHP/Component/Mailer/MiddlewareStack.php new file mode 100644 index 00000000..7a932966 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/MiddlewareStack.php @@ -0,0 +1,31 @@ + + */ +class MiddlewareStack implements MiddlewareStackInterface, \Countable +{ + private array $middlewares = []; + + public function add(MiddlewareInterface $middleware): void + { + $this->middlewares[] = $middleware; + } + + public function next(): MiddlewareInterface + { + return array_shift($this->middlewares); + } + + public function count(): int + { + return count($this->middlewares); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Tests/AddressTest.php b/src/SonsOfPHP/Component/Mailer/Tests/AddressTest.php new file mode 100644 index 00000000..523a8310 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Tests/AddressTest.php @@ -0,0 +1,77 @@ +assertInstanceOf(AddressInterface::class, $address); + } + + /** + * @covers ::getEmail + * @covers ::withEmail + */ + public function testEmail(): void + { + $address = new Address('joshua@sonsofphp.com'); + + $this->assertSame('joshua@sonsofphp.com', $address->getEmail()); + + $this->assertSame($address, $address->withEmail('joshua@sonsofphp.com')); + $this->assertNotSame($address, $address->withEmail('joshua.estes@sonsofphp.com')); + } + + /** + * @covers ::getName + * @covers ::withName + */ + public function testName(): void + { + $address = new Address('joshua@sonsofphp.com'); + + $this->assertNull($address->getName()); + + $this->assertNotSame($address, $address->withName('Joshua')); + + $address = $address->withName('Joshua'); + $this->assertSame('Joshua', $address->getName()); + } + + /** + * @covers ::__toString + */ + public function testToStringMagicMethod(): void + { + $this->assertSame('joshua@sonsofphp.com', (string) new Address('joshua@sonsofphp.com')); + $this->assertSame('Joshua Estes ', (string) new Address('joshua@sonsofphp.com', 'Joshua Estes')); + } + + /** + * @covers ::from + */ + public function testFrom(): void + { + // Would be best to use a data provider here + $address = Address::from('joshua@sonsofphp.com'); + + $this->assertSame('joshua@sonsofphp.com', $address->getEmail()); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Tests/MailerTest.php b/src/SonsOfPHP/Component/Mailer/Tests/MailerTest.php new file mode 100644 index 00000000..5017423a --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Tests/MailerTest.php @@ -0,0 +1,52 @@ +transport = $this->createMock(TransportInterface::class); + $this->message = $this->createMock(MessageInterface::class); + } + + /** + * @covers ::__construct + */ + public function testItHasTheCorrectInterface(): void + { + $mailer = new Mailer($this->transport); + + $this->assertInstanceOf(MailerInterface::class, $mailer); + } + + /** + * @covers ::send + */ + public function testSend(): void + { + $this->transport->expects($this->once())->method('send'); + + $mailer = new Mailer($this->transport); + + $mailer->send($this->message); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Tests/MessageTest.php b/src/SonsOfPHP/Component/Mailer/Tests/MessageTest.php new file mode 100644 index 00000000..6a6a6a6b --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Tests/MessageTest.php @@ -0,0 +1,96 @@ +assertInstanceOf(MessageInterface::class, $message); + } + + /** + * @covers ::setBody + */ + public function testSetBody(): void + { + $message = new Message(); + $message->setBody('body'); + $this->assertSame('body', $message->getBody()); + } + + /** + * @covers ::getBody + */ + public function testGetBody(): void + { + $message = new Message(); + $this->assertNull($message->getBody()); + + $message->setBody('body'); + $this->assertSame('body', $message->getBody()); + } + + /** + * @covers ::getHeaders + */ + public function testGetHeadersWhenEmpty(): void + { + $message = new Message(); + $this->assertCount(0, $message->getHeaders()); + } + + /** + * @covers ::getHeader + */ + public function testGetHeader(): void + { + $message = new Message(); + $this->assertNull($message->getHeader('to')); + + $message->addHeader('to', 'joshua@sonsofphp.com'); + $this->assertSame('joshua@sonsofphp.com', $message->getHeader('to')); + } + + /** + * @covers ::hasHeader + */ + public function testHasHeader(): void + { + $message = new Message(); + $this->assertFalse($message->hasHeader('to')); + + $message->addHeader('to', 'joshua@sonsofphp.com'); + $this->assertTrue($message->hasHeader('TO')); + } + + /** + * @covers ::addHeader + */ + public function testAddHeader(): void + { + $message = new Message(); + + $message->addHeader('To', 'joshua@sonsofphp.com'); + $this->assertSame('joshua@sonsofphp.com', $message->getHeader('to')); + + $message->addHeader('To', 'joshua.estes@sonsofphp.com'); + $this->assertSame('joshua@sonsofphp.com, joshua.estes@sonsofphp.com', $message->getHeader('to')); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Tests/MiddlewareHandlerTest.php b/src/SonsOfPHP/Component/Mailer/Tests/MiddlewareHandlerTest.php new file mode 100644 index 00000000..e5042be0 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Tests/MiddlewareHandlerTest.php @@ -0,0 +1,81 @@ +message = $this->createMock(MessageInterface::class); + $this->stack = new MiddlewareStack(); + } + + /** + * @covers ::__construct + */ + public function testItHasTheCorrectInterface(): void + { + $handler = new MiddlewareHandler(); + + $this->assertInstanceOf(MiddlewareHandlerInterface::class, $handler); + } + + /** + * @covers ::getMiddlewareStack + */ + public function testGetMiddlewareStack(): void + { + $handler = new MiddlewareHandler(); + $this->assertInstanceOf(MiddlewareStackInterface::class, $handler->getMiddlewareStack()); + + $handler = new MiddlewareHandler($this->stack); + $this->assertSame($this->stack, $handler->getMiddlewareStack()); + } + + /** + * @covers ::handle + */ + public function testHandleWhenNoMoreMiddleware(): void + { + $handler = new MiddlewareHandler(); + + $this->assertSame($this->message, $handler->handle($this->message)); + } + + /** + * @covers ::handle + */ + public function testHandle(): void + { + $middleware = new class () implements MiddlewareInterface { + public function __invoke(MessageInterface $message, MiddlewareHandlerInterface $handler) + { + return $message; + } + }; + + $handler = new MiddlewareHandler($this->stack); + $handler->getMiddlewareStack()->add($middleware); + + $this->assertSame($this->message, $handler->handle($this->message)); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Tests/MiddlewareStackTest.php b/src/SonsOfPHP/Component/Mailer/Tests/MiddlewareStackTest.php new file mode 100644 index 00000000..fa904845 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Tests/MiddlewareStackTest.php @@ -0,0 +1,84 @@ +assertInstanceOf(MiddlewareStackInterface::class, $stack); + } + + /** + * @covers ::add + */ + public function testAdd(): void + { + $middleware = new class () implements MiddlewareInterface { + public function __invoke(MessageInterface $message, MiddlewareHandlerInterface $handler) + { + return $message; + } + }; + $property = new \ReflectionProperty(MiddlewareStack::class, 'middlewares'); + + $stack = new MiddlewareStack(); + + $this->assertCount(0, $property->getValue($stack)); + $stack->add($middleware); + $this->assertCount(1, $property->getValue($stack)); + } + + /** + * @covers ::next + */ + public function testNext(): void + { + $middleware = new class () implements MiddlewareInterface { + public function __invoke(MessageInterface $message, MiddlewareHandlerInterface $handler) + { + return $message; + } + }; + $stack = new MiddlewareStack(); + $stack->add($middleware); + + $this->assertSame($middleware, $stack->next()); + } + + /** + * @covers ::count + */ + public function testCount(): void + { + $middleware = new class () implements MiddlewareInterface { + public function __invoke(MessageInterface $message, MiddlewareHandlerInterface $handler) + { + return $message; + } + }; + $stack = new MiddlewareStack(); + $stack->add($middleware); + + $this->assertCount(1, $stack); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Transport/NativeMailTransport.php b/src/SonsOfPHP/Component/Mailer/Transport/NativeMailTransport.php new file mode 100644 index 00000000..aa6244e2 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Transport/NativeMailTransport.php @@ -0,0 +1,27 @@ + + */ +class NativeMailTransport implements TransportInterface +{ + /** + * {@inheritdoc} + */ + public function send(MessageInterface $message): void + { + mail($message->getHeader('to'), $message->getHeader('subject'), $message->getBody(), $message->getHeaders()); + } +} diff --git a/src/SonsOfPHP/Component/Mailer/Transport/NullTransport.php b/src/SonsOfPHP/Component/Mailer/Transport/NullTransport.php new file mode 100644 index 00000000..1109c305 --- /dev/null +++ b/src/SonsOfPHP/Component/Mailer/Transport/NullTransport.php @@ -0,0 +1,22 @@ + + */ +class NullTransport implements TransportInterface +{ + /** + * {@inheritdoc} + */ + public function send(MessageInterface $message): void {} +} diff --git a/src/SonsOfPHP/Contract/Mailer/AddressInterface.php b/src/SonsOfPHP/Contract/Mailer/AddressInterface.php new file mode 100644 index 00000000..7717cf65 --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/AddressInterface.php @@ -0,0 +1,45 @@ +" + * + * @author Joshua Estes + */ +interface AddressInterface extends \Stringable +{ + /** + * @throws InvalidArgumentException + * If the $address is invalid, for example if the string does not contain + * an email address + */ + public static function from(string $address): self; + + /** + * This MUST return the email address ONLY + */ + public function getEmail(): string; + + /** + * Returns a new instance of the class if $email is different, if $email is + * the same, this MUST return the same instance. + * + * @throws \InvalidArgumentException + */ + public function withEmail(string $email): static; + + /** + * Just will return null if no name was set + * + * If a name was set, this will return just the name, ie "Joshua Estes" + */ + public function getName(): ?string; + + /** + * @throws \InvalidArgumentException + */ + public function withName(?string $name): static; +} diff --git a/src/SonsOfPHP/Contract/Mailer/MailerExceptionInterface.php b/src/SonsOfPHP/Contract/Mailer/MailerExceptionInterface.php new file mode 100644 index 00000000..f3fce5f8 --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/MailerExceptionInterface.php @@ -0,0 +1,10 @@ + + */ +interface MailerExceptionInterface {} diff --git a/src/SonsOfPHP/Contract/Mailer/MailerInterface.php b/src/SonsOfPHP/Contract/Mailer/MailerInterface.php new file mode 100644 index 00000000..39bf4577 --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/MailerInterface.php @@ -0,0 +1,15 @@ + + */ +interface MailerInterface +{ + public function send(MessageInterface $message); +} diff --git a/src/SonsOfPHP/Contract/Mailer/MessageInterface.php b/src/SonsOfPHP/Contract/Mailer/MessageInterface.php new file mode 100644 index 00000000..d070574a --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/MessageInterface.php @@ -0,0 +1,54 @@ + + */ +interface MessageInterface +{ + /** + * Returns the contents of the message + * + * If there has been no body set, this will return null + */ + public function getBody(): ?string; + + /** + * This will set the body contents, if there is already a body, this will + * overwrite that content + */ + public function setBody(string $body): self; + + /** + * Returns headers in key => value format + */ + public function getHeaders(): array; + + /** + * If the header is not found or has no value, this will return null + * + * When getting a header, the header name MUST NOT be case sensitive + * + * Example: + * $from = $message->getHeader('From'); + */ + public function getHeader(string $name): ?string; + + /** + * If a header is set, this will return true + */ + public function hasHeader(string $name): bool; + + /** + * If the header already exists, this will append the new value + * + * If the header does not already exist, this will add it + * + * @throws \InvalidArgumentException + * If name or value is invalid + */ + public function addHeader(string $name, AddressInterface|string $value): self; +} diff --git a/src/SonsOfPHP/Contract/Mailer/MiddlewareHandlerInterface.php b/src/SonsOfPHP/Contract/Mailer/MiddlewareHandlerInterface.php new file mode 100644 index 00000000..e6e70109 --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/MiddlewareHandlerInterface.php @@ -0,0 +1,13 @@ + + */ +interface MiddlewareHandlerInterface +{ + public function handle(MessageInterface $message): MessageInterface; +} diff --git a/src/SonsOfPHP/Contract/Mailer/MiddlewareInterface.php b/src/SonsOfPHP/Contract/Mailer/MiddlewareInterface.php new file mode 100644 index 00000000..3409778c --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/MiddlewareInterface.php @@ -0,0 +1,13 @@ + + */ +interface MiddlewareInterface +{ + public function __invoke(MessageInterface $message, MiddlewareHandlerInterface $handler); +} diff --git a/src/SonsOfPHP/Contract/Mailer/MiddlewareStackInterface.php b/src/SonsOfPHP/Contract/Mailer/MiddlewareStackInterface.php new file mode 100644 index 00000000..e7ee01ea --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/MiddlewareStackInterface.php @@ -0,0 +1,16 @@ + + */ +interface MiddlewareStackInterface +{ + /** + * @throw MailerExceptionInterface + */ + public function next(): MiddlewareInterface; +} diff --git a/src/SonsOfPHP/Contract/Mailer/TransportInterface.php b/src/SonsOfPHP/Contract/Mailer/TransportInterface.php new file mode 100644 index 00000000..b0364ca3 --- /dev/null +++ b/src/SonsOfPHP/Contract/Mailer/TransportInterface.php @@ -0,0 +1,19 @@ + + */ +interface TransportInterface +{ + /** + * @throws MailerExceptionInterface + */ + public function send(MessageInterface $message); +} From 9155d9c80ed8ab8cba5897a43ccc0de1926f8566 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Dec 2023 15:57:25 -0500 Subject: [PATCH 07/11] Update symfony/phpunit-bridge requirement from ^6 to ^6 || ^7 (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [symfony/phpunit-bridge](https://github.com/symfony/phpunit-bridge) to permit the latest version.
Release notes

Sourced from symfony/phpunit-bridge's releases.

v7.0.1

Changelog (https://github.com/symfony/phpunit-bridge/compare/v7.0.0...v7.0.1)

Changelog

Sourced from symfony/phpunit-bridge's changelog.

CHANGELOG

6.3

  • Add support for mocking the enum_exists function
  • Enable reporting of deprecations triggered by Doctrine by default

6.2

  • Add support for mocking the hrtime() function

6.1

  • Add option ignoreFile to configure a file that lists deprecation messages to ignore

6.0

  • Remove SetUpTearDownTrait

5.3

  • bumped the minimum PHP version to 7.1.3
  • bumped the minimum PHPUnit version to 7.5
  • deprecated the SetUpTearDownTrait trait, use original methods with "void" return typehint.
  • added logFile option to write deprecations to a file instead of echoing them

5.1.0

  • ignore verbosity settings when the build fails because of deprecations
  • added per-group verbosity
  • added ExpectDeprecationTrait to be able to define an expected deprecation from inside a test
  • deprecated the @expectedDeprecation annotation, use the ExpectDeprecationTrait::expectDeprecation() method instead

5.0.0

  • removed weak_vendor mode, use max[self]=0 instead

4.4.0

  • made the bridge act as a polyfill for newest PHPUnit features
  • added SetUpTearDownTrait to allow working around the void return-type added by PHPUnit 8

... (truncated)

Commits
  • c2d059b Merge branch '6.4' into 7.0
  • cca5373 Merge branch '6.3' into 6.4
  • 1b7f8aa Merge branch '5.4' into 6.3
  • f1428fe run composer update for compatibility with PHPUnit versions shipping composer...
  • 5092bc7 Merge branch '6.4' into 7.0
  • 36bc5bc Merge branch '6.3' into 6.4
  • 4561090 Merge branch '5.4' into 6.3
  • 30656f4 [Tests] Streamline
  • 634ca9c Merge branch '6.4' into 7.0
  • 0470bb7 feature #52193 [PhpUnitBridge] Allow setting the locale using SYMFONY_PHPUNIT...
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index cf9d1765..9ba6a593 100644 --- a/composer.json +++ b/composer.json @@ -211,7 +211,7 @@ "require-dev": { "symfony/http-foundation": "^5 || ^6", "symfony/serializer": "^5 || ^6", - "symfony/phpunit-bridge": "^6", + "symfony/phpunit-bridge": "^6 || ^7", "symfony/error-handler": "^6", "symfony/messenger": "^5 || ^6", "phpunit/phpunit": "^10.4", From 606bb9436149b7127d4588dbdd405a6cf575e548 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 22 Dec 2023 12:57:57 -0500 Subject: [PATCH 08/11] Adding Churn PHP as a tool in the repo (#200) ## Description Closes #68 ## Checklist - [ ] Updated CHANGELOG files --- .gitignore | 1 + Makefile | 14 ++++++++-- churn.yml | 59 +++++++++++++++++++++++++++++++++++++++ tools/churn/composer.json | 5 ++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 churn.yml create mode 100644 tools/churn/composer.json diff --git a/.gitignore b/.gitignore index 5ced1162..d680779d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ composer.phar packages.json results.sarif infection.log +.churn.cache diff --git a/Makefile b/Makefile index f3fdb73b..8a0c2c7f 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ PHP = php PHP_CS_FIXER = tools/php-cs-fixer/vendor/bin/php-cs-fixer PHPUNIT = tools/phpunit/vendor/bin/phpunit PSALM = tools/psalm/vendor/bin/psalm +CHURN = tools/churn/vendor/bin/churn PSALM_BASELINE_FILE = psalm-baseline.xml BARD = src/SonsOfPHP/Bard/bin/bard @@ -200,9 +201,18 @@ infection: -dapc.enable_cli=1 \ tools/infection/vendor/bin/infection --debug -vvv --show-mutations -tools-install: psalm-install php-cs-fixer-install phpunit-install +churn: ## Run Churn PHP + $(CHURN) -tools-upgrade: psalm-upgrade php-cs-fixer-upgrade phpunit-upgrade +churn-install: + $(COMPOSER) install --working-dir=tools/churn --no-interaction --prefer-dist --optimize-autoloader + +churn-upgrade: + $(COMPOSER) upgrade --working-dir=tools/churn --no-interaction --prefer-dist --optimize-autoloader --with-all-dependencies + +tools-install: psalm-install php-cs-fixer-install phpunit-install churn-install + +tools-upgrade: psalm-upgrade php-cs-fixer-upgrade phpunit-upgrade churn-upgrade ## Documentation docs-install: ## Install deps for building docs diff --git a/churn.yml b/churn.yml new file mode 100644 index 00000000..8fa302b0 --- /dev/null +++ b/churn.yml @@ -0,0 +1,59 @@ +# The maximum number of files to display in the results table. +# Default: 10 +filesToShow: 10 + +# The minimum score a file need to display in the results table. +# Disabled if null. +# Default: 0.1 +minScoreToShow: 0 + +# The command returns an 1 exit code if the highest score is greater than the threshold. +# Disabled if null. +# Default: null +maxScoreThreshold: 0.9 + +# The number of parallel jobs to use when processing files. +# Default: 10 +parallelJobs: 10 + +# How far back in the VCS history to count the number of commits to a file +# Can be a human readable date like 'One week ago' or a date like '2017-07-12' +# Default: '10 Years ago' +commitsSince: One year ago + +# Files to ignore when processing. The full path to the file relative to the root of your project is required. +# Also supports regular expressions. +# Default: All PHP files in the path provided to churn-php are processed. +#filesToIgnore: +# - src/Commands/ChurnCommand.php +# - src/Results/ResultsParser.php +# - src/Foo/Ba* + +# File extensions to use when processing. +# Default: php +fileExtensions: + - php + +# This list is used only if there is no argument when running churn. +# Default: +directoriesToScan: + - src/ + +# List of user-defined hooks. +# They can be referenced by their full qualified class name if churn has access to the autoloader. +# Otherwise the file path can be used as well. +# See below the section about hooks for more details. +# Default: +#hooks: +# - Namespace\MyHook +# - path/to/my-hook.php + +# The version control system used for your project. +# Accepted values: fossil, git, mercurial, subversion, none +# Default: git +vcs: git + +# The path of the cache file. It doesn't need to exist before running churn. +# Disabled if null. +# Default: null +cachePath: .churn.cache diff --git a/tools/churn/composer.json b/tools/churn/composer.json new file mode 100644 index 00000000..b5020b0b --- /dev/null +++ b/tools/churn/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "bmitch/churn-php": "^1.7" + } +} From b24d817b08569ffac640c7d0f1241015305e1dac Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Tue, 26 Dec 2023 12:24:58 -0500 Subject: [PATCH 09/11] [Bard] various changes and updates (#201) ## Description Closes #26 #29 ## Checklist - [ ] Updated CHANGELOG files - [ ] Updated Documentation - [ ] Unit Tests Created - [ ] php-cs-fixer --- docs/bard/index.md | 20 +++++++-- src/SonsOfPHP/Bard/composer.json | 3 +- .../Bard/src/Console/Command/AddCommand.php | 42 +++++++++++++++---- .../Bard/src/Console/Command/CopyCommand.php | 38 +++++++++++++++-- .../Bard/src/Console/Command/InitCommand.php | 13 ++---- .../Bard/src/Console/Command/MergeCommand.php | 31 ++++++++++---- .../Bard/src/Console/Command/PushCommand.php | 19 ++++++--- .../src/Console/Command/ReleaseCommand.php | 24 ++++------- .../Bard/src/Console/Command/SplitCommand.php | 19 ++++++--- .../src/Console/Command/UpdateCommand.php | 7 ---- src/SonsOfPHP/Bard/src/JsonFile.php | 9 ++-- .../src/Worker/File/Bard/UpdateVersion.php | 7 +--- .../Composer/Root/UpdateRequireDevSection.php | 21 ++++++++++ 13 files changed, 180 insertions(+), 73 deletions(-) diff --git a/docs/bard/index.md b/docs/bard/index.md index 77007625..47810189 100644 --- a/docs/bard/index.md +++ b/docs/bard/index.md @@ -8,25 +8,25 @@ Bard is used to manage monorepos. ## Usage -Initialize a new bard.json file +Initialize a new bard.json file for new monorepos. ```shell bard init ``` -Add repositories +### Adding Repositories ```shell bard add path/to/code repoUrl ``` -Push changes to read-only repos +### Push changes to read-only repos ```shell bard push ``` -Create a release +### Create a release ```shell bard release major @@ -37,7 +37,19 @@ bard release patch Bard will track the versions so you can just use the keywords: major, minor, patch. +### Copy files + Copy the LICENSE file from the root to all packages ```shell bard copy LICENSE ``` + +### Merging `composer.json` files + +When you have to maintain the composer.json files, this command will take the +packages and merge those into the main composer.json file. It will also update +the package's composer.json file with the correct values as well. + +```shell +bard merge +``` diff --git a/src/SonsOfPHP/Bard/composer.json b/src/SonsOfPHP/Bard/composer.json index f1d905b6..151bdd29 100644 --- a/src/SonsOfPHP/Bard/composer.json +++ b/src/SonsOfPHP/Bard/composer.json @@ -15,6 +15,7 @@ "php": ">=8.1", "sonsofphp/event-dispatcher": "^0.3.x-dev", "sonsofphp/json": "^0.3.x-dev", + "sonsofphp/logger": "^0.3.x-dev", "sonsofphp/version": "^0.3.x-dev", "symfony/console": "^4 || ^5 || ^6", "symfony/dotenv": "^5 || ^6", @@ -66,4 +67,4 @@ "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" } ] -} \ No newline at end of file +} diff --git a/src/SonsOfPHP/Bard/src/Console/Command/AddCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/AddCommand.php index 546e4551..97818dfc 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/AddCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/AddCommand.php @@ -24,26 +24,49 @@ protected function configure(): void $this ->setDescription('Add new repo') ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'What branch we working with?', 'main') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry Run (Do not make any changes)') ->addArgument('path', InputArgument::REQUIRED, 'Path where code will be') - ->addArgument('repository', InputArgument::REQUIRED, 'Repository') + ->addArgument('repository', InputArgument::REQUIRED, 'Repository Uri') + ->setHelp( + <<<'HELP' +The add command will add additional repositories that need to be managed +into the `bard.json` file. + +Examples: + + %command.full_name% src/SonsOfPHP/Bard git@github.com:vendor/package.git + +Read more at https://docs.sonsofphp.com/bard/ +HELP + ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { $bardConfig = new JsonFile($input->getOption('working-dir') . '/bard.json'); - $formatter = $this->getHelper('formatter'); $io = new SymfonyStyle($input, $output); + $isDryRun = $input->getOption('dry-run'); + // --- if (null === $packages = $bardConfig->getSection('packages')) { $packages = []; } - - // @todo Check to make sure this package does not already exist + foreach ($packages as $pkg) { + if ($pkg['path'] === $input->getArgument('path')) { + $io->error([ + sprintf('It appears that the path "%s" is currently being used.', $pkg['path']), + 'Please check your bard.json file', + ]); + return self::FAILURE; + } + } $packages[] = [ - 'path' => $input->getArgument('path'), + 'path' => $input->getArgument('path'), 'repository' => $input->getArgument('repository'), ]; + // --- + $bardConfig = $bardConfig->setSection('packages', $packages); $commands = [ ['git', 'subtree', 'add', '--prefix', $input->getArgument('path'), $input->getArgument('repository'), $input->getOption('branch'), '--squash'], @@ -51,10 +74,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($commands as $cmd) { $process = new Process($cmd); $io->text($process->getCommandLine()); - $this->getHelper('process')->mustRun($output, $process, sprintf('There was and error running command: %s', $process->getCommandLine())); + if (!$isDryRun) { + $this->getHelper('process')->mustRun($output, $process, sprintf('There was and error running command: %s', $process->getCommandLine())); + } + } + + if (!$isDryRun) { + file_put_contents($bardConfig->getFilename(), $bardConfig->toJson()); } - file_put_contents($bardConfig->getFilename(), $bardConfig->toJson()); $io->success('Package has been added.'); return self::SUCCESS; diff --git a/src/SonsOfPHP/Bard/src/Console/Command/CopyCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/CopyCommand.php index 26f9d90d..c63df3dd 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/CopyCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/CopyCommand.php @@ -7,6 +7,7 @@ use SonsOfPHP\Bard\JsonFile; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Process\Process; @@ -22,25 +23,56 @@ protected function configure(): void { $this ->setDescription('Copies a file to each package') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry Run (Do not make any changes)') ->addArgument('source', InputArgument::REQUIRED, 'Source file to copy') + ->addArgument('package', InputArgument::OPTIONAL, 'Which package?') + ->setHelp( + <<<'HELP' +The copy command will copy whatever file you give it to all the other +repositories it is managing. This is useful for LICENSE files. + +Examples: + + %command.full_name% LICENSE + +Read more at https://docs.sonsofphp.com/bard/ +HELP + ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { - $io = new SymfonyStyle($input, $output); + $io = new SymfonyStyle($input, $output); + $isDryRun = $input->getOption('dry-run'); + + // --- $sourceFile = $input->getOption('working-dir') . '/' . $input->getArgument('source'); if (!is_file($sourceFile)) { throw new \RuntimeException(sprintf('The file "%s" is an invalid file.', $sourceFile)); } + // --- + + // --- $bardJsonFile = new JsonFile($input->getOption('working-dir') . '/bard.json'); foreach ($bardJsonFile->getSection('packages') as $pkg) { + $pkgComposerFile = realpath($input->getOption('working-dir') . '/' . $pkg['path'] . '/composer.json'); + $pkgComposerJsonFile = new JsonFile($pkgComposerFile); + $pkgName = $pkgComposerJsonFile->getSection('name'); + + if (null !== $input->getArgument('package') && $pkgName !== $input->getArgument('package')) { + continue; + } + $process = new Process(['cp', $sourceFile, $pkg['path']]); $io->text($process->getCommandLine()); - $this->getHelper('process')->run($output, $process); + if (!$isDryRun) { + $this->getHelper('process')->run($output, $process); + } } + // --- - $io->success('File has been copied.'); + $io->success(sprintf('File "%s" has been copied to all managed repos.', $sourceFile)); return self::SUCCESS; } diff --git a/src/SonsOfPHP/Bard/src/Console/Command/InitCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/InitCommand.php index 6ea7ffd3..0151fef4 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/InitCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/InitCommand.php @@ -7,6 +7,7 @@ use SonsOfPHP\Bard\JsonFile; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; /** * Creates the initial bard.json file. @@ -17,11 +18,6 @@ final class InitCommand extends AbstractCommand { protected static $defaultName = 'init'; - // public function __construct() - // { - // parent::__construct(); - // } - protected function configure(): void { $this @@ -29,14 +25,13 @@ protected function configure(): void ; } - protected function initialize(InputInterface $input, OutputInterface $output): void {} - protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); $filename = $input->getOption('working-dir') . '/bard.json'; if (file_exists($filename)) { - $output->writeln('bard.json file already exists'); + $io->error(sprintf('%s/bard.json file already exists', $input->getOption('working-dir'))); return self::FAILURE; } @@ -51,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ['path' => 'packages/component', 'repository' => 'git@github.com/org/component'], ]); - $output->writeln($bardJsonFile->toJson()); + $io->text($bardJsonFile->toJson()); file_put_contents($bardJsonFile->getFilename(), $bardJsonFile->toJson()); diff --git a/src/SonsOfPHP/Bard/src/Console/Command/MergeCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/MergeCommand.php index 77de3595..67469bef 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/MergeCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/MergeCommand.php @@ -16,8 +16,11 @@ use SonsOfPHP\Bard\Worker\File\Composer\Root\UpdateRequireDevSection; use SonsOfPHP\Bard\Worker\File\Composer\Root\UpdateRequireSection; use SonsOfPHP\Component\Json\Json; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; /** * Merges composer.json files. @@ -42,8 +45,9 @@ public function __construct() protected function configure(): void { $this - // options for dry-run, by default it should be a dry-run ->setDescription('Merges package composer.json files into main composer.json file') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry Run (Do not make any changes)') + ->addArgument('package', InputArgument::OPTIONAL, 'Which package?') ; } @@ -66,6 +70,9 @@ protected function initialize(InputInterface $input, OutputInterface $output): v protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $isDryRun = $input->getOption('dry-run'); + $this->formatter = $this->getHelper('formatter'); $rootComposerJsonFile = new JsonFile($input->getOption('working-dir') . '/composer.json'); @@ -75,13 +82,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $rootComposerJsonFile = $rootComposerJsonFile->setSection('autoload-dev', []); foreach ($this->bardConfig['packages'] as $pkg) { - $packageComposerFile = realpath($input->getOption('working-dir') . '/' . $pkg['path'] . '/composer.json'); - if (!file_exists($packageComposerFile)) { + $pkgComposerFile = realpath($input->getOption('working-dir') . '/' . $pkg['path'] . '/composer.json'); + if (!file_exists($pkgComposerFile)) { $output->writeln(sprintf('No "%s" found, skipping', $packageComposerFile)); continue; } - $pkgComposerJsonFile = new JsonFile(realpath($input->getOption('working-dir') . '/' . $pkg['path'] . '/composer.json')); + $pkgComposerJsonFile = new JsonFile($pkgComposerFile); + $pkgName = $pkgComposerJsonFile->getSection('name'); + if (null !== $input->getArgument('package') && $pkgName !== $input->getArgument('package')) { + continue; + } $output->writeln($this->formatter->formatSection('bard', sprintf('Merging "%s" into root composer.json', $pkgComposerJsonFile->getSection('name')))); @@ -100,12 +111,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $pkgComposerJsonFile = $pkgComposerJsonFile->with(new Authors($rootComposerJsonFile)); $pkgComposerJsonFile = $pkgComposerJsonFile->with(new Funding($rootComposerJsonFile)); - file_put_contents($pkgComposerJsonFile->getFilename(), $pkgComposerJsonFile->toJson()); + if (!$isDryRun) { + file_put_contents($pkgComposerJsonFile->getFilename(), $pkgComposerJsonFile->toJson()); + $io->text(sprintf('Updated "%s"', $pkgComposerJsonFile->getFilename())); + } } - file_put_contents($rootComposerJsonFile->getFilename(), $rootComposerJsonFile->toJson()); + if (!$isDryRun) { + file_put_contents($rootComposerJsonFile->getFilename(), $rootComposerJsonFile->toJson()); + $io->text(sprintf('Updated "%s"', $rootComposerJsonFile->getFilename())); + } - $output->writeln('complete'); + $io->success('Merge Complete'); return self::SUCCESS; } diff --git a/src/SonsOfPHP/Bard/src/Console/Command/PushCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/PushCommand.php index e0b03b0e..159e080f 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/PushCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/PushCommand.php @@ -5,6 +5,7 @@ namespace SonsOfPHP\Bard\Console\Command; use SonsOfPHP\Bard\JsonFile; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -25,32 +26,40 @@ protected function configure(): void $this ->setDescription('Push changes to package repos using git subtree push') ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'What branch we working with?', 'main') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry Run (Do not make any changes)') + ->addArgument('package', InputArgument::OPTIONAL, 'Which package do you want to push?') ; } protected function execute(InputInterface $input, OutputInterface $output): int { $bardConfig = new JsonFile($input->getOption('working-dir') . '/bard.json'); - $formatter = $this->getHelper('formatter'); $io = new SymfonyStyle($input, $output); + $isDryRun = $input->getOption('dry-run'); foreach ($bardConfig->getSection('packages') as $pkg) { $pkgComposerFile = realpath($input->getOption('working-dir') . '/' . $pkg['path'] . '/composer.json'); $pkgComposerJsonFile = new JsonFile($pkgComposerFile); $pkgName = $pkgComposerJsonFile->getSection('name'); - $io->text(sprintf('Pushing %s', $pkgName)); + + if (null !== $input->getArgument('package') && $pkgName !== $input->getArgument('package')) { + continue; + } $commands = [ // subtree push ['git', 'subtree', 'push', '-P', $pkg['path'], $pkg['repository'], $input->getOption('branch')], ]; + $io->text(sprintf('Pushing %s', $pkgName)); foreach ($commands as $cmd) { $process = new Process($cmd); $io->text($process->getCommandLine()); - $this->getHelper('process') - ->mustRun($output, $process, sprintf('There was and error running command: %s', $process->getCommandLine())) - ->wait(); + if (!$isDryRun) { + $this->getHelper('process') + ->mustRun($output, $process, sprintf('There was and error running command: %s', $process->getCommandLine())) + ->wait(); + } } } diff --git a/src/SonsOfPHP/Bard/src/Console/Command/ReleaseCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/ReleaseCommand.php index b2acf3e1..98af86fe 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/ReleaseCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/ReleaseCommand.php @@ -28,11 +28,6 @@ final class ReleaseCommand extends AbstractCommand private $releaseVersion; private bool $isDryRun = true; - // public function __construct() - // { - // parent::__construct(); - // } - protected function configure(): void { $this @@ -41,16 +36,16 @@ protected function configure(): void ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'What branch we working with?', 'main') ->addArgument('release', InputArgument::REQUIRED, 'Next Release you want to start? Use format ..-+ or "major", "minor", "patch"') ->setHelp( - <<%command.full_name% + %command.full_name% - Read more at https://docs.SonsOfPHP.com - EOT +Read more at https://docs.SonsOfPHP.com +EOT ); } @@ -87,8 +82,6 @@ protected function initialize(InputInterface $input, OutputInterface $output): v $this->isDryRun = $input->getOption('dry-run'); } - protected function interact(InputInterface $input, OutputInterface $output): void {} - protected function execute(InputInterface $input, OutputInterface $output): int { $formatter = $this->getHelper('formatter'); @@ -156,6 +149,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success('Mother Repository Released'); // 4. Subtree Split for each package + // @todo run split command $io->newLine(); $io->title(sprintf('updating package repos with release %s', $this->releaseVersion->toString())); foreach ($this->bardConfig->getSection('packages') as $pkg) { diff --git a/src/SonsOfPHP/Bard/src/Console/Command/SplitCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/SplitCommand.php index a941f4d6..94624f8a 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/SplitCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/SplitCommand.php @@ -5,6 +5,7 @@ namespace SonsOfPHP\Bard\Console\Command; use SonsOfPHP\Bard\JsonFile; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -23,20 +24,25 @@ protected function configure(): void $this ->setDescription('Push changes to package repos using git subtree split') ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'What branch we working with?', 'main') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry Run (Do not make any changes)') + ->addArgument('package', InputArgument::OPTIONAL, 'Which package do you want to push?') ; } protected function execute(InputInterface $input, OutputInterface $output): int { $bardConfig = new JsonFile($input->getOption('working-dir') . '/bard.json'); - $formatter = $this->getHelper('formatter'); $io = new SymfonyStyle($input, $output); + $isDryRun = $input->getOption('dry-run'); foreach ($bardConfig->getSection('packages') as $pkg) { $pkgComposerFile = realpath($input->getOption('working-dir') . '/' . $pkg['path'] . '/composer.json'); $pkgComposerJsonFile = new JsonFile($pkgComposerFile); $pkgName = $pkgComposerJsonFile->getSection('name'); - $io->text(sprintf('Pushing %s', $pkgName)); + + if (null !== $input->getArgument('package') && $pkgName !== $input->getArgument('package')) { + continue; + } $commands = [ // subtree split @@ -47,12 +53,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int ['git', 'branch', '-D', $pkgName], ]; + $io->text(sprintf('Pushing %s', $pkgName)); foreach ($commands as $cmd) { $process = new Process($cmd); $io->text($process->getCommandLine()); - $this->getHelper('process') - ->mustRun($output, $process, sprintf('There was and error running command: %s', $process->getCommandLine())) - ->wait(); + if (!$isDryRun) { + $this->getHelper('process') + ->mustRun($output, $process, sprintf('There was and error running command: %s', $process->getCommandLine())) + ->wait(); + } } } diff --git a/src/SonsOfPHP/Bard/src/Console/Command/UpdateCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/UpdateCommand.php index 4ebee170..c943f7fa 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/UpdateCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/UpdateCommand.php @@ -16,11 +16,6 @@ final class UpdateCommand extends AbstractCommand { protected static $defaultName = 'update'; - // public function __construct() - // { - // parent::__construct(); - // } - protected function configure(): void { $this @@ -28,8 +23,6 @@ protected function configure(): void ; } - protected function initialize(InputInterface $input, OutputInterface $output): void {} - protected function execute(InputInterface $input, OutputInterface $output): int { $bardJsonFile = new JsonFile($input->getOption('working-dir') . '/bard.json'); diff --git a/src/SonsOfPHP/Bard/src/JsonFile.php b/src/SonsOfPHP/Bard/src/JsonFile.php index b0d1a360..721d3359 100644 --- a/src/SonsOfPHP/Bard/src/JsonFile.php +++ b/src/SonsOfPHP/Bard/src/JsonFile.php @@ -7,17 +7,18 @@ use SonsOfPHP\Component\Json\Json; /** + * Used to manage bard.json and composer.json files + * * @author Joshua Estes */ final class JsonFile { - private string $filename; private array $config = []; private Json $json; - public function __construct(string $filename) - { - $this->filename = $filename; + public function __construct( + private string $filename, + ) { $this->json = new Json(); $this->load(); } diff --git a/src/SonsOfPHP/Bard/src/Worker/File/Bard/UpdateVersion.php b/src/SonsOfPHP/Bard/src/Worker/File/Bard/UpdateVersion.php index 529967db..b07fe17d 100644 --- a/src/SonsOfPHP/Bard/src/Worker/File/Bard/UpdateVersion.php +++ b/src/SonsOfPHP/Bard/src/Worker/File/Bard/UpdateVersion.php @@ -13,12 +13,7 @@ */ final class UpdateVersion implements WorkerInterface { - private VersionInterface $version; - - public function __construct(VersionInterface $version) - { - $this->version = $version; - } + public function __construct(private VersionInterface $version) {} public function apply(JsonFile $bardJsonFile): JsonFile { diff --git a/src/SonsOfPHP/Bard/src/Worker/File/Composer/Root/UpdateRequireDevSection.php b/src/SonsOfPHP/Bard/src/Worker/File/Composer/Root/UpdateRequireDevSection.php index 3b3976fe..74ee80d4 100644 --- a/src/SonsOfPHP/Bard/src/Worker/File/Composer/Root/UpdateRequireDevSection.php +++ b/src/SonsOfPHP/Bard/src/Worker/File/Composer/Root/UpdateRequireDevSection.php @@ -26,12 +26,33 @@ public function apply(JsonFile $rootComposerJsonFile): JsonFile { $rootRequireDev = $rootComposerJsonFile->getSection('require-dev'); $pkgRequireDev = $this->pkgComposerJsonFile->getSection('require-dev'); + $rootRequire = $rootComposerJsonFile->getSection('require'); + $rootReplace = $rootComposerJsonFile->getSection('replace'); + if (null === $pkgRequireDev) { return $rootComposerJsonFile; } foreach ($pkgRequireDev as $package => $version) { + if (array_key_exists($package, $rootRequire)) { + if (array_key_exists($package, $rootRequireDev)) { + unset($rootRequireDev[$package]); + } + continue; + } + + if (array_key_exists($package, $rootReplace)) { + if (array_key_exists($package, $rootRequireDev)) { + unset($rootRequireDev[$package]); + } + + if (array_key_exists($package, $rootRequire)) { + unset($rootRequire[$package]); + } + continue; + } + $rootRequireDev[$package] = $version; } From 9cd723ebcbf5adf35fa958d21cd5526b6f10338b Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Wed, 27 Dec 2023 14:13:13 -0500 Subject: [PATCH 10/11] [EventDispatcher] Adding a few new things and working on docs (#202) ## Description ## Checklist - [x] Updated CHANGELOG files - [x] Updated Documentation - [x] Unit Tests Created - [x] php-cs-fixer --- CHANGELOG.md | 1 + Makefile | 7 +- docs/components/event-dispatcher/index.md | 100 ++++++++++++++++++ .../AbstractStoppableEvent.php | 15 +++ .../EventDispatcher/EventDispatcher.php | 15 ++- .../EventSubscriberInterface.php | 4 + .../EventDispatcher/ListenerInterface.php | 10 -- .../EventDispatcher/ListenerProvider.php | 11 ++ .../EventDispatcher/StoppableEventTrait.php | 26 +++++ .../Tests/AbstractStoppableEventTest.php | 40 +++++++ .../Tests/EventDispatcherTest.php | 44 ++++++++ 11 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 src/SonsOfPHP/Component/EventDispatcher/AbstractStoppableEvent.php delete mode 100644 src/SonsOfPHP/Component/EventDispatcher/ListenerInterface.php create mode 100644 src/SonsOfPHP/Component/EventDispatcher/StoppableEventTrait.php create mode 100644 src/SonsOfPHP/Component/EventDispatcher/Tests/AbstractStoppableEventTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 691a6733..9c140b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ To get the diff between two versions, go to https://github.com/SonsOfPHP/sonsofp * [PR #182](https://github.com/SonsOfPHP/sonsofphp/pull/182) [Container] New Component (PSR-11) * [PR #187](https://github.com/SonsOfPHP/sonsofphp/pull/187) [HttpHandler] New Component (PSR-15) and Contract * [PR #190](https://github.com/SonsOfPHP/sonsofphp/pull/190) [Mailer] New Component and Contract +* [PR #202](https://github.com/SonsOfPHP/sonsofphp/pull/202) [EventDispatcher] Added Stoppable Event Code ## [0.3.8] diff --git a/Makefile b/Makefile index 8a0c2c7f..629c3085 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,9 @@ test-cookie: phpunit test-cqrs: PHPUNIT_TESTSUITE=cqrs test-cqrs: phpunit +test-event-dispatcher: PHPUNIT_TESTSUITE=event-dispatcher +test-event-dispatcher: phpunit + test-http-factory: PHPUNIT_TESTSUITE=http-factory test-http-factory: phpunit @@ -125,8 +128,8 @@ coverage-cookie: coverage coverage-cqrs: PHPUNIT_TESTSUITE=cqrs coverage-cqrs: coverage -coverage-event-dispatcher: - XDEBUG_MODE=coverage $(PHP) -dxdebug.mode=coverage $(PHPUNIT) --testsuite event-dispatcher --coverage-html $(COVERAGE_DIR) +coverage-event-dispatcher: PHPUNIT_TESTSUITE=event-dispatcher +coverage-event-dispatcher: coverage coverage-event-sourcing: XDEBUG_MODE=coverage $(PHP) -dxdebug.mode=coverage $(PHPUNIT) --testsuite event-sourcing --coverage-html $(COVERAGE_DIR) diff --git a/docs/components/event-dispatcher/index.md b/docs/components/event-dispatcher/index.md index 138b87e5..46493a92 100644 --- a/docs/components/event-dispatcher/index.md +++ b/docs/components/event-dispatcher/index.md @@ -49,9 +49,109 @@ Must implement `EventSubscriberInterface`. ### Listener Priorities ```php +addListener('event.name', function () {}, $priority); ``` The priority will default to `0`. Lower numbers are higher priority. Higher numbers will be handled later. For example, a listener with a priority of `-1` will be handled before a listener of priority `1`. + + +### Stoppable Events + +If your code extends the `AbstractStoppableEvent` and within your listener or +subscriber code, you execute `$this->stopPropagation();` and it will return the +event and no more listeners or subscribers will handle that event. + +```php +stopPropagation(); + // ... + } +} +``` + +You can also create Stoppable Events by using the `StoppableEventInterface` and +`StoppableEventTrait`. + +```php + 'onCreated'; + yield OrderUpdated::class => ['onUpdated', 100]; + yield OrderDeleted::class => [['onDeleted', 100], ['doFirstOnDeleted', -100]]; + + // OR like this + return [ + OrderCreated::class => 'onCreated', + OrderUpdated::class => ['onUpdated', 100], + OrderDeleted::class => [['onDeleted', 100], ['doFirstOnDeleted', -100]], + ]; + } +} +``` diff --git a/src/SonsOfPHP/Component/EventDispatcher/AbstractStoppableEvent.php b/src/SonsOfPHP/Component/EventDispatcher/AbstractStoppableEvent.php new file mode 100644 index 00000000..5e3845b9 --- /dev/null +++ b/src/SonsOfPHP/Component/EventDispatcher/AbstractStoppableEvent.php @@ -0,0 +1,15 @@ + + */ +abstract class AbstractStoppableEvent implements StoppableEventInterface +{ + use StoppableEventTrait; +} diff --git a/src/SonsOfPHP/Component/EventDispatcher/EventDispatcher.php b/src/SonsOfPHP/Component/EventDispatcher/EventDispatcher.php index 963e89ac..932e4e00 100644 --- a/src/SonsOfPHP/Component/EventDispatcher/EventDispatcher.php +++ b/src/SonsOfPHP/Component/EventDispatcher/EventDispatcher.php @@ -18,7 +18,10 @@ public function __construct( ) {} /** - * @return object + * {@inheritdoc} + * + * @param string|null $eventName + * Is the event name is null, is will use the event's classname as the Event Name */ public function dispatch(object $event, string $eventName = null): object { @@ -35,11 +38,19 @@ public function dispatch(object $event, string $eventName = null): object return $event; } - public function addListener(string $eventName, callable|array $listener, int $priority = 0): void + /** + */ + public function addListener(string|object $eventName, callable|array $listener, int $priority = 0): void { + if (is_object($eventName)) { + $eventName = $eventName::class; + } + $this->provider->add($eventName, $listener, $priority); } + /** + */ public function addSubscriber(EventSubscriberInterface $subscriber): void { $this->provider->addSubscriber($subscriber); diff --git a/src/SonsOfPHP/Component/EventDispatcher/EventSubscriberInterface.php b/src/SonsOfPHP/Component/EventDispatcher/EventSubscriberInterface.php index f7142e06..7caef4ee 100644 --- a/src/SonsOfPHP/Component/EventDispatcher/EventSubscriberInterface.php +++ b/src/SonsOfPHP/Component/EventDispatcher/EventSubscriberInterface.php @@ -10,6 +10,10 @@ interface EventSubscriberInterface { /** + * Returns an iterable with one or more of the following + * - ['eventName' => 'methodName'], ... + * - ['eventName' => ['methodName', $priority], ...], ... + * - ['eventName' => [['methodName', $priority], ['methodName']], ...], ... */ public static function getSubscribedEvents(); } diff --git a/src/SonsOfPHP/Component/EventDispatcher/ListenerInterface.php b/src/SonsOfPHP/Component/EventDispatcher/ListenerInterface.php deleted file mode 100644 index 72ec217a..00000000 --- a/src/SonsOfPHP/Component/EventDispatcher/ListenerInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -getListenersForEventName($event::class); } + /** + */ public function add(string $eventName, callable|array $listener, int $priority = 0): void { $this->listeners[$eventName][$priority][] = $listener; unset($this->sorted[$eventName]); } + /** + */ public function addSubscriber(EventSubscriberInterface $subscriber): void { foreach ($subscriber::getSubscribedEvents() as $eventName => $params) { @@ -48,6 +55,8 @@ public function addSubscriber(EventSubscriberInterface $subscriber): void } } + /** + */ public function getListenersForEventName(string $eventName): iterable { if (!\array_key_exists($eventName, $this->listeners)) { @@ -61,6 +70,8 @@ public function getListenersForEventName(string $eventName): iterable return $this->sorted[$eventName]; } + /** + */ private function sortListeners(string $eventName): void { ksort($this->listeners[$eventName]); diff --git a/src/SonsOfPHP/Component/EventDispatcher/StoppableEventTrait.php b/src/SonsOfPHP/Component/EventDispatcher/StoppableEventTrait.php new file mode 100644 index 00000000..8fb1faf8 --- /dev/null +++ b/src/SonsOfPHP/Component/EventDispatcher/StoppableEventTrait.php @@ -0,0 +1,26 @@ + + */ +trait StoppableEventTrait +{ + private bool $isStopped = false; + + public function isPropagationStopped(): bool + { + return $this->isStopped; + } + + /** + * Makes `isPropagationStopped` return true + */ + public function stopPropagation(): void + { + $this->isStopped = true; + } +} diff --git a/src/SonsOfPHP/Component/EventDispatcher/Tests/AbstractStoppableEventTest.php b/src/SonsOfPHP/Component/EventDispatcher/Tests/AbstractStoppableEventTest.php new file mode 100644 index 00000000..812794ce --- /dev/null +++ b/src/SonsOfPHP/Component/EventDispatcher/Tests/AbstractStoppableEventTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(StoppableEventInterface::class, $event); + } + + /** + * @covers ::isPropagationStopped + * @covers ::stopPropagation + */ + public function testItCanStopPropagation(): void + { + $event = new class () extends AbstractStoppableEvent {}; + $this->assertFalse($event->isPropagationStopped()); + + $event->stopPropagation(); + $this->assertTrue($event->isPropagationStopped()); + } +} diff --git a/src/SonsOfPHP/Component/EventDispatcher/Tests/EventDispatcherTest.php b/src/SonsOfPHP/Component/EventDispatcher/Tests/EventDispatcherTest.php index 9a760b6a..69534fe2 100644 --- a/src/SonsOfPHP/Component/EventDispatcher/Tests/EventDispatcherTest.php +++ b/src/SonsOfPHP/Component/EventDispatcher/Tests/EventDispatcherTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; +use SonsOfPHP\Component\EventDispatcher\AbstractStoppableEvent; use SonsOfPHP\Component\EventDispatcher\EventDispatcher; use SonsOfPHP\Component\EventDispatcher\EventSubscriberInterface; use SonsOfPHP\Component\EventDispatcher\ListenerProvider; @@ -15,6 +16,7 @@ * * @uses \SonsOfPHP\Component\EventDispatcher\EventDispatcher * @uses \SonsOfPHP\Component\EventDispatcher\ListenerProvider + * @uses \SonsOfPHP\Component\EventDispatcher\StoppableEventTrait */ final class EventDispatcherTest extends TestCase { @@ -28,6 +30,35 @@ public function testItHasTheCorrectInterface(): void $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); // @phpstan-ignore-line } + /** + * @covers ::dispatch + */ + public function testDispatch(): void + { + $dispatcher = new EventDispatcher(); + $dispatcher->addListener('stdClass', function ($event): void {}); + + $event = new \stdClass(); + $this->assertSame($event, $dispatcher->dispatch($event)); + } + + /** + * @covers ::dispatch + */ + public function testDispatchWithStoppedEvent(): void + { + $event = new class () extends AbstractStoppableEvent {}; + + $dispatcher = new EventDispatcher(); + $dispatcher->addListener($event, function ($event): void { + throw new \RuntimeException('This should never run'); + }); + + $event->stopPropagation(); + + $this->assertSame($event, $dispatcher->dispatch($event)); + } + /** * @covers ::dispatch */ @@ -52,6 +83,19 @@ public function testItCanAddEventListener(): void $dispatcher->addListener('stdClass', function (): void {}); } + /** + * @covers ::addListener + */ + public function testAddListenerWithObject(): void + { + $provider = $this->createMock(ListenerProvider::class); + $provider->expects($this->once())->method('add')->with($this->identicalTo('stdClass')); + + $dispatcher = new EventDispatcher($provider); + + $dispatcher->addListener(new \stdClass(), function (): void {}); + } + /** * @covers ::addSubscriber */ From 5ad14f5c60597635efd23960114a6f4a02f00598 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Thu, 28 Dec 2023 08:45:31 -0500 Subject: [PATCH 11/11] Housekeeping (#203) ## Description ## Checklist - [ ] Updated CHANGELOG files - [ ] Updated Documentation - [ ] Unit Tests Created - [ ] php-cs-fixer --- composer.json | 5 +-- phpunit.xml.dist | 6 +-- src/SonsOfPHP/Bard/.gitattributes | 4 ++ src/SonsOfPHP/Bard/.gitignore | 3 ++ src/SonsOfPHP/Bard/Tests/JsonFileTest.php | 44 +++++++++++++++++++ src/SonsOfPHP/Bard/Tests/fixtures/test.json | 9 ++++ src/SonsOfPHP/Bard/composer.json | 2 +- .../Bard/src/Console/Command/AddCommand.php | 12 ++--- .../Bard/src/Console/Command/CopyCommand.php | 12 ++--- .../src/Console/Command/ReleaseCommand.php | 14 +++--- src/SonsOfPHP/Bard/src/JsonFile.php | 17 +++---- 11 files changed, 94 insertions(+), 34 deletions(-) create mode 100644 src/SonsOfPHP/Bard/.gitattributes create mode 100644 src/SonsOfPHP/Bard/.gitignore create mode 100644 src/SonsOfPHP/Bard/Tests/JsonFileTest.php create mode 100644 src/SonsOfPHP/Bard/Tests/fixtures/test.json diff --git a/composer.json b/composer.json index 9ba6a593..7a7c66ff 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "minimum-stability": "dev", "keywords": [ "php", + "sonsofphp", "sons of php", "mother repo" ], @@ -213,9 +214,7 @@ "symfony/serializer": "^5 || ^6", "symfony/phpunit-bridge": "^6 || ^7", "symfony/error-handler": "^6", - "symfony/messenger": "^5 || ^6", - "phpunit/phpunit": "^10.4", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "phpunit/phpunit": "^10.4" }, "autoload-dev": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 544af877..1d748e5f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,7 +11,7 @@ - + src/SonsOfPHP/Bard/Tests src/SonsOfPHP/Bridge/*/*/Tests src/SonsOfPHP/Bridge/*/*/*/Tests src/SonsOfPHP/Bundle/*/Tests @@ -19,7 +19,7 @@ - + src/SonsOfPHP/Bard/Tests @@ -111,7 +111,7 @@ src/* - + src/SonsOfPHP/Bard/Tests src/SonsOfPHP/Bard/vendor src/SonsOfPHP/Bridge/*/*/*/Tests src/SonsOfPHP/Bridge/*/*/*/vendor diff --git a/src/SonsOfPHP/Bard/.gitattributes b/src/SonsOfPHP/Bard/.gitattributes new file mode 100644 index 00000000..84c7add0 --- /dev/null +++ b/src/SonsOfPHP/Bard/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/SonsOfPHP/Bard/.gitignore b/src/SonsOfPHP/Bard/.gitignore new file mode 100644 index 00000000..5414c2c6 --- /dev/null +++ b/src/SonsOfPHP/Bard/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/SonsOfPHP/Bard/Tests/JsonFileTest.php b/src/SonsOfPHP/Bard/Tests/JsonFileTest.php new file mode 100644 index 00000000..ae731da4 --- /dev/null +++ b/src/SonsOfPHP/Bard/Tests/JsonFileTest.php @@ -0,0 +1,44 @@ +assertIsString($json->getFilename()); + } + + /** + * @covers ::__construct + * @covers ::load + * @covers ::getSection + */ + public function testGetSection(): void + { + $json = new JsonFile(__DIR__ . '/fixtures/test.json'); + + $this->assertSame('1.2.3', $json->getSection('version')); + } +} diff --git a/src/SonsOfPHP/Bard/Tests/fixtures/test.json b/src/SonsOfPHP/Bard/Tests/fixtures/test.json new file mode 100644 index 00000000..e4eb7479 --- /dev/null +++ b/src/SonsOfPHP/Bard/Tests/fixtures/test.json @@ -0,0 +1,9 @@ +{ + "version": "1.2.3", + "packages": [ + { + "path": "path/to/Repo", + "repository": "git@github.com:SonsOfPHP/read-only-repo.git" + } + ] +} diff --git a/src/SonsOfPHP/Bard/composer.json b/src/SonsOfPHP/Bard/composer.json index 151bdd29..f05dc3c7 100644 --- a/src/SonsOfPHP/Bard/composer.json +++ b/src/SonsOfPHP/Bard/composer.json @@ -67,4 +67,4 @@ "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" } ] -} +} \ No newline at end of file diff --git a/src/SonsOfPHP/Bard/src/Console/Command/AddCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/AddCommand.php index 97818dfc..0b60d315 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/AddCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/AddCommand.php @@ -29,15 +29,15 @@ protected function configure(): void ->addArgument('repository', InputArgument::REQUIRED, 'Repository Uri') ->setHelp( <<<'HELP' -The add command will add additional repositories that need to be managed -into the `bard.json` file. + The add command will add additional repositories that need to be managed + into the `bard.json` file. -Examples: + Examples: - %command.full_name% src/SonsOfPHP/Bard git@github.com:vendor/package.git + %command.full_name% src/SonsOfPHP/Bard git@github.com:vendor/package.git -Read more at https://docs.sonsofphp.com/bard/ -HELP + Read more at https://docs.sonsofphp.com/bard/ + HELP ) ; } diff --git a/src/SonsOfPHP/Bard/src/Console/Command/CopyCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/CopyCommand.php index c63df3dd..865ba4c9 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/CopyCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/CopyCommand.php @@ -28,15 +28,15 @@ protected function configure(): void ->addArgument('package', InputArgument::OPTIONAL, 'Which package?') ->setHelp( <<<'HELP' -The copy command will copy whatever file you give it to all the other -repositories it is managing. This is useful for LICENSE files. + The copy command will copy whatever file you give it to all the other + repositories it is managing. This is useful for LICENSE files. -Examples: + Examples: - %command.full_name% LICENSE + %command.full_name% LICENSE -Read more at https://docs.sonsofphp.com/bard/ -HELP + Read more at https://docs.sonsofphp.com/bard/ + HELP ) ; } diff --git a/src/SonsOfPHP/Bard/src/Console/Command/ReleaseCommand.php b/src/SonsOfPHP/Bard/src/Console/Command/ReleaseCommand.php index 98af86fe..c9e773a9 100644 --- a/src/SonsOfPHP/Bard/src/Console/Command/ReleaseCommand.php +++ b/src/SonsOfPHP/Bard/src/Console/Command/ReleaseCommand.php @@ -37,15 +37,15 @@ protected function configure(): void ->addArgument('release', InputArgument::REQUIRED, 'Next Release you want to start? Use format ..-+ or "major", "minor", "patch"') ->setHelp( <<<'EOT' -This command allows you to create a new release and will update the various -repos that have been configured. The current version can be found in the -`bard.json` file. This will will update the version based on the type of release -that you are doing. + This command allows you to create a new release and will update the various + repos that have been configured. The current version can be found in the + `bard.json` file. This will will update the version based on the type of release + that you are doing. - %command.full_name% + %command.full_name% -Read more at https://docs.SonsOfPHP.com -EOT + Read more at https://docs.SonsOfPHP.com + EOT ); } diff --git a/src/SonsOfPHP/Bard/src/JsonFile.php b/src/SonsOfPHP/Bard/src/JsonFile.php index 721d3359..43b5ddf6 100644 --- a/src/SonsOfPHP/Bard/src/JsonFile.php +++ b/src/SonsOfPHP/Bard/src/JsonFile.php @@ -36,9 +36,11 @@ public function getFilename(): string } /** + * Grabs and returns a section from the JSON file + * * @return array|int|string|null */ - public function getSection(string $section) + public function getSection(string $section): mixed { if (!isset($this->config)) { $this->load(); @@ -51,6 +53,8 @@ public function getSection(string $section) return null; } + /** + */ public function setSection(string $section, $value): self { if (!isset($this->config)) { @@ -63,6 +67,8 @@ public function setSection(string $section, $value): self return $clone; } + /** + */ public function toJson(): string { return $this->json->getEncoder() @@ -73,13 +79,8 @@ public function toJson(): string } /** - * The idea is something like this - * $newRootComposerJsonFile = $rootComposerJsonFile->with($updateReplaceSection, $pkgComposerJsonFile); - * or - * $newRootComposerJsonFile = $rootComposerJsonFile->with($bumpBranchAlias);. - * - * Can even use this for the package composer.json file - * $newPkgComposerJsonFile = $pkgComposerJsonFile->with($updateSupportSection, $rootComposerJsonFile); + * $operator = new Operator(); + * $jsonFile->with(new ExampleOperator()); */ public function with($operator): self {