From 78e793f8e3127e2c876dc1867b084c3a4c26442c Mon Sep 17 00:00:00 2001 From: Marco Stoll Date: Thu, 20 Jun 2019 12:13:45 +0200 Subject: [PATCH] [FEATURE] services and services factory --- composer.json | 6 +- readme.md | 173 ++++++++++++++++++ src/Services/AbstractService.php | 93 ++++++++++ .../Exceptions/ConfigurationException.php | 27 +++ src/Services/SF.php | 31 ++++ src/Services/ServiceLocatorTrait.php | 30 +++ src/Services/ServicesFactory.php | 136 ++++++++++++++ tests/Services/AbstractServiceTest.php | 87 +++++++++ tests/Services/SFTest.php | 42 +++++ tests/Services/ServicesFactoryTest.php | 126 +++++++++++++ tests/testsuite.xml | 8 + 11 files changed, 755 insertions(+), 4 deletions(-) create mode 100644 readme.md create mode 100644 src/Services/AbstractService.php create mode 100644 src/Services/Exceptions/ConfigurationException.php create mode 100644 src/Services/SF.php create mode 100644 src/Services/ServiceLocatorTrait.php create mode 100644 src/Services/ServicesFactory.php create mode 100644 tests/Services/AbstractServiceTest.php create mode 100644 tests/Services/SFTest.php create mode 100644 tests/Services/ServicesFactoryTest.php create mode 100644 tests/testsuite.xml diff --git a/composer.json b/composer.json index fbda321..c444b5a 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "fastforward", + "name": "fastforward/fastforward", "description": "A template for building web applications - part of the Fast Forward Family", "type": "library", "license": "MIT", @@ -14,9 +14,7 @@ ], "require": { "php": ">=7.2", - "fastforward/data-structures": "^1.0.0", - "fastforward/factories": "^1.0.0", - "fastforward/utils": "^1.0.0" + "fastforward/factories": "^1.0.0" }, "require-dev": { "phpunit/phpunit": "^8" diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..5565b94 --- /dev/null +++ b/readme.md @@ -0,0 +1,173 @@ +Fast Forward +======================================================================================================================== + +by Marco Stoll + +- +- +- +- +------------------------------------------------------------------------------------------------------------------------ + +# Introduction - What is Fast Forward? + +**Fast Forward** (in short **FF**) is a generic application template for building web and/or command line applications +fast and easy. It addresses the common tasks and provides configurable and/or extendable default implementations for you +to use. + +Currently **FF** is composed of the following features: +1. Services and a Service Factory + +More features will follow (see the Road Map section below). + +# A Warning before you start + +**FF** is highly opinionated and depends on a bunch of conventions! So be sure to consult the documentation +before deciding to develop your application based on **FF**. +But if you do **FF** ensures a minimal amount of setup, letting you concentrate your efforts on your business logic +instead of soe framework configuration. + +# Dependencies + +- FF Family + + **FF** makes heavy usage of the **Fast Forward Family** components, a collection of independent components providing + generic implementations (like data structures of design patterns) used by many of **FF**'s features. + +# Installation + +## via Composer + +## manual Installation + +# Bootstrapping + +## Bootstrap a web application + +## Bootstrap a command line application + +# Services and the Service Factory + +## Services - a definition + +From the **FF** perspective a service is a singular component (mostly class) that provide needed functionality as part +of certain domain. Common attributes of services should be +- Make minimal assumptions of the surrounding runtime environment. +- Be stateless. +- Be unit testable. + +## Writing services + +[**CONVENTION**] Service classes extend `FF\Services\AbstractService`. +[**CONVENTION**] Services should be located in your project's `MyProject\Services` namespace (or any sub namespace +thereof) to be found by the `ServicesFactory`. + +***Example: Basic service implementation*** + + namespace MyProject\Services; + + use FF\Services\AbstractService; + + class MyService extends AbstractService + { + + } + +**FF** services can be configured if needed. The `ServicesFactory` will initialize a service's instance with available +config options. But it will be your responsibility to validate that any given options will meet your service's +requirements. + +***Example: Configurable service implementation*** + + namespace MyProject\Services; + + use FF\Services\AbstractService; + + class MyConfigurableService extends AbstractService + { + /** + * {@inheritDoc} + */ + protected function validateOptions(array $options, array &$errors): bool + { + // place you option validation logic here + // example + if (!isset($options['some-option'])) { + $errors[] = 'missing options [some-options]'; + return false; + } + + return true; + } + } + +## Using the Service Factory + +**FF**'s service factory is based on the `AbstractSingletonFactory` of the **FF-Factories** component. Be sure to +consult component's [documentation](https://github.com/marcostoll/FF-Factories) for further information about using +**FF** factories. + +To retrieve a service from the factory you have to know thw service's **class identifier**. These class identifiers are +composed of the service's class name prefixed by any sub namespace relative to `FF\Services` (for built-in services) or +`MyProject\Services` (for you custom services). + +For convenience reasons there is a shorthand class `SF` that lets you retrieve one or more services with minimal effort. + +***Example: Getting a single service*** + + use FF\Services\SF; + use MyProject\Services\MyService; + + /** @var MyService $myService */ + $myService = SF::i()->get('MyService'); // finds MyProject\Services\MyService + +***Example: Getting multiple services at once*** + + use FF\Services\Events\EventBroker; + use FF\Services\SF; + use MyProject\Services\MyService; + + /** @var EventBroker $eventBroker */ + /** @var MyService $myService */ + list ($eventBroker, $myService) = SF::i()->get('Events\EventBroker', MyService'); + + +## Extending built-in FF Services + +The `ServicesFactory` uses a `FF\Factories\NamespaceClassLocator` locator to find services definitions bases on a class +identifier. To archive this, it searches the list of registered base namespace for any suitable service class **in the +given order**. + +This feature lets you sub class and replace **FF**'s built-in service implementations easily by just following the +naming conventions and registering the `ServiceFactory` as shown in the **Bootstrapping** section of this document. + +***Example: Extend/Replace a built-in service*** + + namespace MyProject\Services\Runtime; + + use FF\Services\Runtime\ExceptionHandler as FFExceptionHandler; + + class ExceptionHandler extends FFExceptionHandler + { + // place your custom logic here + } + + ############### + + // when ever some component refers to the 'Runtime\ExceptionHandler' service via the ServicesFactory + // an instance of your service extension will be used instead of the built-in service + + /** @var MyProject\Services\Runtime\ExceptionHandler $exceptionHandler */ + $exceptionHandler = SF::get('Runtime\ExceptionHandler'); + + +# Road map + +- Events +- Runtime +- Controllers +- Sessions +- Security +- CLI +- ORM +- Bootstrapping \ No newline at end of file diff --git a/src/Services/AbstractService.php b/src/Services/AbstractService.php new file mode 100644 index 0000000..29f1495 --- /dev/null +++ b/src/Services/AbstractService.php @@ -0,0 +1,93 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services; + +use FF\Services\Exceptions\ConfigurationException; + +/** + * Class AbstractService + * + * @package FF\Services + */ +abstract class AbstractService +{ + use ServiceLocatorTrait; + + /** + * @var array + */ + protected $options; + + /** + * @param array $options + * @throws ConfigurationException + */ + public final function __construct(array $options = []) + { + $this->initialize($options); + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Retrieves a config option + * + * If no option is present indexed with the given $key, the $default value is returned instead. + * + * @param string $key + * @param null $default + * @return mixed + */ + public function getOption(string $key, $default = null) + { + return $this->options[$key] ?? $default; + } + + /** + * Initializes the service + * + * Overwrite this method to place custom service initialization logic. + * + * @param array $options + * @throws ConfigurationException + */ + protected function initialize(array $options) + { + $errors = []; + if (!$this->validateOptions($options, $errors)) { + throw new ConfigurationException($errors); + } + + $this->options = $options; + } + + /** + * Validates the service's options + * + * Fills $errors with messages regarding erroneous service configuration. + * + * Overwrite this method to do configuration validation for any concrete service depending on specific options. + * + * @param array $options + * @param string[] $errors + * @return bool + */ + protected function validateOptions(array $options, array &$errors): bool + { + return true; + } +} \ No newline at end of file diff --git a/src/Services/Exceptions/ConfigurationException.php b/src/Services/Exceptions/ConfigurationException.php new file mode 100644 index 0000000..e591369 --- /dev/null +++ b/src/Services/Exceptions/ConfigurationException.php @@ -0,0 +1,27 @@ + + * @link http://core4.de CORE4 GmbH & Co. KG + * @filesource + */ + +namespace FF\Services\Exceptions; + +/** + * Class ConfigurationException + */ +class ConfigurationException extends \RuntimeException +{ + /** + * @param string[] $errors + * @param int $code + * @param \Throwable $previous + */ + public function __construct(array $errors, int $code = 0, \Throwable $previous = null) + { + parent::__construct(implode(PHP_EOL, $errors), $code, $previous); + } +} diff --git a/src/Services/SF.php b/src/Services/SF.php new file mode 100644 index 0000000..2fa7c42 --- /dev/null +++ b/src/Services/SF.php @@ -0,0 +1,31 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services; + +/** + * Class SF + * + * @package FF\Services + */ +class SF extends ServicesFactory +{ + /** + * Retrieves the ServicesFactory's singleton instance + * + * @return ServicesFactory + */ + public static function i(): ServicesFactory + { + return parent::getInstance(); + } + + +} \ No newline at end of file diff --git a/src/Services/ServiceLocatorTrait.php b/src/Services/ServiceLocatorTrait.php new file mode 100644 index 0000000..8373ceb --- /dev/null +++ b/src/Services/ServiceLocatorTrait.php @@ -0,0 +1,30 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services; + +/** + * Trait ServiceLocatorTrait + * + * @package FF\Services + */ +trait ServiceLocatorTrait +{ + /** + * Retrieves services from the factory + * + * @param string[] $classIdentifiers + * @return AbstractService|AbstractService[] + */ + protected function getService(string ...$classIdentifiers) + { + return SF::i()->get(...$classIdentifiers); + } +} \ No newline at end of file diff --git a/src/Services/ServicesFactory.php b/src/Services/ServicesFactory.php new file mode 100644 index 0000000..b4e1fae --- /dev/null +++ b/src/Services/ServicesFactory.php @@ -0,0 +1,136 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services; + +use FF\Factories\AbstractSingletonFactory; +use FF\Factories\ClassLocators\ClassLocatorInterface; +use FF\Factories\ClassLocators\NamespaceClassLocator; +use FF\Factories\Exceptions\ClassNotFoundException; +use FF\Services\Exceptions\ConfigurationException; + +/** + * Class ServicesFactory + * + * @package FF\Services + */ +class ServicesFactory extends AbstractSingletonFactory +{ + /** + * @var ServicesFactory + */ + protected static $instance; + + /** + * @var array + */ + protected $servicesOptions; + + /** + * Uses a NamespaceClassLocator pre-configured with the FF\Services namespace. + * @param array $servicesOptions + * @see \FF\Factories\ClassLocators\NamespaceClassLocator + */ + public function __construct(array $servicesOptions = []) + { + parent::__construct(new NamespaceClassLocator(__NAMESPACE__)); + + $this->servicesOptions = $servicesOptions; + } + + /** + * Sets the singleton instance of this class + * + * @param ServicesFactory $instance + */ + public static function setInstance(ServicesFactory $instance) + { + self::$instance = $instance; + } + + /** + * Retrieves the singleton instance of this class + * + * @return ServicesFactory + * @throws ConfigurationException + */ + public static function getInstance(): ServicesFactory + { + if (is_null(self::$instance)) { + throw new ConfigurationException(['singleton instance of the service factory has not been initialized']); + } + + return self::$instance; + } + + + /** + * Removes the singleton instance of this class + */ + public static function clearInstance() + { + self::$instance = null; + } + + + /** + * Retrieves one ro more fully initialized services + * + * Returns a single service instance if only one class identifier was given as argument. + * Returns an array of service instances instead if two or more class identifiers were passed. The returned list + * will ordered in the same way as the class identifier arguments have been passed. + * + * @param string ...$classIdentifiers + * @return AbstractService|AbstractService[] + * @throws ClassNotFoundException + * @throws ConfigurationException + */ + public function get(string ...$classIdentifiers) + { + $services = []; + foreach ($classIdentifiers as $classIdentifier) + { + $services[] = parent::create($classIdentifier, $this->getServiceOptions($classIdentifier)); + } + + return count($services) == 1 ? $services[0] : $services; + } + + /** + * {@inheritdoc} + * @return AbstractService + */ + public function create(string $classIdentifier, ...$args) + { + /** @var AbstractService $service */ + $service = parent::create($classIdentifier, ...$args); + return $service; + } + + /** + * {@inheritDoc} + * @return NamespaceClassLocator + */ + public function getClassLocator(): ClassLocatorInterface + { + return parent::getClassLocator(); + } + + /** + * Retrieves the options for a specific service + * + * @param string $classIdentifier + * @return array + */ + public function getServiceOptions(string $classIdentifier) + { + return $this->servicesOptions[$classIdentifier] ?? []; + } +} \ No newline at end of file diff --git a/tests/Services/AbstractServiceTest.php b/tests/Services/AbstractServiceTest.php new file mode 100644 index 0000000..37090d7 --- /dev/null +++ b/tests/Services/AbstractServiceTest.php @@ -0,0 +1,87 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Services; + +use FF\Services\AbstractService; +use FF\Services\Exceptions\ConfigurationException; +use PHPUnit\Framework\TestCase; + +/** + * Test AbstractServiceTest + * + * @package FF\Tests + */ +class AbstractServiceTest extends TestCase +{ + const TEST_OPTIONS = ['foo' => 'bar']; + + /** + * @var MyService + */ + protected $uut; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->uut = new MyService(self::TEST_OPTIONS); + } + + /** + * Tests the namesake method/feature + */ + public function testGetOptions() + { + $this->assertEquals(self::TEST_OPTIONS, $this->uut->getOptions()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetOption() + { + $this->assertEquals(self::TEST_OPTIONS['foo'], $this->uut->getOption('foo')); + $this->assertNull($this->uut->getOption('unknown')); + } + + /** + * Tests the namesake method/feature + */ + public function testGetDefault() + { + $default = 'default'; + $this->assertEquals($default, $this->uut->getOption('unknown', $default)); + } + + /** + * Tests the namesake method/feature + */ + public function testConfigurationException() + { + $this->expectException(ConfigurationException::class); + + new MyService(['foo' => 'baz']); + } +} + +class MyService extends AbstractService +{ + protected function validateOptions(array $options, array &$errors): bool + { + if (isset($options['foo']) && $options['foo'] != 'bar') { + $errors[] = 'foo is not bar'; + return false; + } + + return parent::validateOptions($options, $errors); + } +} \ No newline at end of file diff --git a/tests/Services/SFTest.php b/tests/Services/SFTest.php new file mode 100644 index 0000000..a4cc8dc --- /dev/null +++ b/tests/Services/SFTest.php @@ -0,0 +1,42 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Services; + +use FF\Services\ServicesFactory; +use FF\Services\SF; +use PHPUnit\Framework\TestCase; + +/** + * Test SFTest + * + * @package FF\Tests + */ +class SFTest extends TestCase +{ + /** + * {@inheritdoc} + */ + public static function setUpBeforeClass(): void + { + $servicesFactory = new ServicesFactory(); + + SF::setInstance($servicesFactory); + } + + /** + * Tests the namesake method/feature + */ + public function testI() + { + $sf = SF::i(); + $this->assertInstanceOf(ServicesFactory::class, $sf); + } +} \ No newline at end of file diff --git a/tests/Services/ServicesFactoryTest.php b/tests/Services/ServicesFactoryTest.php new file mode 100644 index 0000000..6e49311 --- /dev/null +++ b/tests/Services/ServicesFactoryTest.php @@ -0,0 +1,126 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Services; + +use FF\Factories\Exceptions\ClassNotFoundException; +use FF\Services\AbstractService; +use FF\Services\Exceptions\ConfigurationException; +use FF\Services\ServicesFactory; +use PHPUnit\Framework\TestCase; + +/** + * Test ServicesFactoryTest + * + * @package FF\Tests + */ +class ServicesFactoryTest extends TestCase +{ + const TEST_OPTIONS = ['ServiceOne' => ['foo' => 'bar']]; + + /** + * {@inheritdoc} + */ + public static function setUpBeforeClass(): void + { + ServicesFactory::clearInstance(); + } + + /** + * Tests the namesake method/feature + */ + public function testGetInstanceConfigException() + { + $this->expectException(ConfigurationException::class); + + ServicesFactory::getInstance(); + } + + /** + * Tests the namesake method/feature + */ + public function testSetGetInstance() + { + $instance = new ServicesFactory(self::TEST_OPTIONS); + $instance->getClassLocator()->prependNamespaces(__NAMESPACE__); + ServicesFactory::setInstance($instance); + + $this->assertSame($instance, ServicesFactory::getInstance()); + } + + /** + * Tests the namesake method/feature + * + * @depends testSetGetInstance + */ + public function testSetServiceOptions() + { + $this->assertEquals( + self::TEST_OPTIONS['ServiceOne'], + ServicesFactory::getInstance()->getServiceOptions('ServiceOne') + ); + $this->assertEquals([], ServicesFactory::getInstance()->getServiceOptions('unknown')); + } + + /** + * Tests the namesake method/feature + * + * @depends testSetGetInstance + */ + public function testGetSingle() + { + $service = ServicesFactory::getInstance()->get('ServiceOne'); + $this->assertInstanceOf(ServiceOne::class, $service); + $this->assertEquals(self::TEST_OPTIONS['ServiceOne'], $service->getOptions()); + } + + /** + * Tests the namesake method/feature + * + * @depends testSetGetInstance + */ + public function testGetMultiples() + { + $services = ServicesFactory::getInstance()->get('ServiceOne', 'ServiceOne'); + $this->assertEquals(2, count($services)); + $this->assertInstanceOf(ServiceOne::class, $services[0]); + $this->assertInstanceOf(ServiceOne::class, $services[1]); + } + + /** + * Tests the namesake method/feature + * + * @depends testSetGetInstance + */ + public function testGetClassNotFound() + { + $this->expectException(ClassNotFoundException::class); + + ServicesFactory::getInstance()->get('ServiceUnknown'); + } +} + +class ServiceOne extends AbstractService +{ + protected function validateOptions(array $options, array &$errors): bool + { + if (isset($options['foo']) && $options['foo'] != 'bar') { + $errors[] = 'foo is not bar'; + return false; + } + + return parent::validateOptions($options, $errors); + } +} + +class ServiceTwo extends AbstractService +{ + +} \ No newline at end of file diff --git a/tests/testsuite.xml b/tests/testsuite.xml new file mode 100644 index 0000000..2a8fd5f --- /dev/null +++ b/tests/testsuite.xml @@ -0,0 +1,8 @@ + + + + + ./ + + + \ No newline at end of file