From 2f41d15be88fcbe781fc2fc5436b5328197778e5 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Fri, 19 Jun 2026 21:48:53 -0500 Subject: [PATCH] setup Signed-off-by: Joey Smith --- .gitattributes | 23 ++ .github/workflows/continuous-integration.yml | 31 ++ .../workflows/release-on-milestone-closed.yml | 15 + .gitignore | 12 + .laminas-ci.json | 12 + .php-cs-fixer.dist.php | 52 ++++ composer.json | 65 ++++ docs/schema-driven-filter-architecture.md | 280 ++++++++++++++++++ phpstan-baseline.neon | 2 + phpstan.neon.dist | 17 ++ phpunit.xml.dist | 25 ++ src/ConfigProvider.php | 36 +++ src/Configuration.php | 102 +++++++ src/ConfigurationInterface.php | 34 +++ .../AttachCoreServicesMiddlewareFactory.php | 19 ++ src/Exception/ContainerException.php | 63 ++++ src/Exception/ExceptionInterface.php | 7 + src/HttpMethodProcessorTrait.php | 63 ++++ .../AttachCoreServicesMiddleware.php | 29 ++ src/SchemaInterface.php | 12 + test/integration/.gitkeep | 0 test/unit/.gitkeep | 0 test/unit/HttpMethodProcessorTraitTest.php | 124 ++++++++ 23 files changed, 1023 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/continuous-integration.yml create mode 100644 .github/workflows/release-on-milestone-closed.yml create mode 100644 .gitignore create mode 100644 .laminas-ci.json create mode 100644 .php-cs-fixer.dist.php create mode 100644 composer.json create mode 100644 docs/schema-driven-filter-architecture.md create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/ConfigProvider.php create mode 100644 src/Configuration.php create mode 100644 src/ConfigurationInterface.php create mode 100644 src/Container/AttachCoreServicesMiddlewareFactory.php create mode 100644 src/Exception/ContainerException.php create mode 100644 src/Exception/ExceptionInterface.php create mode 100644 src/HttpMethodProcessorTrait.php create mode 100644 src/Middleware/AttachCoreServicesMiddleware.php create mode 100644 src/SchemaInterface.php create mode 100644 test/integration/.gitkeep create mode 100644 test/unit/.gitkeep create mode 100644 test/unit/HttpMethodProcessorTraitTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c47d255 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +/.gitattributes export-ignore +/.github/ export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/renovate.json export-ignore +/.laminas-ci.json export-ignore +/phpunit.xml.dist export-ignore +/test/ export-ignore +/clover.xml export-ignore + +# Force our line endings to be LF, even for Windows +* text eol=lf +# Set certain files to be binary +*.png binary +*.jpg binary +*.gif binary +*.tgz binary +*.zip binary +*.tar.gz binary +*.ttf binary +*.svg binary +*.dia binary +*.xcf binary \ No newline at end of file diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..25bc29b --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,31 @@ +name: "Continuous Integration" + +on: + pull_request: + push: + branches: + tags: + +jobs: + matrix: + name: Generate job matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Gather CI configuration + id: matrix + uses: laminas/laminas-ci-matrix-action@v1 + + qa: + name: QA Checks + needs: [matrix] + runs-on: ${{ matrix.operatingSystem }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix.outputs.matrix) }} + steps: + - name: ${{ matrix.name }} + uses: laminas/laminas-continuous-integration-action@v1 + with: + job: ${{ matrix.job }} \ No newline at end of file diff --git a/.github/workflows/release-on-milestone-closed.yml b/.github/workflows/release-on-milestone-closed.yml new file mode 100644 index 0000000..a52f436 --- /dev/null +++ b/.github/workflows/release-on-milestone-closed.yml @@ -0,0 +1,15 @@ +name: "Automatic Releases" + +on: + milestone: + types: + - "closed" + +jobs: + release: + uses: laminas/workflow-automatic-releases/.github/workflows/release-on-milestone-closed.yml@1.x + secrets: + GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }} + GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }} + ORGANIZATION_ADMIN_TOKEN: ${{ secrets.ORGANIZATION_ADMIN_TOKEN }} + SIGNING_SECRET_KEY: ${{ secrets.SIGNING_SECRET_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0720b3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.php-cs-fixer.cache +/.phpunit.cache +/.phpunit.result.cache +/.phpstan-cache +/phpstan.neon +/phpbench.json +/clover.xml +/coveralls-upload.json +/phpunit.xml +/vendor/ +/.vscode +*:Zone.Identifier diff --git a/.laminas-ci.json b/.laminas-ci.json new file mode 100644 index 0000000..10c530b --- /dev/null +++ b/.laminas-ci.json @@ -0,0 +1,12 @@ +{ + "additional_checks": [ + { + "name": "PhpStan", + "job": { + "php": "8.2", + "dependencies": "latest", + "command": "composer require --dev phpstan/phpstan && vendor/bin/phpstan analyse" + } + } + ] +} \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..7e29bbc --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,52 @@ +registerCustomRuleSets([ + new Webware1x0Set(), + new WebwareCopyRight( + packageName: $composerData['name'], + authorName: $composerData['authors'][0]['name'], + authorEmail: $composerData['authors'][0]['email'], + ), + ]) + ->setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually + ->setRiskyAllowed(true) + ->setRules([ + '@Webware/copyright-header' => true, + '@Webware/coding-standard-1.0' => true, + ]) + // 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config + ->setFinder( + (new Finder()) + // 💡 root folder to check + ->in(__DIR__) + ->notPath([ + ]) + ->exclude([ + '.github', + 'stubs', + ]) + // 💡 additional files, eg bin entry file + // ->append([__DIR__.'/bin-entry-file']) + // 💡 folders to exclude, if any + // ->exclude([/* ... */]) + // 💡 path patterns to exclude, if any + // ->notPath([/* ... */]) + // 💡 extra configs + // ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode + // ->ignoreVCS(true) // true by default + ); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3814625 --- /dev/null +++ b/composer.json @@ -0,0 +1,65 @@ +{ + "name": "webware/webware-core", + "description": "Contracts and utilities for the webware-* ecosystem.", + "license": "BSD-3-Clause", + "keywords": [ + "webware", + "core", + "mezzio" + ], + "authors": [ + { + "name": "Joey Smith", + "email": "jsmith@webinertia.net", + "role": "Developer" + } + ], + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "fig/http-message-util": "^1.1", + "psr/http-message": "^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^13.0.0", + "roave/security-advisories": "dev-master", + "webware/coding-standard": "^0.1.0" + }, + "autoload": { + "psr-4": { + "Webware\\Core\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Webware\\CoreTest\\": "test/unit/", + "Webware\\CoreIntegrationTest\\": "test/integration/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true + }, + "scripts": { + "check-all": [ + "@cs-check", + "@sa", + "@test" + ], + "cs-check": "vendor/bin/php-cs-fixer check", + "cs-fix": "vendor/bin/php-cs-fixer fix", + "test": "phpunit --colors=always", + "test-coverage": "phpunit --colors=always --coverage-clover clover.xml", + "sa": "vendor/bin/phpstan analyse --memory-limit=256M", + "sa-gen-baseline": "vendor/bin/phpstan analyse --memory-limit=256M --generate-baseline", + "sa-verbose": "vendor/bin/phpstan analyse --memory-limit=256M -vv", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "php -S 0.0.0.0:8080 -t public/" + ] + } +} diff --git a/docs/schema-driven-filter-architecture.md b/docs/schema-driven-filter-architecture.md new file mode 100644 index 0000000..35b9af7 --- /dev/null +++ b/docs/schema-driven-filter-architecture.md @@ -0,0 +1,280 @@ +# Schema-Driven Filter Architecture + +_Authored: 2026-05-31_ + +--- + +## Problem Statement + +Across all web applications there is a fundamental pain point: a single logical +field must be consistently named and typed across multiple layers — the HTML form +attribute, the request body array key, the filter/validator input name, the +command constructor parameter, and the database column name. In this codebase +that problem manifested as scattered `is_array()` checks, `(int)` casts, and +hardcoded key names duplicated independently in each layer with nothing enforcing +consistency between them. + +The goal of this architecture is a strict convention with a single source of +truth, so that no component independently decides what a field is called or what +constraints apply to it. + +--- + +## The Chain + +Every form submission travels through the following layers: + +``` +HTML Form (field name attributes) + │ + ▼ +Request body array (keys match form field names) + │ + ▼ +Filter class (PSR-7 boundary — the ONLY place raw body is touched) + │ + ▼ +Command / DTO (typed value transport) + │ + ▼ +CommandHandler (entry point to persistence) + │ + ▼ +Repository (DB column mapping) + │ + ▼ +Database +``` + +--- + +## Agreed Filter Convention (interim — before webware-filter ships) + +Until the full schema-driven infrastructure exists, every form endpoint gets a +`*Filter` class following this pattern: + +```php +final class RuleFilter +{ + public readonly string $roleId; + // ... + private bool $valid = false; + + private function __construct(array $body) { /* assign + validate */ } + + public static function fromRequest(ServerRequestInterface $request): self + { + return new self((array) $request->getParsedBody()); + } + + public function isValid(): bool { return $this->valid; } + + public function getValues(): array { /* keyed to command param names */ } +} +``` + +**Hard rules:** +1. `(array)` cast lives **only** in `fromRequest()` — never in middleware +2. `isValid()` is the **only** gate — middleware never inspects individual properties +3. `getValues()` keys **must** match the target command's constructor parameter names exactly +4. No filter logic outside the filter class — ever + +Middleware pattern: +```php +$filter = RuleFilter::fromRequest($request); +if (! $filter->isValid()) { + return $handler->handle($request); // early return, no CommandResult attribute +} +$result = $this->commandBus->handle(new SaveRuleCommand(...$filter->getValues())); +``` + +--- + +## Long-Term Vision: Schema-Driven Architecture + +### Core Insight + +The database schema is the most stable contract in the system. Everything else +— forms, filters, commands, repositories — must honour it anyway. Therefore the +schema should be the single source of truth from which all other artefacts are +derived or validated. + +`phpdb`'s `Metadata` layer already exposes everything needed: + +| `ColumnObject` method | Derivable rule | +|---|---| +| `getDataType()` | type coercion, base filter, form element type | +| `getIsNullable()` | required vs optional | +| `getCharacterMaximumLength()` | `MaxLength(n)` | +| `getNumericPrecision()` / `getNumericScale()` | decimal constraints | +| `isNumericUnsigned()` | min 0 | +| `getColumnDefault()` | default value fallback | +| `getErrata('permitted_values')` | `InArray([...])` for ENUM/SET columns | + +The MySQL metadata source (`phpdb-mysql`) populates `permitted_values` errata by +parsing `INFORMATION_SCHEMA.COLUMN_TYPE` for `ENUM` and `SET` columns, returning +`string[]` of the allowed values. No additional annotation is needed. + +### `ColumnMetadataInterface` + +Lives in **`webware-core`**. Defines the contract that the orchestrator and both +managers consume: + +```php +interface ColumnMetadataInterface +{ + public function getName(): string; + public function getDataType(): string; + public function getIsNullable(): bool; + public function getCharacterMaximumLength(): ?int; + public function isNumericUnsigned(): ?bool; + public function getColumnDefault(): mixed; + public function getErrata(string $name): mixed; +} +``` + +`phpdb`'s `ColumnObject` already satisfies every method but cannot implement +this interface directly (phpdb must not depend on webware packages). The adapter +pattern resolves this — see `webware-orchestra` below. + +### Package Responsibilities + +``` +webware-core + Defines: ColumnMetadataInterface + All other webware packages already depend on this — no new dependency introduced. + +phpdb / phpdb-mysql + Unchanged. ColumnObject remains as-is. + Metadata\Source (MySQL) populates ColumnObject including errata for ENUM/SET. + +webware-filter + Depends on: webware-core + Owns: FilterManager, AggregateFilter, InputFilterInterface (mirrors Laminas) + Consumes: ColumnMetadataInterface + Knows nothing of: ColumnObject, phpdb, webware-form, webware-orchestra + FilterManager alias map: dataType string → filter chain configuration + Example aliases: + 'varchar' → StringTrim + MaxLength(n) + 'enum' → InArray(permitted_values) + 'json' → JsonDecode transform + 'int' → Digits + optional min 0 if unsigned + 'boolean' → Boolean + +webware-form (when laminas-form is compatible with laminas-servicemanager v4) + Depends on: webware-core + Owns: FormElementManager + Consumes: ColumnMetadataInterface + Knows nothing of: ColumnObject, phpdb, webware-filter, webware-orchestra + FormElementManager alias map: dataType string → form element class + Example aliases: + 'varchar' → Text + 'enum' → Select (options from permitted_values) + 'json' → Hidden + 'int' → Number + 'boolean' → Checkbox + +webware-orchestra + Depends on: webware-core, phpdb, webware-filter, (later) webware-form + Owns: ColumnObjectAdapter (implements ColumnMetadataInterface, wraps ColumnObject) + AggregateFilterFactory (orchestrator — takes table name, builds filter) + Column type → alias mapping config + ConfigProvider: opt-in — wires everything together, nothing breaks if absent + This is the ONLY package that knows about both phpdb and webware-filter. +``` + +### Dependency Graph + +``` +webware-core ◄──── webware-filter +webware-core ◄──── webware-form +webware-core ◄──── webware-orchestra +phpdb ◄──── webware-orchestra +webware-filter ◄── webware-orchestra +webware-form ◄── webware-orchestra (future) + +phpdb (no webware dependencies) +webware-filter (no phpdb dependency) +webware-form (no phpdb dependency) +``` + +### The Adapter + +`webware-orchestra\Adapter\ColumnObjectAdapter` is the seam between `phpdb` and +the rest of the ecosystem: + +```php +final class ColumnObjectAdapter implements ColumnMetadataInterface +{ + public function __construct(private readonly ColumnObject $column) {} + + public function getName(): string { return $this->column->getName(); } + public function getDataType(): string { return $this->column->getDataType(); } + public function getIsNullable(): bool { return $this->column->getIsNullable() ?? false; } + public function getCharacterMaximumLength(): ?int { return $this->column->getCharacterMaximumLength(); } + public function isNumericUnsigned(): ?bool { return $this->column->isNumericUnsigned(); } + public function getColumnDefault(): mixed { return $this->column->getColumnDefault(); } + public function getErrata(string $name): mixed { return $this->column->getErrata($name); } +} +``` + +### Orchestrator Flow + +``` +AggregateFilterFactory::fromTable(string $table, AdapterInterface $adapter) + │ + ├─ Metadata::getColumns($table) + │ returns ColumnObject[] + │ + ├─ foreach ColumnObject → wrap in ColumnObjectAdapter + │ + ├─ foreach ColumnMetadataInterface: + │ $dataType = $col->getDataType() // 'varchar', 'enum', etc. + │ $filterChain = FilterManager::get($dataType, $col) + │ configure chain: MaxLength, InArray, Digits etc from ColumnObject methods + │ + └─ return AggregateFilter (isValid() / getValues()) +``` + +### Laminas Interoperability + +`webware-filter` defines its own `InputFilterInterface` mirroring the Laminas +`InputFilterInterface` signatures exactly: +- `setData(iterable $data): void` +- `isValid(): bool` +- `getValues(): array` +- `getMessages(): array` + +When `laminas/laminas-inputfilter` ships a release compatible with +`laminas/laminas-servicemanager` v4, `webware-filter` makes its interface extend +the Laminas one (or deprecates in favour of it). No consuming code changes because +the method signatures are identical. The migration is a `composer require` swap, +not a refactor. + +### Naming Convention — The Key Alignment Problem + +The one piece that cannot be derived from the schema automatically is the mapping +between DB column names (`role_id`) and command constructor parameter names +(`$roleId`). This is provided as a developer-owned array in the filter class or +orchestrator configuration: + +```php +['role_id' => 'roleId', 'resource_id' => 'resourceId', 'rule_type' => 'type'] +``` + +By convention, `getValues()` on any filter class returns keys matching the target +command's constructor parameter names exactly, so the command can be constructed +with `new SaveRuleCommand(...$filter->getValues())`. + +--- + +## Current Status + +- `ColumnMetadataInterface` — **not yet created** (defined here, to be implemented) +- `webware-filter` package — **not yet created** +- `webware-orchestra` package — **not yet created** +- Interim `*Filter` convention — **agreed, being applied** starting with `RuleFilter` + in `webware-acl` +- `laminas-form` / `laminas-inputfilter` / `laminas-validator` compatible with + servicemanager v4 — **not yet released**, architecture designed to accommodate + them when they land diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..364905f --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..1f64dd8 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,17 @@ +includes: + - phpstan-baseline.neon +parameters: + level: 10 + paths: + - src + - test + universalObjectCratesClasses: + - Laminas\Stdlib\ArrayObject + stubFiles: + - stubs/Laminas/ServiceManager/Factory/AbstractFactoryInterface.stub + - stubs/Laminas/ServiceManager/Factory/DelegatorFactoryInterface.stub + - stubs/Laminas/ServiceManager/Factory/FactoryInterface.stub + - stubs/Laminas/ServiceManager/Factory/InvokableFactory.stub + - stubs/Laminas/ServiceManager/Initializer/InitializerInterface.stub + - stubs/Psr/Container/ContainerInterface.stub + treatPhpDocTypesAsCertain: false \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ab3e431 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + test/unit + + + test/integration + + + + + src + + + \ No newline at end of file diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php new file mode 100644 index 0000000..589abbe --- /dev/null +++ b/src/ConfigProvider.php @@ -0,0 +1,36 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Core; + +final class ConfigProvider +{ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + ]; + } + + public function getDependencies(): array + { + return [ + 'aliases' => [], + 'invokables' => [], + 'factories' => [ + Middleware\AttachCoreServicesMiddleware::class => Container\AttachCoreServicesMiddlewareFactory::class, + ], + ]; + } +} diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 0000000..45d94e3 --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,102 @@ +has('config')) { + throw Exception\ContainerException::forMissingConfigService('config', $callingFactory); + } + + $config = $container->get('config'); + if (! isset($config[static::CONFIG_KEY])) { + throw Exception\ContainerException::forMissingConfigKey(static::CONFIG_KEY, $callingFactory); + } + if ( + ! is_array($config[static::CONFIG_KEY]) || $config[static::CONFIG_KEY] === [] + ) { + throw Exception\ContainerException::forInvalidConfigType(static::CONFIG_KEY, 'non-empty array', get_debug_type($config[static::CONFIG_KEY]), $callingFactory); + } + + return $config[static::CONFIG_KEY]; + } + + public static function getAdminRouteSegment(ContainerInterface $container, string $callingFactory): string + { + $config = static::getConfig($container, $callingFactory); + + if (! isset($config[static::ADMIN_ROUTE_SEGMENT_KEY])) { + throw Exception\ContainerException::forMissingConfigKey(static::ADMIN_ROUTE_SEGMENT_KEY, $callingFactory); + } + + if ( + ! is_string($config[static::ADMIN_ROUTE_SEGMENT_KEY]) + || $config[static::ADMIN_ROUTE_SEGMENT_KEY] === '' + ) { + throw Exception\ContainerException::forInvalidConfigType(static::ADMIN_ROUTE_SEGMENT_KEY, 'non-empty string', get_debug_type($config[static::ADMIN_ROUTE_SEGMENT_KEY]), $callingFactory); + } + + return $config[static::ADMIN_ROUTE_SEGMENT_KEY]; + } + + public static function getAdminRouteNamePrefix(ContainerInterface $container, string $callingFactory): string + { + $config = static::getConfig($container, $callingFactory); + + if (! isset($config[static::ADMIN_ROUTE_NAME_PREFIX_KEY])) { + throw Exception\ContainerException::forMissingConfigKey(static::ADMIN_ROUTE_NAME_PREFIX_KEY, $callingFactory); + } + if ( + ! is_string($config[static::ADMIN_ROUTE_NAME_PREFIX_KEY]) + || $config[static::ADMIN_ROUTE_NAME_PREFIX_KEY] === '' + ) { + throw Exception\ContainerException::forInvalidConfigType(static::ADMIN_ROUTE_NAME_PREFIX_KEY, 'non-empty string', get_debug_type($config[static::ADMIN_ROUTE_NAME_PREFIX_KEY]), $callingFactory); + } + + return $config[static::ADMIN_ROUTE_NAME_PREFIX_KEY]; + } + + public static function getRouteSegment(ContainerInterface $container, string $callingFactory): string + { + $config = static::getConfig($container, $callingFactory); + + if (! isset($config[static::ROUTE_SEGMENT_KEY])) { + throw Exception\ContainerException::forMissingConfigKey(static::ROUTE_SEGMENT_KEY, $callingFactory); + } + if ( + ! is_string($config[static::ROUTE_SEGMENT_KEY]) + || $config[static::ROUTE_SEGMENT_KEY] === '' + ) { + throw Exception\ContainerException::forInvalidConfigType(static::ROUTE_SEGMENT_KEY, 'non-empty string', get_debug_type($config[static::ROUTE_SEGMENT_KEY]), $callingFactory); + } + + return $config[static::ROUTE_SEGMENT_KEY]; + } + + public static function getRouteNamePrefix(ContainerInterface $container, string $callingFactory): string + { + $config = static::getConfig($container, $callingFactory); + + if (! isset($config[static::ROUTE_NAME_PREFIX_KEY])) { + throw Exception\ContainerException::forMissingConfigKey(static::ROUTE_NAME_PREFIX_KEY, $callingFactory); + } + if ( + ! is_string($config[static::ROUTE_NAME_PREFIX_KEY]) + || $config[static::ROUTE_NAME_PREFIX_KEY] === '' + ) { + throw Exception\ContainerException::forInvalidConfigType(static::ROUTE_NAME_PREFIX_KEY, 'non-empty string', get_debug_type($config[static::ROUTE_NAME_PREFIX_KEY]), $callingFactory); + } + + return $config[static::ROUTE_NAME_PREFIX_KEY]; + } +} diff --git a/src/ConfigurationInterface.php b/src/ConfigurationInterface.php new file mode 100644 index 0000000..edeb986 --- /dev/null +++ b/src/ConfigurationInterface.php @@ -0,0 +1,34 @@ +get(InputFilterPluginManager::class) + ); + } +} diff --git a/src/Exception/ContainerException.php b/src/Exception/ContainerException.php new file mode 100644 index 0000000..1282c15 --- /dev/null +++ b/src/Exception/ContainerException.php @@ -0,0 +1,63 @@ +getMethod()) { + RequestMethodInterface::METHOD_GET => $this->processGet($request, $handler), + RequestMethodInterface::METHOD_POST => $this->processPost($request, $handler), + RequestMethodInterface::METHOD_PATCH, + RequestMethodInterface::METHOD_PUT => $this->processPatch($request, $handler), + RequestMethodInterface::METHOD_DELETE => $this->processDelete($request, $handler), + default => throw new DomainException('Unsupported HTTP method: ' . $request->getMethod()), + }; + } + + public function processGet( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + return $handler->handle($request); + } + + public function processPost( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + return $handler->handle($request); + } + + public function processPatch( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + return $handler->handle($request); + } + + public function processDelete( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + return $handler->handle($request); + } +} diff --git a/src/Middleware/AttachCoreServicesMiddleware.php b/src/Middleware/AttachCoreServicesMiddleware.php new file mode 100644 index 0000000..e085702 --- /dev/null +++ b/src/Middleware/AttachCoreServicesMiddleware.php @@ -0,0 +1,29 @@ +handle( + $request + ->withAttribute( + InputFilterPluginManager::class, + $this->inputFilterPluginManager + ) + ); + } +} diff --git a/src/SchemaInterface.php b/src/SchemaInterface.php new file mode 100644 index 0000000..5d0992c --- /dev/null +++ b/src/SchemaInterface.php @@ -0,0 +1,12 @@ +middleware = new class() implements MiddlewareInterface { + use HttpMethodProcessorTrait; + + /** @var string[] */ + public array $called = []; + + public function processGet( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + $this->called[] = 'GET'; + + return $handler->handle($request); + } + + public function processPost( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + $this->called[] = 'POST'; + + return $handler->handle($request); + } + + public function processPatch( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + $this->called[] = 'PATCH'; + + return $handler->handle($request); + } + + public function processDelete( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + $this->called[] = 'DELETE'; + + return $handler->handle($request); + } + }; + } + + /** @return array */ + public static function verbProvider(): array + { + return [ + 'GET dispatches to processGet' => ['GET', 'GET'], + 'POST dispatches to processPost' => ['POST', 'POST'], + 'PATCH dispatches to processPatch' => ['PATCH', 'PATCH'], + 'PUT dispatches to processPatch' => ['PUT', 'PATCH'], + 'DELETE dispatches to processDelete' => ['DELETE', 'DELETE'], + ]; + } + + #[Test] + #[DataProvider('verbProvider')] + public function itDispatchesToCorrectMethod(string $httpMethod, string $expectedMethod): void + { + $request = new ServerRequest([], [], '/', $httpMethod); + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn(new EmptyResponse()); + + $this->middleware->process($request, $handler); + + self::assertSame([$expectedMethod], $this->middleware->called); + } + + #[Test] + public function itThrowsDomainExceptionOnUnknownMethod(): void + { + $request = new ServerRequest([], [], '/', 'TRACE'); + $handler = $this->createStub(RequestHandlerInterface::class); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Unsupported HTTP method: TRACE'); + + $this->middleware->process($request, $handler); + } + + #[Test] + public function defaultPassThroughCallsHandler(): void + { + $middleware = new class() implements MiddlewareInterface { + use HttpMethodProcessorTrait; + }; + + $expectedResponse = new EmptyResponse(); + $request = new ServerRequest([], [], '/', 'GET'); + $handler = $this->createStub(RequestHandlerInterface::class); + $handler->method('handle')->willReturn($expectedResponse); + + $response = $middleware->process($request, $handler); + + self::assertSame($expectedResponse, $response); + } +}