From 06ee7d15524824cbb48427b424414519edaebdd8 Mon Sep 17 00:00:00 2001 From: Sam Jones Date: Tue, 30 Jun 2020 09:26:28 +0100 Subject: [PATCH] :tada: Initial Commit --- .gitignore | 3 + README.md | 131 ++++++++++- composer.json | 30 +++ phpunit.xml | 18 ++ src/Attempt.php | 228 +++++++++++++++++++ src/Exceptions/NoTryCallbackSetException.php | 10 + tests/Exceptions/AttemptTestException.php | 10 + tests/Unit/AttemptTest.php | 90 ++++++++ 8 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/Attempt.php create mode 100644 src/Exceptions/NoTryCallbackSetException.php create mode 100644 tests/Exceptions/AttemptTestException.php create mode 100644 tests/Unit/AttemptTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f4a2b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/.idea diff --git a/README.md b/README.md index 99b3161..6fc6d25 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,129 @@ -# try -Try is a simple, fluent class for attempting to run code multiple times. +# Attempt + +Attempt is a simple, fluent class for attempting to run code multiple times whilst handling exceptions. It attempts +to mimic PHPs built-in try/catch syntax where possible but sprinkles in some additional magic on top. + +## Getting an instance + +Depending on your preference you can grab an Attempt instance in a couple of ways: + +```php +use SlashEquip\Attempt\Attempt; + +$attempt = new Attempt(); + +$attempt = Attempt::make(); +``` + + +## Building your Attempt + +Once you have your instance you can begin to build your Attempt. + +### Try + +This is the only required method, the `try` method accepts a callable argument, the code that you want to run. + +```php +$attempt->try(function () { + // My code that may or may not work. +})->thenReturn(); +``` + +### Then Return + +You may have noticed in the example above the method `thenReturn`, this method is what tells the Attempt to run. +It will also return the value you return from the callable you pass to the `try` method. + +There is also a `then` method, this too accepts a callable which is executed and passed the value returned +by your `try` callable. + +If it's your kind of jam, an attempt is also invokable which means at any point you can invoke the Attempt and it will run. + +```php +// $valueOne will be true +$valueOne = $attempt->try(function () { + return true; +})->thenReturn(); + +// $valueTwo will be false +$valueTwo = $attempt->try(function () { + return true; +})->then(function ($result) { + return !$result; +}); + +// $valueThree will be true +$valueThree = $attempt->try(function () { + return true; +})(); +``` + +### Times + +You can set the amount of times the Attempt should be made whilst an exception is being encountered. + +### Catch + +The `catch` method allows you to define exceptions you are expecting to encounter during the attempts, when +exceptions have been passed to the catch method the Attempt will throw any other types of exceptions it +comes across _early_ rather than performing all attempts. + +The `catch` method can be called multiple times to add multiple expected exceptions. + +_If you do no provide any expected exception via the `catch` method then the Attempt will ignore all exceptions +until all attempts have been made._ + +```php +$attempt + ->try(function () { + throw new UnexpectedException; + }) + ->catch(TheExceptionWeAreExpecting::class) + ->catch(AnotherExceptionWeAreExpecting::class) + ->thenReturn(); + +// In this example; only one attempt would be made and a UnexpectedException would be thrown +``` + +### Finally + +The `finally` method allows you to run a callback at the end of the attempt _no matter the result_, whether the attempt +was successful or an exception was thrown the `finally` callback will be run. + +```php +$attempt + ->try(function () { + throw new UnexpectedException; + }) + ->finally(function () { + // run some clean up. + }) + ->thenReturn(); + +// In this example; only one attempt would be made and a UnexpectedException would be thrown +``` + +### Wait Between + +The `waitBetween` method takes an integer indicating the desired number of milliseconds to wait between attempts. + +## Example use case + +```php +use SlashEquip\Attempt\Attempt; +use GuzzleHttp\Exception\ClientException; + +$blogPost = Attempt::make() + ->try(function () use ($data) { + return \App\UnstableBlogApiServiceUsingGuzzle::post([ + 'data' => $data, + ]); + }) + ->times(3) + ->waitBetween(250) + ->catch(ClientException::class) + ->then(function ($apiResponse) { + return \App\BlogPost::fromApiResponse($apiResponse); + }); +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e0bea6c --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "slashequip/attempt", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Sam Jones", + "email": "sam@slashequip.com" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": "^7.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "pestphp/pest": "^0.2.2" + }, + "autoload": { + "psr-4": { + "SlashEquip\\Attempt\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SlashEquip\\Attempt\\Tests\\": "tests/" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..69a03bf --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + ./app + ./src + + + diff --git a/src/Attempt.php b/src/Attempt.php new file mode 100644 index 0000000..0aa1e94 --- /dev/null +++ b/src/Attempt.php @@ -0,0 +1,228 @@ +validate(); + + while ($this->attempts < $this->times) + { + // Increase the attempt number. + ++$this->attempts; + + // Wait if needed. + $this->runWait(); + + try { + $result = ($this->try)(); + } catch (Throwable $e) { + // We have reached max number of attempts. + if ($this->attempts === $this->times) { + $this->handleException($e); + } + + // Not expecting specific exceptions so continue with loop. + if (empty($this->expects)) { + continue; + } + + // This exception is something we expect to see. + if (in_array(get_class($e), $this->expects)) { + continue; + } + + // Nothing left to do but throw. + $this->handleException($e); + } + + return $this->handleSuccess($result); + } + } + + /** + * @return static + */ + public static function make(): self + { + return new static(); + } + + /** + * @param string $exceptionClass + * + * @return $this + */ + public function catch(string $exceptionClass): self + { + $this->expects[] = $exceptionClass; + + return $this; + } + + /** + * @param int $times + * + * @return $this + */ + public function times(int $times): self + { + $this->times = $times; + + return $this; + } + + /** + * @param callable $callback + * + * @return mixed + * @throws Throwable + */ + public function try(callable $callback): self + { + $this->try = $callback; + + return $this; + } + + /** + * @param callable $callback + * + * @return $this + */ + public function finally(callable $callback) + { + $this->finally = $callback; + + return $this; + } + + /** + * @param int $milliseconds + * + * @return $this + */ + public function waitBetween(int $milliseconds): self + { + $this->waitBetween = $milliseconds; + + return $this; + } + + /** + * @return mixed + * @throws Throwable + */ + public function then(callable $callback) + { + return $callback($this->thenReturn()); + } + + /** + * @return mixed + * @throws Throwable|NoTryCallbackSetException + */ + public function thenReturn() + { + return $this(); + } + + /** + * @return void + * @throws NoTryCallbackSetException + */ + protected function validate(): void + { + if (!$this->try) { + throw new NoTryCallbackSetException(); + } + } + + /** + * @param $value + * + * @return mixed + */ + protected function handleSuccess($value) + { + $this->runFinally(); + return $value; + } + + /** + * @param Throwable $e + * + * @throws Throwable + */ + protected function handleException(Throwable $e) + { + $this->runFinally(); + throw $e; + } + + /** + * @return void + */ + protected function runFinally(): void + { + if ($this->finally) { + ($this->finally)(); + } + } + + /** + * @return void + */ + protected function runWait(): void + { + // If the user has defined a wait time and this isn't the first attempt. + if ($this->waitBetween && $this->attempts > 1) { + usleep($this->waitBetween * 1000); + } + } +} \ No newline at end of file diff --git a/src/Exceptions/NoTryCallbackSetException.php b/src/Exceptions/NoTryCallbackSetException.php new file mode 100644 index 0000000..38b2ce2 --- /dev/null +++ b/src/Exceptions/NoTryCallbackSetException.php @@ -0,0 +1,10 @@ +thenReturn(); +})->throws(NoTryCallbackSetException::class); + +it('makes the attempt the expected number of times', function () { + $attempts = 0; + $exceptionThrown = false; + + try { + Attempt::make() + ->try(function () use (&$attempts) { + ++$attempts; + throw new AttemptTestException(); + }) + ->times(3) + ->thenReturn(); + } catch (AttemptTestException $e) { + $exceptionThrown = true; + } + + assertTrue($exceptionThrown); + assertSame(3, $attempts); +}); + +it('will return early if the callback succeeds before the max attempts is reached', function () { + $attempts = 0; + + Attempt::make() + ->try(function () use (&$attempts) { + ++$attempts; + + if ($attempts < 2) { + throw new AttemptTestException(); + } + }) + ->times(3) + ->thenReturn(); + + assertSame(2, $attempts); +}); + +it('if expecting an exception it will throw if it encounters a different exception', function () { + Attempt::make() + ->try(function () { + throw new BadMethodCallException(); + }) + ->times(3) + ->catch(AttemptTestException::class) + ->thenReturn(); +})->throws(BadMethodCallException::class); + +it('will call final callback on success', function () { + $finallyCalled = false; + + Attempt::make() + ->try(function () { + // + }) + ->finally(function () use (&$finallyCalled) { + $finallyCalled = true; + }) + ->thenReturn(); + + assertTrue($finallyCalled); +}); + +it('will call final callback on exception', function () { + $finallyCalled = false; + + try { + Attempt::make() + ->try(function () { + throw new AttemptTestException(); + }) + ->finally(function () use (&$finallyCalled) { + $finallyCalled = true; + }) + ->thenReturn(); + } catch (AttemptTestException $e) { + // + } + + assertTrue($finallyCalled); +}); \ No newline at end of file