From 04135fdf54207bca6b7af26e62fc08802a0f13e2 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Thu, 20 Aug 2026 19:19:00 -0300 Subject: [PATCH 01/10] feat: establish expressive API foundation --- .github/workflows/ci.yml | 26 + .gitignore | 4 + README.md | 92 +- composer.json | 20 + composer.lock | 1891 ++++++++++++++++++++++++ docs/API-2.md | 74 + phpstan.neon | 8 + phpunit.xml | 18 + src/App.php | 107 +- src/CallableRequestHandler.php | 4 + src/Container/Binding.php | 20 + src/Container/BindingLifetime.php | 13 + src/Container/Container.php | 186 +++ src/ControllerHandler.php | 38 + src/HandlerResolver.php | 41 + src/Http/HttpException.php | 20 + src/Http/JsonResource.php | 33 + src/Http/ProblemCode.php | 18 + src/Http/ResourceCollection.php | 41 + src/Http/Responsable.php | 14 + src/PendingRoute.php | 43 + src/Route.php | 11 +- src/RouteConstraint.php | 28 + src/RouteRegistrar.php | 113 ++ src/Router.php | 34 +- src/Validation/EnumRule.php | 24 + src/Validation/FormRequest.php | 90 ++ src/Validation/Rule.php | 15 + src/Validation/ValidationException.php | 23 + src/Validation/ValidationRule.php | 11 + tests/Container/ContainerTest.php | 60 + tests/ControllerHandlerTest.php | 47 + tests/Fixtures/LoginController.php | 33 + tests/Fixtures/LoginRequest.php | 18 + tests/Fixtures/LoginService.php | 14 + tests/RouteRegistrarTest.php | 44 + tests/RouterFluentTest.php | 57 + tests/ValidationAndResourceTest.php | 53 + tests/bootstrap.php | 9 + 39 files changed, 3365 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 composer.lock create mode 100644 docs/API-2.md create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/Container/Binding.php create mode 100644 src/Container/BindingLifetime.php create mode 100644 src/Container/Container.php create mode 100644 src/ControllerHandler.php create mode 100644 src/HandlerResolver.php create mode 100644 src/Http/HttpException.php create mode 100644 src/Http/JsonResource.php create mode 100644 src/Http/ProblemCode.php create mode 100644 src/Http/ResourceCollection.php create mode 100644 src/Http/Responsable.php create mode 100644 src/PendingRoute.php create mode 100644 src/RouteConstraint.php create mode 100644 src/RouteRegistrar.php create mode 100644 src/Validation/EnumRule.php create mode 100644 src/Validation/FormRequest.php create mode 100644 src/Validation/Rule.php create mode 100644 src/Validation/ValidationException.php create mode 100644 src/Validation/ValidationRule.php create mode 100644 tests/Container/ContainerTest.php create mode 100644 tests/ControllerHandlerTest.php create mode 100644 tests/Fixtures/LoginController.php create mode 100644 tests/Fixtures/LoginRequest.php create mode 100644 tests/Fixtures/LoginService.php create mode 100644 tests/RouteRegistrarTest.php create mode 100644 tests/RouterFluentTest.php create mode 100644 tests/ValidationAndResourceTest.php create mode 100644 tests/bootstrap.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b8f8fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - uses: ramsey/composer-install@v3 + - run: composer verify + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9627cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/.build/ +/.phpunit.cache/ + diff --git a/README.md b/README.md index 7605545..d081471 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ -# pushinbr/pam-api +# PAM API -The optional Express-like HTTP layer for Pam: route parameters, 404/405 handling, -a precompiled middleware pipeline, error boundaries and Composer provider -discovery. +Express-like routing. Laravel-like application structure. PAM-native execution. ```bash pam composer require pushinbr/pam-api @@ -10,13 +8,95 @@ pam composer require pushinbr/pam-api ```php use Pam\App; +use Pam\Api\RouteConstraint; $app = new App(); -$app->get('/users/{id}', static fn ($request, $response) => - $response->json(['id' => $request->route('id')])); + +$app->post('/login', [LoginController::class, 'onLogin']); + +$app->get('/users/{id}', [UserController::class, 'show']) + ->where('id', RouteConstraint::Integer) + ->name('users.show'); + $app->listen(3000); ``` +Controllers are resolved through the container. Both constructor dependencies +and action parameters are injected: + +```php +final readonly class LoginController +{ + public function __construct(private LoginService $login) {} + + public function onLogin(LoginRequest $request): AuthResource + { + return new AuthResource($this->login->handle($request->validated())); + } +} +``` + +## Route groups + +```php +$app->prefix('/api/v1') + ->middleware(Authenticate::class) + ->group(function (RouteRegistrar $routes): void { + $routes->apiResource('/users', UserController::class); + $routes->post('/login', [LoginController::class, 'onLogin']); + }); +``` + +Global, group and route middleware use the same PAM middleware contract. + +## Container lifetimes + +```php +$app->container()->bind(UserRepository::class, DatabaseUserRepository::class); +$app->container()->singleton(Cache::class, RedisCache::class); +$app->container()->scoped(CurrentUser::class); +``` + +`scoped` values are created once per request and discarded even when the +handler throws. This boundary is essential for PAM's persistent workers. + +## Validation and resources + +```php +final class LoginRequest extends FormRequest +{ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'type' => ['required', Rule::enum(UserType::class)], + ]; + } +} + +enum UserType: int +{ + case Regular = 1; + case Administrator = 2; +} +``` + +Return a `JsonResource` from a handler to receive a consistent `data` envelope. +Validation failures use Problem Details with stable sequential integer codes. + +## Quality gate + +```bash +composer install +composer verify +``` + +The verification gate runs PHPStan at level 9 and the PHPUnit suite on every +supported PHP version. + +See the [PAM API 2 design and delivery contract](docs/API-2.md) for the complete +15-track implementation plan and current delivery status. + ## License Free and open-source under the [Apache License 2.0](LICENSE). You may use, diff --git a/composer.json b/composer.json index 7c7efaa..7ed4eda 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,27 @@ "Pam\\Api\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Pam\\Api\\Tests\\": "tests/" + }, + "files": [ + "tests/bootstrap.php" + ] + }, + "scripts": { + "analyse": "phpstan analyse --configuration=phpstan.neon --memory-limit=1G", + "test": "phpunit --configuration=phpunit.xml", + "verify": [ + "@analyse", + "@test" + ] + }, "config": { "sort-packages": true + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.5" } } diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..82e53c6 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1891 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "aee5ffae5417c0b267105094e1e9626a", + "packages": [ + { + "name": "pushinbr/pam-core-api", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/push-in/pam-core-api.git", + "reference": "67de4c55b9c0ca6f14c1f61dbd1a575b0b68f48a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/push-in/pam-core-api/zipball/67de4c55b9c0ca6f14c1f61dbd1a575b0b68f48a", + "reference": "67de4c55b9c0ca6f14c1f61dbd1a575b0b68f48a", + "shasum": "" + }, + "require": { + "php": "^8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pam\\Contracts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Stable PHP contracts for the Pam persistent runtime.", + "homepage": "https://github.com/push-in/pam", + "keywords": [ + "contracts", + "pam", + "php", + "runtime" + ], + "support": { + "issues": "https://github.com/push-in/pam/issues", + "source": "https://github.com/push-in/pam-core-api" + }, + "time": "2026-08-10T23:21:29+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.8", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", + "reference": "e285254e60f33c21902efef4a926ca0987c06804", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-08-04T22:21:45+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.56", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:52:39+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/docs/API-2.md b/docs/API-2.md new file mode 100644 index 0000000..39b96a4 --- /dev/null +++ b/docs/API-2.md @@ -0,0 +1,74 @@ +# PAM API 2 design and delivery contract + +PAM API combines Express-style route ergonomics with Laravel-style application +boundaries. Small applications may use closures. Product applications can use +controllers, request objects, services, repositories and resources without +changing runtimes. + +## Design rules + +1. Public APIs are explicit, typed and friendly to static analysis. +2. Reflection is compiled or cached before serving production traffic. +3. Request-specific values use request scope and are never retained by workers. +4. Controllers orchestrate; services own use cases; repositories own persistence. +5. Input uses Form Requests/DTOs and domain output uses Resources. +6. Status/type/state/kind/category codes are sequential integer-backed enums. +7. Every unbounded operation requires limits, cancellation and observability. +8. Optional integrations depend on contracts so the HTTP core stays small. + +## Handler forms + +All handler forms resolve through the same pipeline: + +```php +$app->get('/health', static fn (Request $request, Response $response) => + $response->json(['status' => 1])); + +$app->post('/orders', CreateOrderController::class); + +$app->post('/login', [LoginController::class, 'onLogin']); +``` + +Class-and-method handlers are validated during route registration. Controllers +are resolved by the container, constructor dependencies are autowired, and +method parameters may receive request/response objects, container dependencies +and route parameters by name. + +## Fifteen delivery tracks + +| # | Track | Contract | +| --- | --- | --- | +| 1 | Application experience | Closures and structured applications share one runtime. | +| 2 | Router | Groups, prefixes, constraints, names, resources and compiled matching. | +| 3 | Dependency injection | Transient, singleton and request-scoped lifetimes. | +| 4 | Form Requests and DTOs | Authorization, validation and typed input hydration. | +| 5 | Resources | Stable `data`/`meta` domain response envelopes. | +| 6 | Route binding | Typed entity resolution with explicit lookup keys. | +| 7 | Middleware | Global, group and route layers with parameterized policies. | +| 8 | Authentication | Token strategies, policies, abilities and current principal. | +| 9 | Errors | Problem Details, integer error codes and safe production rendering. | +| 10 | OpenAPI | Contract generation, compatibility checks and typed clients. | +| 11 | Production primitives | Idempotency, transactions, cache, timeout, retry, circuit breaker, jobs, events, SSE and sockets. | +| 12 | Distributed rate limiting | Pluggable stores and safe client-key resolution. | +| 13 | Multi-tenancy | Request-scoped tenant resolution and isolation. | +| 14 | Testing | In-memory client, fakes, contract assertions and leak assertions. | +| 15 | Observability | Normalized route metrics, traces, logs and slow-request diagnostics. | + +## Current implementation status + +The `feat/api-2-foundation` development line currently implements: + +- class-and-method controller handlers; +- constructor and method dependency injection; +- transient, singleton and request-scoped container lifetimes; +- named routes, built-in/custom constraints and per-route middleware; +- composable prefixes and route groups; +- API resource route registration; +- Form Request authorization/validation and integer enum validation; +- Problem Details validation responses; +- JSON Resources and Resource Collections; +- PHPUnit and PHPStan level 9 verification. + +Remaining tracks will land behind stable contracts with tests before they are +documented as production-ready. + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..0a7fee4 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: 9 + paths: + - src + - tests + tmpDir: .build/phpstan + treatPhpDocTypesAsCertain: true + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..bcdee5f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + tests + + + + + src + + + + diff --git a/src/App.php b/src/App.php index 0f37ada..c514c43 100644 --- a/src/App.php +++ b/src/App.php @@ -8,10 +8,15 @@ use Pam\Contracts\Http\MiddlewareInterface; use Pam\Contracts\Package\ServiceProviderInterface; use Pam\Api\CallableRequestHandler; +use Pam\Api\Container\Container; +use Pam\Api\HandlerResolver; +use Pam\Api\Http\HttpException; use Pam\Api\PackageDiscovery; +use Pam\Api\PendingRoute; use Pam\Api\Pipeline; use Pam\Api\Router; use Pam\Api\RoutingResultType; +use Pam\Api\RouteRegistrar; use Pam\Http\Request; use Pam\Http\Response; use Pam\Http\Server as HttpServer; @@ -21,6 +26,10 @@ final class App implements ApplicationInterface { private readonly Router $router; + private readonly Container $container; + + private readonly HandlerResolver $handlerResolver; + /** @var list */ private array $middleware = []; @@ -38,16 +47,23 @@ final class App implements ApplicationInterface private bool $frozen = false; - public function __construct(bool $discoverPackages = true) + public function __construct(bool $discoverPackages = true, ?Container $container = null) { $this->router = new Router(); + $this->container = $container ?? new Container(); + $this->handlerResolver = new HandlerResolver($this->container); + $this->container->instance(self::class, $this); + $this->container->instance(Container::class, $this->container); $this->errorHandler = static function (\Throwable $error, Response $response): Response { - \Pam\Observability\Telemetry::log('error', 'Unhandled Pam API exception', [ - 'exception' => $error::class, - 'message' => $error->getMessage(), - 'file' => $error->getFile(), - 'line' => $error->getLine(), - ]); + $telemetry = ['Pam\\Observability\\Telemetry', 'log']; + if (is_callable($telemetry)) { + $telemetry('error', 'Unhandled Pam API exception', [ + 'exception' => $error::class, + 'message' => $error->getMessage(), + 'file' => $error->getFile(), + 'line' => $error->getLine(), + ]); + } return $response->json(['error' => 'Internal Server Error'], 500); }; @@ -61,38 +77,59 @@ public function __construct(bool $discoverPackages = true) } } - public function get(string $path, callable $handler): self + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function get(string $path, callable|string|array $handler): PendingRoute { - return $this->route('GET', $path, $handler); + return $this->registerRoute('GET', $path, $handler); } - public function post(string $path, callable $handler): self + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function post(string $path, callable|string|array $handler): PendingRoute { - return $this->route('POST', $path, $handler); + return $this->registerRoute('POST', $path, $handler); } - public function put(string $path, callable $handler): self + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function put(string $path, callable|string|array $handler): PendingRoute { - return $this->route('PUT', $path, $handler); + return $this->registerRoute('PUT', $path, $handler); } - public function patch(string $path, callable $handler): self + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function patch(string $path, callable|string|array $handler): PendingRoute { - return $this->route('PATCH', $path, $handler); + return $this->registerRoute('PATCH', $path, $handler); } - public function delete(string $path, callable $handler): self + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function delete(string $path, callable|string|array $handler): PendingRoute { - return $this->route('DELETE', $path, $handler); + return $this->registerRoute('DELETE', $path, $handler); } - public function route(string $method, string $path, callable $handler): self + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function route(string $method, string $path, callable|string|array $handler): self { - $this->assertMutable(); - $this->router->add($method, $path, $handler); + $this->registerRoute($method, $path, $handler); return $this; } + public function container(): Container + { + return $this->container; + } + + public function prefix(string $prefix): RouteRegistrar + { + return new RouteRegistrar($this, $prefix); + } + + /** @param callable(RouteRegistrar): void $routes */ + public function group(callable $routes): void + { + (new RouteRegistrar($this))->group($routes); + } + public function middleware(object|callable $middleware): self { $this->assertMutable(); @@ -152,16 +189,30 @@ public function listen(int $port, string $host = '127.0.0.1', array $options = [ public function handle(Request $request, Response $response): Response { $this->freeze(); + $this->container->beginScope(); + $this->container->scopedInstance(Request::class, $request); + $this->container->scopedInstance(Response::class, $response); try { return $this->pipeline?->handle($request, $response) ?? throw new \LogicException('Pam API pipeline was not compiled.'); } catch (\Throwable $error) { + if ($error instanceof HttpException) { + return $response->json([ + 'type' => 'https://pam.dev/problems/' . $error->problemCode->value, + 'title' => $error->getMessage(), + 'status' => $error->status, + 'code' => $error->problemCode->value, + ...$error->details, + ], $error->status); + } $handler = $this->errorHandler; $result = $handler($error, $response); if (!$result instanceof Response) { throw new \UnexpectedValueException('The Pam error handler must return Response.'); } return $result; + } finally { + $this->container->endScope(); } } @@ -178,7 +229,11 @@ private function dispatchRoute(Request $request, Response $response): Response } $route = $result->route ?? throw new \LogicException('A matched route must contain a handler.'); $request = $request->withRouteParameters($result->parameters); - return (new CallableRequestHandler($route->handler))->handle($request, $response); + $destination = new CallableRequestHandler($route->handler); + if ($route->middleware === []) { + return $destination->handle($request, $response); + } + return (new Pipeline($route->middleware, $destination))->handle($request, $response); } private function freeze(): void @@ -202,4 +257,14 @@ private function assertMutable(): void throw new \LogicException('Pam application configuration is frozen after it starts handling requests.'); } } + + /** + * @param callable|class-string|array{class-string, non-empty-string} $handler + */ + private function registerRoute(string $method, string $path, callable|string|array $handler): PendingRoute + { + $this->assertMutable(); + $route = $this->router->register($method, $path, $this->handlerResolver->resolve($handler)); + return new PendingRoute($this->router, $route); + } } diff --git a/src/CallableRequestHandler.php b/src/CallableRequestHandler.php index 454072f..92c0ff4 100644 --- a/src/CallableRequestHandler.php +++ b/src/CallableRequestHandler.php @@ -4,6 +4,7 @@ namespace Pam\Api; +use Pam\Api\Http\Responsable; use Pam\Contracts\Http\RequestHandlerInterface; use Pam\Http\Request; use Pam\Http\Response; @@ -23,6 +24,9 @@ public function handle(Request $request, Response $response): Response if ($result instanceof Response) { return $result; } + if ($result instanceof Responsable) { + return $result->toResponse($request, $response); + } if ($result !== null && $response->isEmpty()) { $response->send($result); } diff --git a/src/Container/Binding.php b/src/Container/Binding.php new file mode 100644 index 0000000..e75c652 --- /dev/null +++ b/src/Container/Binding.php @@ -0,0 +1,20 @@ +factory = \Closure::fromCallable($factory); + } +} + diff --git a/src/Container/BindingLifetime.php b/src/Container/BindingLifetime.php new file mode 100644 index 0000000..f88297a --- /dev/null +++ b/src/Container/BindingLifetime.php @@ -0,0 +1,13 @@ + */ + private array $bindings = []; + + /** @var array */ + private array $singletons = []; + + /** @var array */ + private array $scoped = []; + + private bool $scopeActive = false; + + /** @param class-string|string $id */ + public function bind(string $id, callable|string|null $factory = null): self + { + return $this->register($id, $factory, BindingLifetime::Transient); + } + + /** @param class-string|string $id */ + public function singleton(string $id, callable|string|null $factory = null): self + { + return $this->register($id, $factory, BindingLifetime::Singleton); + } + + /** @param class-string|string $id */ + public function scoped(string $id, callable|string|null $factory = null): self + { + return $this->register($id, $factory, BindingLifetime::Scoped); + } + + public function instance(string $id, mixed $instance): self + { + $this->singletons[$id] = $instance; + return $this; + } + + public function scopedInstance(string $id, mixed $instance): self + { + if (!$this->scopeActive) { + throw new \LogicException("Scoped entry {$id} cannot be registered outside a request scope."); + } + $this->scoped[$id] = $instance; + return $this; + } + + public function beginScope(): void + { + if ($this->scopeActive) { + throw new \LogicException('A PAM API container scope is already active.'); + } + $this->scoped = []; + $this->scopeActive = true; + } + + public function endScope(): void + { + $this->scoped = []; + $this->scopeActive = false; + } + + public function get(string $id): mixed + { + if (array_key_exists($id, $this->singletons)) { + return $this->singletons[$id]; + } + if (array_key_exists($id, $this->scoped)) { + return $this->scoped[$id]; + } + + $binding = $this->bindings[$id] ?? null; + if ($binding === null) { + if (!class_exists($id)) { + throw new \RuntimeException("Container entry {$id} is not bound and is not a class."); + } + return $this->build($id); + } + + $value = ($binding->factory)($this); + if ($binding->lifetime === BindingLifetime::Singleton) { + $this->singletons[$id] = $value; + } elseif ($binding->lifetime === BindingLifetime::Scoped) { + if (!$this->scopeActive) { + throw new \LogicException("Scoped entry {$id} was resolved outside a request scope."); + } + $this->scoped[$id] = $value; + } + return $value; + } + + /** + * @param array{object, string} $callable + * @param array $named + * @param list $provided + */ + public function call(array $callable, array $named = [], array $provided = []): mixed + { + $reflection = new \ReflectionMethod($callable[0], $callable[1]); + $arguments = $this->resolveParameters($reflection->getParameters(), $named, $provided); + return $reflection->invokeArgs($callable[0], $arguments); + } + + /** @param class-string $class */ + private function build(string $class): object + { + $reflection = new \ReflectionClass($class); + if (!$reflection->isInstantiable()) { + throw new \RuntimeException("Container entry {$class} is not instantiable."); + } + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return $reflection->newInstance(); + } + return $reflection->newInstanceArgs( + $this->resolveParameters($constructor->getParameters()), + ); + } + + /** + * @param list<\ReflectionParameter> $parameters + * @param array $named + * @param list $provided + * @return list + */ + private function resolveParameters(array $parameters, array $named = [], array $provided = []): array + { + $arguments = []; + foreach ($parameters as $parameter) { + if (array_key_exists($parameter->getName(), $named)) { + $arguments[] = $named[$parameter->getName()]; + continue; + } + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $class = $type->getName(); + $matched = null; + foreach ($provided as $candidate) { + if ($candidate instanceof $class) { + $matched = $candidate; + break; + } + } + $arguments[] = $matched ?? $this->get($class); + continue; + } + if ($parameter->isDefaultValueAvailable()) { + $arguments[] = $parameter->getDefaultValue(); + continue; + } + if ($parameter->allowsNull()) { + $arguments[] = null; + continue; + } + throw new \RuntimeException( + "Unable to resolve parameter \${$parameter->getName()} for {$parameter->getDeclaringFunction()->getName()}().", + ); + } + return $arguments; + } + + private function register( + string $id, + callable|string|null $factory, + BindingLifetime $lifetime, + ): self { + $resolver = match (true) { + $factory === null => static function (self $container) use ($id): mixed { + if (!class_exists($id)) { + throw new \RuntimeException("Container entry {$id} is not a class."); + } + return $container->build($id); + }, + is_string($factory) => static fn (self $container): mixed => $container->get($factory), + default => $factory, + }; + $this->bindings[$id] = new Binding($resolver, $lifetime); + unset($this->singletons[$id], $this->scoped[$id]); + return $this; + } +} diff --git a/src/ControllerHandler.php b/src/ControllerHandler.php new file mode 100644 index 0000000..f0f7062 --- /dev/null +++ b/src/ControllerHandler.php @@ -0,0 +1,38 @@ +isPublic() || $reflection->isAbstract()) { + throw new \InvalidArgumentException("Controller handler {$controller}::{$method} must be public and concrete."); + } + } + + public function __invoke(Request $request, Response $response): mixed + { + $controller = $this->container->get($this->controller); + if (!is_object($controller)) { + throw new \UnexpectedValueException("Container entry {$this->controller} must resolve to an object."); + } + return $this->container->call( + [$controller, $this->method], + $request->routeParameters(), + [$request, $response], + ); + } +} + diff --git a/src/HandlerResolver.php b/src/HandlerResolver.php new file mode 100644 index 0000000..6d63cec --- /dev/null +++ b/src/HandlerResolver.php @@ -0,0 +1,41 @@ +container, $controller, $method))(...); + } + if (is_string($handler)) { + if (!class_exists($handler) || !method_exists($handler, '__invoke')) { + throw new \InvalidArgumentException("Invokable controller {$handler} must define __invoke()."); + } + return (new ControllerHandler($this->container, $handler, '__invoke'))(...); + } + return \Closure::fromCallable($handler); + } +} diff --git a/src/Http/HttpException.php b/src/Http/HttpException.php new file mode 100644 index 0000000..6131a8e --- /dev/null +++ b/src/Http/HttpException.php @@ -0,0 +1,20 @@ + $details */ + public function __construct( + public readonly int $status, + public readonly ProblemCode $problemCode, + string $message, + public readonly array $details = [], + ?\Throwable $previous = null, + ) { + parent::__construct($message, $problemCode->value, $previous); + } +} + diff --git a/src/Http/JsonResource.php b/src/Http/JsonResource.php new file mode 100644 index 0000000..105428f --- /dev/null +++ b/src/Http/JsonResource.php @@ -0,0 +1,33 @@ + */ + abstract public function toArray(Request $request): array; + + public function toResponse(Request $request, Response $response): Response + { + return $response->json(['data' => $this->toArray($request)]); + } + + /** @param iterable $resources */ + public static function collection(iterable $resources): ResourceCollection + { + $items = []; + foreach ($resources as $resource) { + $items[] = new static($resource); + } + return new ResourceCollection($items); + } +} diff --git a/src/Http/ProblemCode.php b/src/Http/ProblemCode.php new file mode 100644 index 0000000..f4de75b --- /dev/null +++ b/src/Http/ProblemCode.php @@ -0,0 +1,18 @@ + $resources + * @param array $meta + */ + public function __construct( + private array $resources, + private array $meta = [], + ) { + } + + /** @param array $meta */ + public function withMeta(array $meta): self + { + return new self($this->resources, $meta); + } + + public function toResponse(Request $request, Response $response): Response + { + $payload = [ + 'data' => array_map( + static fn (JsonResource $resource): array => $resource->toArray($request), + $this->resources, + ), + ]; + if ($this->meta !== []) { + $payload['meta'] = $this->meta; + } + return $response->json($payload); + } +} diff --git a/src/Http/Responsable.php b/src/Http/Responsable.php new file mode 100644 index 0000000..c17fb4f --- /dev/null +++ b/src/Http/Responsable.php @@ -0,0 +1,14 @@ +router->name($this->route, $name); + return $this; + } + + public function where(string $parameter, string|RouteConstraint $constraint): self + { + $this->router->constrain($this->route, $parameter, $constraint); + return $this; + } + + public function middleware(object|callable $middleware): self + { + if (!$middleware instanceof MiddlewareInterface && !is_callable($middleware)) { + throw new \InvalidArgumentException('Route middleware must implement the PAM contract or be callable.'); + } + $this->route->middleware[] = $middleware; + return $this; + } + + public function definition(): Route + { + return $this->route; + } +} + diff --git a/src/Route.php b/src/Route.php index 0dc43d2..d5549cf 100644 --- a/src/Route.php +++ b/src/Route.php @@ -4,17 +4,24 @@ namespace Pam\Api; -final readonly class Route +use Pam\Contracts\Http\MiddlewareInterface; + +final class Route { public \Closure $handler; + /** @var list */ + public array $middleware = []; + + public ?string $name = null; + /** @param list $parameterNames */ public function __construct( public string $method, public string $path, callable $handler, public string $pattern, - public array $parameterNames, + public readonly array $parameterNames, ) { $this->handler = \Closure::fromCallable($handler); } diff --git a/src/RouteConstraint.php b/src/RouteConstraint.php new file mode 100644 index 0000000..b4b9ada --- /dev/null +++ b/src/RouteConstraint.php @@ -0,0 +1,28 @@ + '[0-9]+', + self::Uuid => '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}', + self::Ulid => '[0-7][0-9A-HJKMNP-TV-Z]{25}', + self::Slug => '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*', + self::Alpha => '[A-Za-z]+', + self::AlphaNumeric => '[A-Za-z0-9]+', + }; + } +} + diff --git a/src/RouteRegistrar.php b/src/RouteRegistrar.php new file mode 100644 index 0000000..b9b363e --- /dev/null +++ b/src/RouteRegistrar.php @@ -0,0 +1,113 @@ + */ + private array $middleware = []; + + public function __construct( + private readonly App $app, + private string $prefix = '', + ) { + } + + public function prefix(string $prefix): self + { + $clone = clone $this; + $clone->prefix = self::join($clone->prefix, $prefix); + return $clone; + } + + /** @param MiddlewareInterface|callable|list $middleware */ + public function middleware(MiddlewareInterface|callable|array $middleware): self + { + $clone = clone $this; + foreach (is_array($middleware) ? $middleware : [$middleware] as $layer) { + if (!$layer instanceof MiddlewareInterface && !is_callable($layer)) { + throw new \InvalidArgumentException('Group middleware must implement the PAM contract or be callable.'); + } + $clone->middleware[] = $layer; + } + return $clone; + } + + /** @param callable(self): void $routes */ + public function group(callable $routes): void + { + $routes($this); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function get(string $path, callable|string|array $handler): PendingRoute + { + return $this->decorate($this->app->get(self::join($this->prefix, $path), $handler)); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function post(string $path, callable|string|array $handler): PendingRoute + { + return $this->decorate($this->app->post(self::join($this->prefix, $path), $handler)); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function put(string $path, callable|string|array $handler): PendingRoute + { + return $this->decorate($this->app->put(self::join($this->prefix, $path), $handler)); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function patch(string $path, callable|string|array $handler): PendingRoute + { + return $this->decorate($this->app->patch(self::join($this->prefix, $path), $handler)); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function delete(string $path, callable|string|array $handler): PendingRoute + { + return $this->decorate($this->app->delete(self::join($this->prefix, $path), $handler)); + } + + /** @param class-string $controller */ + public function apiResource(string $path, string $controller): void + { + $base = '/' . trim($path, '/'); + $parameter = self::singularParameter($base); + $this->get($base, [$controller, 'index']); + $this->post($base, [$controller, 'store']); + $this->get("{$base}/{{$parameter}}", [$controller, 'show']); + $this->put("{$base}/{{$parameter}}", [$controller, 'update']); + $this->patch("{$base}/{{$parameter}}", [$controller, 'update']); + $this->delete("{$base}/{{$parameter}}", [$controller, 'destroy']); + } + + private function decorate(PendingRoute $route): PendingRoute + { + foreach ($this->middleware as $middleware) { + $route->middleware($middleware); + } + return $route; + } + + private static function join(string $prefix, string $path): string + { + $joined = '/' . trim($prefix, '/') . '/' . trim($path, '/'); + $joined = preg_replace('#/+#', '/', $joined); + return $joined === null ? '/' : (rtrim($joined, '/') ?: '/'); + } + + private static function singularParameter(string $path): string + { + $segment = basename($path); + $singular = str_ends_with($segment, 'ies') + ? substr($segment, 0, -3) . 'y' + : (str_ends_with($segment, 's') ? substr($segment, 0, -1) : $segment); + return preg_replace('/[^A-Za-z0-9_]/', '', $singular) ?: 'resource'; + } +} diff --git a/src/Router.php b/src/Router.php index 4ed6352..ec0b74b 100644 --- a/src/Router.php +++ b/src/Router.php @@ -19,6 +19,12 @@ final class Router private array $signatures = []; public function add(string $method, string $path, callable $handler): self + { + $this->register($method, $path, $handler); + return $this; + } + + public function register(string $method, string $path, callable $handler): Route { $method = strtoupper(trim($method)); if ($method === '' || preg_match('/^[A-Z!#$%&\'*+.^_`|~-]+$/D', $method) !== 1) { @@ -42,7 +48,33 @@ public function add(string $method, string $path, callable $handler): self $this->dynamicRoutes[] = $route; } $this->signatures[$signature] = true; - return $this; + return $route; + } + + public function name(Route $route, string $name): void + { + if ($name === '' || preg_match('/^[A-Za-z0-9_.-]+$/D', $name) !== 1) { + throw new \InvalidArgumentException('Route names may contain letters, numbers, dots, dashes and underscores.'); + } + foreach ($this->routes as $registered) { + if ($registered !== $route && $registered->name === $name) { + throw new \LogicException("Route name {$name} is already registered."); + } + } + $route->name = $name; + } + + public function constrain(Route $route, string $parameter, string|RouteConstraint $constraint): void + { + if (!in_array($parameter, $route->parameterNames, true)) { + throw new \InvalidArgumentException("Route {$route->path} has no parameter named {$parameter}."); + } + $pattern = $constraint instanceof RouteConstraint ? $constraint->pattern() : $constraint; + if ($pattern === '' || @preg_match('#^(?:' . $pattern . ')$#D', '') === false) { + throw new \InvalidArgumentException("Constraint for {$parameter} is not a valid regular expression."); + } + $needle = '(?P<' . $parameter . '>[^/]+)'; + $route->pattern = str_replace($needle, '(?P<' . $parameter . '>' . $pattern . ')', $route->pattern); } public function match(string $method, string $path): RoutingResult diff --git a/src/Validation/EnumRule.php b/src/Validation/EnumRule.php new file mode 100644 index 0000000..e88d833 --- /dev/null +++ b/src/Validation/EnumRule.php @@ -0,0 +1,24 @@ + $enum */ + public function __construct(private string $enum) + { + } + + public function validate(string $field, mixed $value): ?string + { + foreach ($this->enum::cases() as $case) { + if ($case->value === $value) { + return null; + } + } + return "The {$field} field is invalid."; + } +} + diff --git a/src/Validation/FormRequest.php b/src/Validation/FormRequest.php new file mode 100644 index 0000000..3792b00 --- /dev/null +++ b/src/Validation/FormRequest.php @@ -0,0 +1,90 @@ + */ + private array $validated; + + final public function __construct(protected readonly Request $request) + { + $this->validated = $this->validate(); + } + + /** @return array> */ + abstract public function rules(): array; + + public function authorize(): bool + { + return true; + } + + /** @return array */ + final public function validated(): array + { + return $this->validated; + } + + final public function input(string $key, mixed $default = null): mixed + { + return $this->validated[$key] ?? $default; + } + + /** @return array */ + private function validate(): array + { + if (!$this->authorize()) { + throw new \Pam\Api\Http\HttpException( + 403, + \Pam\Api\Http\ProblemCode::Forbidden, + 'This action is not authorized.', + ); + } + $decoded = $this->request->body() === '' ? [] : $this->request->json(); + if (!is_array($decoded)) { + throw new ValidationException(['body' => ['The request body must be a JSON object.']]); + } + $errors = []; + $validated = []; + foreach ($this->rules() as $field => $rules) { + $exists = array_key_exists($field, $decoded); + $value = $decoded[$field] ?? null; + foreach ($rules as $rule) { + $message = $rule instanceof ValidationRule + ? $rule->validate($field, $value) + : $this->validateBuiltin($field, $value, $exists, $rule); + if ($message !== null) { + $errors[$field][] = $message; + } + } + if ($exists && !isset($errors[$field])) { + $validated[$field] = $value; + } + } + if ($errors !== []) { + throw new ValidationException($errors); + } + return $validated; + } + + private function validateBuiltin(string $field, mixed $value, bool $exists, string $rule): ?string + { + return match ($rule) { + 'required' => !$exists || $value === null || $value === '' ? "The {$field} field is required." : null, + 'string' => $exists && !is_string($value) ? "The {$field} field must be a string." : null, + 'integer' => $exists && !is_int($value) ? "The {$field} field must be an integer." : null, + 'boolean' => $exists && !is_bool($value) ? "The {$field} field must be a boolean." : null, + 'array' => $exists && !is_array($value) ? "The {$field} field must be an array." : null, + 'email' => $exists && (!is_string($value) || filter_var($value, FILTER_VALIDATE_EMAIL) === false) + ? "The {$field} field must be a valid email address." + : null, + default => throw new \InvalidArgumentException("Unknown validation rule {$rule}."), + }; + } +} + diff --git a/src/Validation/Rule.php b/src/Validation/Rule.php new file mode 100644 index 0000000..1c6f6e7 --- /dev/null +++ b/src/Validation/Rule.php @@ -0,0 +1,15 @@ + $enum */ + public static function enum(string $enum): EnumRule + { + return new EnumRule($enum); + } +} + diff --git a/src/Validation/ValidationException.php b/src/Validation/ValidationException.php new file mode 100644 index 0000000..f25cef4 --- /dev/null +++ b/src/Validation/ValidationException.php @@ -0,0 +1,23 @@ +> $errors */ + public function __construct(public readonly array $errors) + { + parent::__construct( + 422, + ProblemCode::ValidationFailed, + 'The submitted data is invalid.', + ['errors' => $errors], + ); + } +} + diff --git a/src/Validation/ValidationRule.php b/src/Validation/ValidationRule.php new file mode 100644 index 0000000..e33825c --- /dev/null +++ b/src/Validation/ValidationRule.php @@ -0,0 +1,11 @@ +get(DependentService::class)); + } + + public function testScopedBindingsAreReusedAndThenDiscarded(): void + { + $container = new Container(); + $container->scoped(ScopedValue::class); + + $container->beginScope(); + $first = $container->get(ScopedValue::class); + self::assertSame($first, $container->get(ScopedValue::class)); + $container->endScope(); + + $container->beginScope(); + self::assertNotSame($first, $container->get(ScopedValue::class)); + $container->endScope(); + } + + public function testScopedBindingsCannotResolveOutsideARequest(): void + { + $container = new Container(); + $container->scoped(ScopedValue::class); + + $this->expectException(\LogicException::class); + $container->get(ScopedValue::class); + } +} + +final class Dependency +{ +} + +final readonly class DependentService +{ + public function __construct(public Dependency $dependency) + { + } +} + +final class ScopedValue +{ +} + diff --git a/tests/ControllerHandlerTest.php b/tests/ControllerHandlerTest.php new file mode 100644 index 0000000..a7e2a30 --- /dev/null +++ b/tests/ControllerHandlerTest.php @@ -0,0 +1,47 @@ +post('/{tenant}/login', [LoginController::class, 'onLogin']); + + $request = new Request( + 'POST', + '/acme/login', + [], + ['content-type' => ['application/json']], + '{"email":"dev@pam.dev"}', + ); + + $export = $app->handle($request, new Response())->export(); + + self::assertSame(200, $export['status']); + self::assertSame( + ['message' => 'authenticated:dev@pam.dev', 'tenant' => 'acme'], + json_decode($export['body'], true, 512, JSON_THROW_ON_ERROR), + ); + } + + public function testMissingControllerMethodFailsDuringRegistration(): void + { + $app = new App(discoverPackages: false); + + $this->expectException(\InvalidArgumentException::class); + $app->post('/login', [LoginController::class, 'missing']); + } +} + diff --git a/tests/Fixtures/LoginController.php b/tests/Fixtures/LoginController.php new file mode 100644 index 0000000..4b4687b --- /dev/null +++ b/tests/Fixtures/LoginController.php @@ -0,0 +1,33 @@ +json(); + $email = is_array($payload) && is_string($payload['email'] ?? null) + ? $payload['email'] + : ''; + + return $response->json([ + 'message' => $this->login->message($email), + 'tenant' => $tenant, + ]); + } + + public function validated(LoginRequest $request, Response $response): Response + { + return $response->json(['email' => $request->input('email')]); + } +} diff --git a/tests/Fixtures/LoginRequest.php b/tests/Fixtures/LoginRequest.php new file mode 100644 index 0000000..c3f6d60 --- /dev/null +++ b/tests/Fixtures/LoginRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'email'], + ]; + } +} + diff --git a/tests/Fixtures/LoginService.php b/tests/Fixtures/LoginService.php new file mode 100644 index 0000000..0eab5af --- /dev/null +++ b/tests/Fixtures/LoginService.php @@ -0,0 +1,14 @@ +handle($request, $response)->header('x-api', 'v1'); + }; + + $app->prefix('/api') + ->middleware($middleware) + ->prefix('/v1') + ->group(static function (RouteRegistrar $routes): void { + $routes->get('/ping', static fn (Request $request, Response $response): Response => + $response->json(['message' => 'pong'])); + }); + + $response = $app->handle( + new Request('GET', '/api/v1/ping', [], [], ''), + new Response(), + )->export(); + + self::assertSame(200, $response['status']); + self::assertSame(['v1'], $response['headers']['x-api']); + } +} + diff --git a/tests/RouterFluentTest.php b/tests/RouterFluentTest.php new file mode 100644 index 0000000..74c6a7b --- /dev/null +++ b/tests/RouterFluentTest.php @@ -0,0 +1,57 @@ +get('/users/{id}', static fn (Request $request, Response $response): Response => + $response->json(['id' => $request->route('id')])) + ->where('id', RouteConstraint::Integer) + ->name('users.show'); + + $valid = $app->handle($this->request('GET', '/users/42'), new Response())->export(); + $invalid = $app->handle($this->request('GET', '/users/not-an-id'), new Response())->export(); + + self::assertSame(200, $valid['status']); + self::assertSame(404, $invalid['status']); + } + + public function testRouteMiddlewareOnlyWrapsItsOwnRoute(): void + { + $app = new App(discoverPackages: false); + $middleware = static function ( + Request $request, + Response $response, + RequestHandlerInterface $next, + ): Response { + return $next->handle($request, $response)->header('x-route', 'wrapped'); + }; + $app->get('/wrapped', static fn (Request $request, Response $response): Response => $response->send('ok')) + ->middleware($middleware); + $app->get('/plain', static fn (Request $request, Response $response): Response => $response->send('ok')); + + $wrapped = $app->handle($this->request('GET', '/wrapped'), new Response())->export(); + $plain = $app->handle($this->request('GET', '/plain'), new Response())->export(); + + self::assertSame(['wrapped'], $wrapped['headers']['x-route']); + self::assertArrayNotHasKey('x-route', $plain['headers']); + } + + private function request(string $method, string $path): Request + { + return new Request($method, $path, [], [], ''); + } +} + diff --git a/tests/ValidationAndResourceTest.php b/tests/ValidationAndResourceTest.php new file mode 100644 index 0000000..050156b --- /dev/null +++ b/tests/ValidationAndResourceTest.php @@ -0,0 +1,53 @@ +post('/login', [LoginController::class, 'validated']); + + $valid = $app->handle($this->request('{"email":"dev@pam.dev"}'), new Response())->export(); + $invalid = $app->handle($this->request('{"email":"invalid"}'), new Response())->export(); + + self::assertSame(200, $valid['status']); + self::assertSame(422, $invalid['status']); + $problem = json_decode($invalid['body'], true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($problem); + self::assertSame(1, $problem['code']); + } + + public function testJsonResourceCreatesADataEnvelope(): void + { + $app = new App(discoverPackages: false); + $app->get('/user', static fn (): JsonResource => new TestUserResource(['id' => 10])); + + $response = $app->handle(new Request('GET', '/user', [], [], ''), new Response())->export(); + + self::assertSame(['data' => ['id' => 10]], json_decode($response['body'], true, 512, JSON_THROW_ON_ERROR)); + } + + private function request(string $body): Request + { + return new Request('POST', '/login', [], ['content-type' => ['application/json']], $body); + } +} + +final readonly class TestUserResource extends JsonResource +{ + public function toArray(Request $request): array + { + return ['id' => is_array($this->resource) ? $this->resource['id'] : null]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..77ec93e --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,9 @@ + Date: Thu, 20 Aug 2026 19:21:47 -0300 Subject: [PATCH 02/10] docs: link the official PAM API guide --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d081471..2c4808f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Express-like routing. Laravel-like application structure. PAM-native execution. +**[Official documentation](https://push-in.github.io/pam-docs/packages/api/) · +[PAM introduction](https://push-in.github.io/pam-docs/introduction/) · +[Report an issue](https://github.com/push-in/pam-api/issues)** + ```bash pam composer require pushinbr/pam-api ``` From 17f7ef320a365e139136ad7eab77dc52b0ce1163 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Thu, 20 Aug 2026 19:23:50 -0300 Subject: [PATCH 03/10] feat: add pluggable rate-limit stores --- README.md | 19 +++++++ docs/API-2.md | 2 +- src/Middleware/RateLimitMiddleware.php | 52 +++++++++++--------- src/RateLimit/MemoryRateLimitStore.php | 52 ++++++++++++++++++++ src/RateLimit/RateLimitDecision.php | 20 ++++++++ src/RateLimit/RateLimitStore.php | 16 ++++++ tests/RateLimit/MemoryRateLimitStoreTest.php | 34 +++++++++++++ 7 files changed, 171 insertions(+), 24 deletions(-) create mode 100644 src/RateLimit/MemoryRateLimitStore.php create mode 100644 src/RateLimit/RateLimitDecision.php create mode 100644 src/RateLimit/RateLimitStore.php create mode 100644 tests/RateLimit/MemoryRateLimitStoreTest.php diff --git a/README.md b/README.md index 2c4808f..49191f3 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,25 @@ supported PHP version. See the [PAM API 2 design and delivery contract](docs/API-2.md) for the complete 15-track implementation plan and current delivery status. +## Distributed rate limiting + +`RateLimitMiddleware` uses a bounded in-memory token bucket by default and +accepts any `RateLimitStore` for process-wide or distributed enforcement: + +```php +$app->middleware(new RateLimitMiddleware( + requestsPerSecond: 20, + burst: 40, + store: $redisRateLimitStore, + keyResolver: static fn (Request $request): string => + 'token:' . $request->getHeader('authorization', 'anonymous'), +)); +``` + +The middleware emits limit/remaining/retry headers and a Problem Details `429` +response. Applications behind proxies must supply a key resolver that trusts +only their explicitly configured proxy boundary. + ## License Free and open-source under the [Apache License 2.0](LICENSE). You may use, diff --git a/docs/API-2.md b/docs/API-2.md index 39b96a4..8a827da 100644 --- a/docs/API-2.md +++ b/docs/API-2.md @@ -68,7 +68,7 @@ The `feat/api-2-foundation` development line currently implements: - Problem Details validation responses; - JSON Resources and Resource Collections; - PHPUnit and PHPStan level 9 verification. +- pluggable rate-limit stores with a bounded in-memory token bucket fallback. Remaining tracks will land behind stable contracts with tests before they are documented as production-ready. - diff --git a/src/Middleware/RateLimitMiddleware.php b/src/Middleware/RateLimitMiddleware.php index 21198bf..9de9536 100644 --- a/src/Middleware/RateLimitMiddleware.php +++ b/src/Middleware/RateLimitMiddleware.php @@ -8,50 +8,56 @@ use Pam\Contracts\Http\RequestHandlerInterface; use Pam\Http\Request; use Pam\Http\Response; +use Pam\Api\RateLimit\MemoryRateLimitStore; +use Pam\Api\RateLimit\RateLimitStore; final class RateLimitMiddleware implements MiddlewareInterface { - /** @var array */ - private array $buckets = []; + private readonly RateLimitStore $store; + + /** @var \Closure(Request): string */ + private readonly \Closure $keyResolver; public function __construct( private readonly int $requestsPerSecond, private readonly int $burst = 0, private readonly int $maxBuckets = 65_536, private readonly float $idleTtlSeconds = 300.0, + ?RateLimitStore $store = null, + ?callable $keyResolver = null, ) { if ($requestsPerSecond < 1 || $burst < 0 || $maxBuckets < 1 || $idleTtlSeconds <= 0) { throw new \InvalidArgumentException('Rate limit configuration is invalid.'); } + $this->store = $store ?? new MemoryRateLimitStore($maxBuckets, $idleTtlSeconds); + $this->keyResolver = $keyResolver === null + ? static fn (Request $request): string => is_string($_SERVER['REMOTE_ADDR'] ?? null) + ? $_SERVER['REMOTE_ADDR'] + : 'unknown' + : \Closure::fromCallable($keyResolver); } public function process(Request $request, Response $response, RequestHandlerInterface $next): Response { - $key = is_string($_SERVER['REMOTE_ADDR'] ?? null) ? $_SERVER['REMOTE_ADDR'] : 'unknown'; - $now = microtime(true); - if (!isset($this->buckets[$key]) && count($this->buckets) >= $this->maxBuckets) { - $this->buckets = array_filter( - $this->buckets, - fn (array $entry): bool => $entry['updatedAt'] >= $now - $this->idleTtlSeconds, - ); - if (count($this->buckets) >= $this->maxBuckets) { - return $response - ->header('retry-after', '1') - ->json(['error' => 'Too Many Requests'], 429); - } + $key = ($this->keyResolver)($request); + if ($key === '') { + throw new \UnexpectedValueException('The rate-limit key resolver returned an empty key.'); } $capacity = $this->burst > 0 ? $this->burst : $this->requestsPerSecond; - $bucket = $this->buckets[$key] ?? ['tokens' => (float) $capacity, 'updatedAt' => $now]; - $elapsed = max(0.0, $now - $bucket['updatedAt']); - $tokens = min((float) $capacity, $bucket['tokens'] + ($elapsed * $this->requestsPerSecond)); - if ($tokens < 1.0) { - $this->buckets[$key] = ['tokens' => $tokens, 'updatedAt' => $now]; + $decision = $this->store->consume($key, $this->requestsPerSecond, $capacity, microtime(true)); + $response + ->header('x-ratelimit-limit', (string) $decision->limit) + ->header('x-ratelimit-remaining', (string) $decision->remaining); + if (!$decision->allowed) { return $response - ->status(429) - ->header('retry-after', '1') - ->json(['error' => 'Too Many Requests'], 429); + ->header('retry-after', (string) $decision->retryAfterSeconds) + ->json([ + 'type' => 'https://pam.dev/problems/6', + 'title' => 'Too Many Requests', + 'status' => 429, + 'code' => 6, + ], 429); } - $this->buckets[$key] = ['tokens' => $tokens - 1.0, 'updatedAt' => $now]; return $next->handle($request, $response); } } diff --git a/src/RateLimit/MemoryRateLimitStore.php b/src/RateLimit/MemoryRateLimitStore.php new file mode 100644 index 0000000..25700ee --- /dev/null +++ b/src/RateLimit/MemoryRateLimitStore.php @@ -0,0 +1,52 @@ + */ + private array $buckets = []; + + public function __construct( + private readonly int $maxBuckets = 65_536, + private readonly float $idleTtlSeconds = 300.0, + ) { + if ($maxBuckets < 1 || $idleTtlSeconds <= 0) { + throw new \InvalidArgumentException('Memory rate-limit store configuration is invalid.'); + } + } + + public function consume( + string $key, + int $requestsPerSecond, + int $capacity, + float $now, + ): RateLimitDecision { + if (!isset($this->buckets[$key]) && count($this->buckets) >= $this->maxBuckets) { + $threshold = $now - $this->idleTtlSeconds; + $this->buckets = array_filter( + $this->buckets, + static fn (array $entry): bool => $entry['updatedAt'] >= $threshold, + ); + if (count($this->buckets) >= $this->maxBuckets) { + return new RateLimitDecision(false, $capacity, 0, 1); + } + } + + $bucket = $this->buckets[$key] ?? ['tokens' => (float) $capacity, 'updatedAt' => $now]; + $elapsed = max(0.0, $now - $bucket['updatedAt']); + $tokens = min((float) $capacity, $bucket['tokens'] + ($elapsed * $requestsPerSecond)); + if ($tokens < 1.0) { + $this->buckets[$key] = ['tokens' => $tokens, 'updatedAt' => $now]; + $retry = max(1, (int) ceil((1.0 - $tokens) / $requestsPerSecond)); + return new RateLimitDecision(false, $capacity, 0, $retry); + } + + $remaining = $tokens - 1.0; + $this->buckets[$key] = ['tokens' => $remaining, 'updatedAt' => $now]; + return new RateLimitDecision(true, $capacity, (int) floor($remaining)); + } +} + diff --git a/src/RateLimit/RateLimitDecision.php b/src/RateLimit/RateLimitDecision.php new file mode 100644 index 0000000..1441adc --- /dev/null +++ b/src/RateLimit/RateLimitDecision.php @@ -0,0 +1,20 @@ +consume('user:1', 2, 2, 100.0)->allowed); + self::assertTrue($store->consume('user:1', 2, 2, 100.0)->allowed); + $limited = $store->consume('user:1', 2, 2, 100.0); + self::assertFalse($limited->allowed); + self::assertSame(1, $limited->retryAfterSeconds); + + self::assertTrue($store->consume('user:1', 2, 2, 100.5)->allowed); + } + + public function testFullStoreFailsClosedUntilAnIdleBucketCanBeEvicted(): void + { + $store = new MemoryRateLimitStore(maxBuckets: 1, idleTtlSeconds: 10.0); + $store->consume('first', 1, 1, 100.0); + + self::assertFalse($store->consume('second', 1, 1, 105.0)->allowed); + self::assertTrue($store->consume('second', 1, 1, 111.0)->allowed); + } +} + From d80454dbb3a3415c285d68eec620059f378306e3 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Thu, 20 Aug 2026 19:45:15 -0300 Subject: [PATCH 04/10] feat: complete PAM API application platform --- README.md | 46 +++++++++ benchmarks/router.php | 28 ++++++ composer.json | 8 +- docs/API-2.md | 21 +++- src/App.php | 20 +++- src/Auth/AuthContext.php | 13 +++ src/Auth/Authenticator.php | 13 +++ src/Auth/Principal.php | 13 +++ src/Cache/CacheRecord.php | 17 ++++ src/Cache/MemoryResponseCacheStore.php | 42 ++++++++ src/Cache/ResponseCacheStore.php | 15 +++ src/Container/Container.php | 62 +++++++++++- src/Container/ContainerState.php | 12 +++ src/ContainerMiddleware.php | 34 +++++++ src/Events/EventDispatcher.php | 11 +++ src/Events/SyncEventDispatcher.php | 36 +++++++ src/Health/HealthCheck.php | 11 +++ src/Health/HealthRegistry.php | 36 +++++++ src/Health/HealthResult.php | 16 ++++ src/Health/HealthState.php | 13 +++ src/Http/ClientIpResolver.php | 30 ++++++ src/Http/ResponseSnapshot.php | 36 +++++++ src/Idempotency/IdempotencyRecord.php | 18 ++++ src/Idempotency/IdempotencyStore.php | 13 +++ src/Idempotency/MemoryIdempotencyStore.php | 37 ++++++++ src/Jobs/JobDispatcher.php | 11 +++ src/Jobs/JobEnvelope.php | 23 +++++ src/Jobs/JobState.php | 15 +++ src/Jobs/MemoryJobDispatcher.php | 40 ++++++++ src/Middleware/AuthenticateMiddleware.php | 38 ++++++++ src/Middleware/AuthorizeMiddleware.php | 35 +++++++ src/Middleware/CorsMiddleware.php | 13 +++ src/Middleware/DeadlineMiddleware.php | 34 +++++++ src/Middleware/IdempotencyMiddleware.php | 61 ++++++++++++ src/Middleware/ObserveRequestMiddleware.php | 46 +++++++++ src/Middleware/ResolveTenantMiddleware.php | 42 ++++++++ src/Middleware/ResponseCacheMiddleware.php | 55 +++++++++++ src/Middleware/TransactionalMiddleware.php | 30 ++++++ src/Observability/RequestObservation.php | 18 ++++ src/Observability/RequestObserver.php | 11 +++ src/OpenApi/ClientGenerator.php | 85 +++++++++++++++++ src/OpenApi/ClientLanguage.php | 13 +++ src/OpenApi/CompatibilityChange.php | 16 ++++ src/OpenApi/CompatibilityChangeCode.php | 13 +++ src/OpenApi/CompatibilityChecker.php | 44 +++++++++ src/OpenApi/OpenApiGenerator.php | 100 ++++++++++++++++++++ src/PendingRoute.php | 55 ++++++++++- src/Resilience/CircuitBreaker.php | 50 ++++++++++ src/Resilience/CircuitOpenException.php | 10 ++ src/Resilience/CircuitState.php | 13 +++ src/Resilience/RetryPolicy.php | 42 ++++++++ src/Route.php | 14 +++ src/RouteRegistrar.php | 7 +- src/Router.php | 10 ++ src/Routing/RouteBindable.php | 11 +++ src/Runtime/Deadline.php | 34 +++++++ src/Tenancy/Tenant.php | 11 +++ src/Tenancy/TenantContext.php | 13 +++ src/Tenancy/TenantResolver.php | 13 +++ src/Testing/TestClient.php | 56 +++++++++++ src/Testing/TestResponse.php | 58 ++++++++++++ src/Transactions/TransactionManager.php | 11 +++ src/Validation/DtoHydrator.php | 49 ++++++++++ src/Validation/FormRequest.php | 11 ++- tests/AuthAndIdempotencyTest.php | 91 ++++++++++++++++++ tests/ResponseCacheTest.php | 33 +++++++ tests/RouteBindingAndOpenApiTest.php | 85 +++++++++++++++++ tests/TestingAndClientGenerationTest.php | 41 ++++++++ 68 files changed, 2045 insertions(+), 17 deletions(-) create mode 100644 benchmarks/router.php create mode 100644 src/Auth/AuthContext.php create mode 100644 src/Auth/Authenticator.php create mode 100644 src/Auth/Principal.php create mode 100644 src/Cache/CacheRecord.php create mode 100644 src/Cache/MemoryResponseCacheStore.php create mode 100644 src/Cache/ResponseCacheStore.php create mode 100644 src/Container/ContainerState.php create mode 100644 src/ContainerMiddleware.php create mode 100644 src/Events/EventDispatcher.php create mode 100644 src/Events/SyncEventDispatcher.php create mode 100644 src/Health/HealthCheck.php create mode 100644 src/Health/HealthRegistry.php create mode 100644 src/Health/HealthResult.php create mode 100644 src/Health/HealthState.php create mode 100644 src/Http/ClientIpResolver.php create mode 100644 src/Http/ResponseSnapshot.php create mode 100644 src/Idempotency/IdempotencyRecord.php create mode 100644 src/Idempotency/IdempotencyStore.php create mode 100644 src/Idempotency/MemoryIdempotencyStore.php create mode 100644 src/Jobs/JobDispatcher.php create mode 100644 src/Jobs/JobEnvelope.php create mode 100644 src/Jobs/JobState.php create mode 100644 src/Jobs/MemoryJobDispatcher.php create mode 100644 src/Middleware/AuthenticateMiddleware.php create mode 100644 src/Middleware/AuthorizeMiddleware.php create mode 100644 src/Middleware/DeadlineMiddleware.php create mode 100644 src/Middleware/IdempotencyMiddleware.php create mode 100644 src/Middleware/ObserveRequestMiddleware.php create mode 100644 src/Middleware/ResolveTenantMiddleware.php create mode 100644 src/Middleware/ResponseCacheMiddleware.php create mode 100644 src/Middleware/TransactionalMiddleware.php create mode 100644 src/Observability/RequestObservation.php create mode 100644 src/Observability/RequestObserver.php create mode 100644 src/OpenApi/ClientGenerator.php create mode 100644 src/OpenApi/ClientLanguage.php create mode 100644 src/OpenApi/CompatibilityChange.php create mode 100644 src/OpenApi/CompatibilityChangeCode.php create mode 100644 src/OpenApi/CompatibilityChecker.php create mode 100644 src/OpenApi/OpenApiGenerator.php create mode 100644 src/Resilience/CircuitBreaker.php create mode 100644 src/Resilience/CircuitOpenException.php create mode 100644 src/Resilience/CircuitState.php create mode 100644 src/Resilience/RetryPolicy.php create mode 100644 src/Routing/RouteBindable.php create mode 100644 src/Runtime/Deadline.php create mode 100644 src/Tenancy/Tenant.php create mode 100644 src/Tenancy/TenantContext.php create mode 100644 src/Tenancy/TenantResolver.php create mode 100644 src/Testing/TestClient.php create mode 100644 src/Testing/TestResponse.php create mode 100644 src/Transactions/TransactionManager.php create mode 100644 src/Validation/DtoHydrator.php create mode 100644 tests/AuthAndIdempotencyTest.php create mode 100644 tests/ResponseCacheTest.php create mode 100644 tests/RouteBindingAndOpenApiTest.php create mode 100644 tests/TestingAndClientGenerationTest.php diff --git a/README.md b/README.md index 49191f3..6b82f2d 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,52 @@ The middleware emits limit/remaining/retry headers and a Problem Details `429` response. Applications behind proxies must supply a key resolver that trusts only their explicitly configured proxy boundary. +## Production building blocks + +PAM API exposes small, replaceable contracts instead of choosing application +infrastructure: + +- authenticators, principals and ability checks; +- idempotency and response-cache stores; +- request-scoped tenant resolution; +- transactions, events and bounded jobs; +- retry, circuit breakers and cooperative deadlines; +- normalized observations, health checks and scope diagnostics. + +Shared production state belongs in atomic Redis/database/broker adapters. The +included memory stores are bounded and intended for development and tests. + +## OpenAPI and generated clients + +```php +$app->post('/users', [UserController::class, 'store']) + ->name('users.store') + ->summary('Create a user') + ->tags(['Users']) + ->input(StoreUserRequest::class) + ->output(UserResource::class); + +$contract = $app->openApi('My API', '1.0.0'); +$openapi = $contract->toJson(); +$typescript = $contract->client(ClientLanguage::TypeScript); +$kotlin = $contract->client(ClientLanguage::Kotlin); +$swift = $contract->client(ClientLanguage::Swift); +``` + +`CompatibilityChecker` reports breaking path and operation removals using +sequential integer codes. + +## In-memory testing + +```php +(new TestClient($app)) + ->postJson('/login', ['email' => 'dev@pam.dev']) + ->assertStatus(200) + ->assertJsonPath('data.status', 1); +``` + +Run `composer benchmark` for the standalone router benchmark. + ## License Free and open-source under the [Apache License 2.0](LICENSE). You may use, diff --git a/benchmarks/router.php b/benchmarks/router.php new file mode 100644 index 0000000..7243ee4 --- /dev/null +++ b/benchmarks/router.php @@ -0,0 +1,28 @@ +add('GET', "/static/{$index}", static fn (): null => null); +} +$dynamic = $router->register('GET', '/users/{id}', static fn (): null => null); +$router->constrain($dynamic, 'id', RouteConstraint::Integer); + +$iterations = 100_000; +$startedAt = hrtime(true); +for ($index = 0; $index < $iterations; ++$index) { + $router->match('GET', $index % 2 === 0 ? '/static/50' : '/users/42'); +} +$seconds = (hrtime(true) - $startedAt) / 1_000_000_000; + +echo json_encode([ + 'iterations' => $iterations, + 'seconds' => $seconds, + 'matchesPerSecond' => (int) round($iterations / $seconds), +], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), "\n"; diff --git a/composer.json b/composer.json index 7ed4eda..faddf57 100644 --- a/composer.json +++ b/composer.json @@ -4,14 +4,19 @@ "type": "library", "license": "Apache-2.0", "keywords": [ + "api", + "dependency-injection", "http", "middleware", + "openapi", "pam", "php", - "router" + "router", + "validation" ], "homepage": "https://github.com/push-in/pam", "support": { + "docs": "https://push-in.github.io/pam-docs/packages/api/", "issues": "https://github.com/push-in/pam/issues", "source": "https://github.com/push-in/pam-api" }, @@ -41,6 +46,7 @@ }, "scripts": { "analyse": "phpstan analyse --configuration=phpstan.neon --memory-limit=1G", + "benchmark": "php benchmarks/router.php", "test": "phpunit --configuration=phpunit.xml", "verify": [ "@analyse", diff --git a/docs/API-2.md b/docs/API-2.md index 8a827da..844c87a 100644 --- a/docs/API-2.md +++ b/docs/API-2.md @@ -56,7 +56,7 @@ and route parameters by name. ## Current implementation status -The `feat/api-2-foundation` development line currently implements: +The `feat/api-2-foundation` development line implements: - class-and-method controller handlers; - constructor and method dependency injection; @@ -67,8 +67,19 @@ The `feat/api-2-foundation` development line currently implements: - Form Request authorization/validation and integer enum validation; - Problem Details validation responses; - JSON Resources and Resource Collections; -- PHPUnit and PHPStan level 9 verification. -- pluggable rate-limit stores with a bounded in-memory token bucket fallback. +- PHPUnit and PHPStan level 9 verification; +- pluggable rate-limit stores with a bounded in-memory token bucket fallback; +- authenticators, request-scoped principals, abilities and authorization; +- idempotency and response-cache stores with bounded memory implementations; +- route model binding and custom binding resolvers; +- OpenAPI 3.1, compatibility checks and TypeScript/Kotlin/Swift clients; +- request-scoped tenancy and normalized request observations; +- transactions, events, bounded jobs, retry and circuit-breaker primitives; +- strict CORS, trusted-proxy IP resolution and cooperative deadlines; +- composable health checks and container-scope diagnostics; +- an in-memory test client with fluent response assertions; +- a reproducible router benchmark. -Remaining tracks will land behind stable contracts with tests before they are -documented as production-ready. +Redis, database, queue, JWT and OpenTelemetry adapters intentionally remain +application/ecosystem integrations. The core defines their contracts and ships +bounded memory implementations only where safe for development and tests. diff --git a/src/App.php b/src/App.php index c514c43..389c065 100644 --- a/src/App.php +++ b/src/App.php @@ -17,6 +17,7 @@ use Pam\Api\Router; use Pam\Api\RoutingResultType; use Pam\Api\RouteRegistrar; +use Pam\Api\OpenApi\OpenApiGenerator; use Pam\Http\Request; use Pam\Http\Response; use Pam\Http\Server as HttpServer; @@ -49,8 +50,8 @@ final class App implements ApplicationInterface public function __construct(bool $discoverPackages = true, ?Container $container = null) { - $this->router = new Router(); $this->container = $container ?? new Container(); + $this->router = new Router($this->container); $this->handlerResolver = new HandlerResolver($this->container); $this->container->instance(self::class, $this); $this->container->instance(Container::class, $this->container); @@ -130,16 +131,27 @@ public function group(callable $routes): void (new RouteRegistrar($this))->group($routes); } - public function middleware(object|callable $middleware): self + public function openApi(string $title = 'PAM API', string $version = '1.0.0'): OpenApiGenerator + { + return new OpenApiGenerator($this->router, $title, $version); + } + + /** @param MiddlewareInterface|callable|class-string $middleware */ + public function middleware(object|callable|string $middleware): self { $this->assertMutable(); + if (is_string($middleware)) { + if (is_a($middleware, MiddlewareInterface::class, true)) { + $middleware = new \Pam\Api\ContainerMiddleware($this->container, $middleware); + } + } if (interface_exists(\Psr\Http\Server\MiddlewareInterface::class) && $middleware instanceof \Psr\Http\Server\MiddlewareInterface ) { $this->psrMiddleware[] = $middleware; return $this; } - if (!$middleware instanceof MiddlewareInterface && !is_callable($middleware)) { + if (is_object($middleware) && !$middleware instanceof MiddlewareInterface && !method_exists($middleware, '__invoke')) { throw new \InvalidArgumentException('Middleware must implement a Pam/PSR contract or be callable.'); } $this->middleware[] = $middleware; @@ -228,6 +240,7 @@ private function dispatchRoute(Request $request, Response $response): Response ->json(['error' => 'Method Not Allowed'], 405); } $route = $result->route ?? throw new \LogicException('A matched route must contain a handler.'); + $this->container->scopedInstance(\Pam\Api\Route::class, $route); $request = $request->withRouteParameters($result->parameters); $destination = new CallableRequestHandler($route->handler); if ($route->middleware === []) { @@ -265,6 +278,7 @@ private function registerRoute(string $method, string $path, callable|string|arr { $this->assertMutable(); $route = $this->router->register($method, $path, $this->handlerResolver->resolve($handler)); + $route->sourceHandler = $handler; return new PendingRoute($this->router, $route); } } diff --git a/src/Auth/AuthContext.php b/src/Auth/AuthContext.php new file mode 100644 index 0000000..fbf726f --- /dev/null +++ b/src/Auth/AuthContext.php @@ -0,0 +1,13 @@ + */ + private array $records = []; + + public function __construct(private readonly int $maximumRecords = 10_000) + { + if ($maximumRecords < 1) { + throw new \InvalidArgumentException('Maximum cache records must be positive.'); + } + } + + public function get(string $key, int $now): ?CacheRecord + { + $record = $this->records[$key] ?? null; + if ($record !== null && $record->expiresAt <= $now) { + unset($this->records[$key]); + return null; + } + return $record; + } + + public function put(string $key, CacheRecord $record): void + { + if (!isset($this->records[$key]) && count($this->records) >= $this->maximumRecords) { + throw new \RuntimeException('The bounded response cache is full.'); + } + $this->records[$key] = $record; + } + + public function forget(string $key): void + { + unset($this->records[$key]); + } +} + diff --git a/src/Cache/ResponseCacheStore.php b/src/Cache/ResponseCacheStore.php new file mode 100644 index 0000000..91355f3 --- /dev/null +++ b/src/Cache/ResponseCacheStore.php @@ -0,0 +1,15 @@ + */ @@ -17,6 +19,16 @@ final class Container private bool $scopeActive = false; + /** @var array */ + private array $routeBindings = []; + + /** @param class-string $class @param callable(string, Container): object $resolver */ + public function bindRoute(string $class, callable $resolver): self + { + $this->routeBindings[$class] = \Closure::fromCallable($resolver); + return $this; + } + /** @param class-string|string $id */ public function bind(string $id, callable|string|null $factory = null): self { @@ -50,6 +62,22 @@ public function scopedInstance(string $id, mixed $instance): self return $this; } + public function scopedValue(string $id): mixed + { + return $this->scoped[$id] ?? null; + } + + /** @return array{state: int, scopedEntries: int, singletonEntries: int, bindings: int} */ + public function diagnostics(): array + { + return [ + 'state' => ($this->scopeActive ? ContainerState::RequestActive : ContainerState::Idle)->value, + 'scopedEntries' => count($this->scoped), + 'singletonEntries' => count($this->singletons), + 'bindings' => count($this->bindings), + ]; + } + public function beginScope(): void { if ($this->scopeActive) { @@ -132,11 +160,30 @@ private function resolveParameters(array $parameters, array $named = [], array $ { $arguments = []; foreach ($parameters as $parameter) { + $type = $parameter->getType(); if (array_key_exists($parameter->getName(), $named)) { - $arguments[] = $named[$parameter->getName()]; + $value = $named[$parameter->getName()]; + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $class = $type->getName(); + $resolver = $this->routeBindings[$class] ?? null; + if ($resolver !== null) { + $resolved = $resolver(self::routeValue($value), $this); + } elseif (is_a($class, RouteBindable::class, true)) { + $resolved = $class::resolveRouteBinding(self::routeValue($value)); + } else { + throw new \RuntimeException( + "Route parameter {$parameter->getName()} cannot resolve {$class}; register bindRoute() or implement RouteBindable.", + ); + } + if (!$resolved instanceof $class) { + throw new \UnexpectedValueException("Route binding for {$class} returned another type."); + } + $arguments[] = $resolved; + } else { + $arguments[] = $value; + } continue; } - $type = $parameter->getType(); if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { $class = $type->getName(); $matched = null; @@ -183,4 +230,15 @@ private function register( unset($this->singletons[$id], $this->scoped[$id]); return $this; } + + private static function routeValue(mixed $value): string + { + if (is_string($value)) { + return $value; + } + if (is_int($value) || is_float($value) || is_bool($value) || $value instanceof \Stringable) { + return (string) $value; + } + throw new \UnexpectedValueException('Route binding values must be scalar or stringable.'); + } } diff --git a/src/Container/ContainerState.php b/src/Container/ContainerState.php new file mode 100644 index 0000000..dd188b5 --- /dev/null +++ b/src/Container/ContainerState.php @@ -0,0 +1,12 @@ +container->get($this->middleware); + if (!$middleware instanceof MiddlewareInterface) { + throw new \UnexpectedValueException("Container entry {$this->middleware} is not middleware."); + } + return $middleware->process($request, $response, $next); + } +} + diff --git a/src/Events/EventDispatcher.php b/src/Events/EventDispatcher.php new file mode 100644 index 0000000..37e79c0 --- /dev/null +++ b/src/Events/EventDispatcher.php @@ -0,0 +1,11 @@ +> */ + private array $listeners = []; + + public function __construct(private readonly Container $container) + { + } + + /** @param class-string $event @param callable|class-string $listener */ + public function listen(string $event, callable|string $listener): self + { + $this->listeners[$event][] = $listener; + return $this; + } + + public function dispatch(object $event): object + { + foreach ($this->listeners[$event::class] ?? [] as $listener) { + $resolved = is_string($listener) ? $this->container->get($listener) : $listener; + if (!is_callable($resolved)) { + throw new \UnexpectedValueException('Event listeners must resolve to callables.'); + } + $resolved($event); + } + return $event; + } +} diff --git a/src/Health/HealthCheck.php b/src/Health/HealthCheck.php new file mode 100644 index 0000000..7ae5c7e --- /dev/null +++ b/src/Health/HealthCheck.php @@ -0,0 +1,11 @@ + */ + private array $checks = []; + + public function add(string $name, HealthCheck $check): self + { + if ($name === '' || isset($this->checks[$name])) { + throw new \InvalidArgumentException('Health-check name must be non-empty and unique.'); + } + $this->checks[$name] = $check; + return $this; + } + + /** @return array{state: int, checks: array}>} */ + public function run(): array + { + $state = HealthState::Healthy; + $checks = []; + foreach ($this->checks as $name => $check) { + $result = $check->check(); + if ($result->state->value > $state->value) { + $state = $result->state; + } + $checks[$name] = ['state' => $result->state->value, 'details' => $result->details]; + } + return ['state' => $state->value, 'checks' => $checks]; + } +} + diff --git a/src/Health/HealthResult.php b/src/Health/HealthResult.php new file mode 100644 index 0000000..d33d90f --- /dev/null +++ b/src/Health/HealthResult.php @@ -0,0 +1,16 @@ + $details */ + public function __construct( + public HealthState $state, + public array $details = [], + ) { + } +} + diff --git a/src/Health/HealthState.php b/src/Health/HealthState.php new file mode 100644 index 0000000..47eafbb --- /dev/null +++ b/src/Health/HealthState.php @@ -0,0 +1,13 @@ + $trustedProxies */ + public function __construct(private array $trustedProxies = []) + { + } + + public function resolve(Request $request): string + { + $remote = is_string($_SERVER['REMOTE_ADDR'] ?? null) ? $_SERVER['REMOTE_ADDR'] : 'unknown'; + if (!in_array($remote, $this->trustedProxies, true)) { + return $remote; + } + $forwarded = $request->getHeader('x-forwarded-for'); + if ($forwarded === null) { + return $remote; + } + $candidate = trim(explode(',', $forwarded)[0]); + return filter_var($candidate, FILTER_VALIDATE_IP) === false ? $remote : $candidate; + } +} + diff --git a/src/Http/ResponseSnapshot.php b/src/Http/ResponseSnapshot.php new file mode 100644 index 0000000..026ccab --- /dev/null +++ b/src/Http/ResponseSnapshot.php @@ -0,0 +1,36 @@ +> $headers */ + public function __construct( + public int $status, + public array $headers, + public string $body, + ) { + } + + public static function capture(Response $response): self + { + $export = $response->export(); + return new self($export['status'], $export['headers'], $export['body']); + } + + public function restore(Response $response): Response + { + $response->status($this->status); + foreach ($this->headers as $name => $values) { + foreach ($values as $index => $value) { + $index === 0 ? $response->header($name, $value) : $response->addHeader($name, $value); + } + } + return $response->send($this->body); + } +} + diff --git a/src/Idempotency/IdempotencyRecord.php b/src/Idempotency/IdempotencyRecord.php new file mode 100644 index 0000000..448e842 --- /dev/null +++ b/src/Idempotency/IdempotencyRecord.php @@ -0,0 +1,18 @@ + */ + private array $records = []; + + public function __construct(private readonly int $maximumRecords = 10_000) + { + if ($maximumRecords < 1) { + throw new \InvalidArgumentException('Maximum idempotency records must be positive.'); + } + } + + public function get(string $key, int $now): ?IdempotencyRecord + { + $record = $this->records[$key] ?? null; + if ($record !== null && $record->expiresAt <= $now) { + unset($this->records[$key]); + return null; + } + return $record; + } + + public function put(string $key, IdempotencyRecord $record): void + { + if (!isset($this->records[$key]) && count($this->records) >= $this->maximumRecords) { + throw new \RuntimeException('The bounded idempotency store is full.'); + } + $this->records[$key] = $record; + } +} + diff --git a/src/Jobs/JobDispatcher.php b/src/Jobs/JobDispatcher.php new file mode 100644 index 0000000..b3a4ed4 --- /dev/null +++ b/src/Jobs/JobDispatcher.php @@ -0,0 +1,11 @@ + */ + private array $queue = []; + + public function __construct(private readonly int $maximumQueuedJobs = 10_000) + { + if ($maximumQueuedJobs < 1) { + throw new \InvalidArgumentException('Maximum queued jobs must be positive.'); + } + } + + public function dispatch(object $job, int $maximumAttempts = 3, int $delaySeconds = 0): JobEnvelope + { + if ($delaySeconds < 0 || count($this->queue) >= $this->maximumQueuedJobs) { + throw new \RuntimeException('Job cannot be added to the bounded queue.'); + } + $envelope = new JobEnvelope( + bin2hex(random_bytes(16)), + $job, + $maximumAttempts, + time() + $delaySeconds, + ); + $this->queue[] = $envelope; + return $envelope; + } + + /** @return list */ + public function pending(): array + { + return $this->queue; + } +} + diff --git a/src/Middleware/AuthenticateMiddleware.php b/src/Middleware/AuthenticateMiddleware.php new file mode 100644 index 0000000..a5fabe2 --- /dev/null +++ b/src/Middleware/AuthenticateMiddleware.php @@ -0,0 +1,38 @@ +authenticator->authenticate($request); + if ($principal === null) { + throw new HttpException(401, ProblemCode::Unauthenticated, 'Authentication is required.'); + } + $this->container + ->scopedInstance(Principal::class, $principal) + ->scopedInstance(AuthContext::class, new AuthContext($principal)); + return $next->handle($request, $response); + } +} + diff --git a/src/Middleware/AuthorizeMiddleware.php b/src/Middleware/AuthorizeMiddleware.php new file mode 100644 index 0000000..4ddc93a --- /dev/null +++ b/src/Middleware/AuthorizeMiddleware.php @@ -0,0 +1,35 @@ +container->get(AuthContext::class); + if (!$auth instanceof AuthContext || !$auth->principal->can($this->ability)) { + throw new HttpException(403, ProblemCode::Forbidden, 'This action is not authorized.'); + } + return $next->handle($request, $response); + } +} diff --git a/src/Middleware/CorsMiddleware.php b/src/Middleware/CorsMiddleware.php index bcf9529..8491845 100644 --- a/src/Middleware/CorsMiddleware.php +++ b/src/Middleware/CorsMiddleware.php @@ -43,6 +43,19 @@ public function process(Request $request, Response $response, RequestHandlerInte $response->header('access-control-allow-credentials', 'true'); } if ($request->method === 'OPTIONS') { + $requestedMethod = strtoupper($request->getHeader('access-control-request-method') ?? ''); + if ($requestedMethod === '' || !in_array($requestedMethod, $this->methods, true)) { + return $response->json(['error' => 'CORS method is not allowed'], 403); + } + $requestedHeaders = array_values(array_filter(array_map( + static fn (string $header): string => strtolower(trim($header)), + explode(',', $request->getHeader('access-control-request-headers') ?? ''), + ))); + foreach ($requestedHeaders as $requestedHeader) { + if (!in_array($requestedHeader, array_map('strtolower', $this->headers), true)) { + return $response->json(['error' => 'CORS header is not allowed'], 403); + } + } return $response ->status(204) ->header('access-control-allow-methods', implode(', ', $this->methods)) diff --git a/src/Middleware/DeadlineMiddleware.php b/src/Middleware/DeadlineMiddleware.php new file mode 100644 index 0000000..f815d86 --- /dev/null +++ b/src/Middleware/DeadlineMiddleware.php @@ -0,0 +1,34 @@ +seconds); + $this->container->scopedInstance(Deadline::class, $deadline); + $result = $next->handle($request, $response); + $deadline->throwIfExpired(); + return $result; + } +} + diff --git a/src/Middleware/IdempotencyMiddleware.php b/src/Middleware/IdempotencyMiddleware.php new file mode 100644 index 0000000..45cd9c5 --- /dev/null +++ b/src/Middleware/IdempotencyMiddleware.php @@ -0,0 +1,61 @@ +getHeader('idempotency-key'); + if ($key === null || $key === '') { + if ($this->required) { + throw new HttpException(422, ProblemCode::ValidationFailed, 'Idempotency-Key is required.'); + } + return $next->handle($request, $response); + } + if (strlen($key) > 255 || preg_match('/^[\x21-\x7E]+$/D', $key) !== 1) { + throw new HttpException(422, ProblemCode::ValidationFailed, 'Idempotency-Key is invalid.'); + } + + $fingerprint = hash('sha256', $request->method . "\n" . $request->path . "\n" . $request->body()); + $now = time(); + $existing = $this->store->get($key, $now); + if ($existing !== null) { + if (!hash_equals($existing->fingerprint, $fingerprint)) { + throw new HttpException(409, ProblemCode::Conflict, 'Idempotency-Key was reused with another request.'); + } + return $existing->response->restore($response)->header('idempotency-replayed', 'true'); + } + + $result = $next->handle($request, $response); + $this->store->put($key, new IdempotencyRecord( + $fingerprint, + ResponseSnapshot::capture($result), + $now + $this->ttlSeconds, + )); + return $result; + } +} + diff --git a/src/Middleware/ObserveRequestMiddleware.php b/src/Middleware/ObserveRequestMiddleware.php new file mode 100644 index 0000000..a8f1eec --- /dev/null +++ b/src/Middleware/ObserveRequestMiddleware.php @@ -0,0 +1,46 @@ +handle($request, $response); + } catch (\Throwable $error) { + $exception = $error::class; + throw $error; + } finally { + $export = $response->export(); + $route = $this->container?->scopedValue(Route::class); + $this->observer->record(new RequestObservation( + $request->method, + $route instanceof Route ? $route->path : $request->path, + $exception === null ? $export['status'] : 500, + (hrtime(true) - $startedAt) / 1_000_000_000, + $exception, + )); + } + } +} diff --git a/src/Middleware/ResolveTenantMiddleware.php b/src/Middleware/ResolveTenantMiddleware.php new file mode 100644 index 0000000..75478ba --- /dev/null +++ b/src/Middleware/ResolveTenantMiddleware.php @@ -0,0 +1,42 @@ +resolver->resolve($request); + if ($tenant === null) { + if ($this->required) { + throw new HttpException(404, ProblemCode::NotFound, 'Tenant was not found.'); + } + return $next->handle($request, $response); + } + $this->container + ->scopedInstance(Tenant::class, $tenant) + ->scopedInstance(TenantContext::class, new TenantContext($tenant)); + return $next->handle($request, $response); + } +} + diff --git a/src/Middleware/ResponseCacheMiddleware.php b/src/Middleware/ResponseCacheMiddleware.php new file mode 100644 index 0000000..8f9ba75 --- /dev/null +++ b/src/Middleware/ResponseCacheMiddleware.php @@ -0,0 +1,55 @@ +keyResolver = $keyResolver === null + ? static fn (Request $request): string => hash('sha256', $request->method . "\n" . $request->path . "\n" . serialize($request->query())) + : \Closure::fromCallable($keyResolver); + } + + public function process(Request $request, Response $response, RequestHandlerInterface $next): Response + { + if (!in_array($request->method, ['GET', 'HEAD'], true)) { + return $next->handle($request, $response); + } + $key = ($this->keyResolver)($request); + if ($key === '') { + throw new \UnexpectedValueException('Response cache key cannot be empty.'); + } + $now = time(); + $cached = $this->store->get($key, $now); + if ($cached !== null) { + return $cached->response->restore($response)->header('x-cache', 'HIT'); + } + $result = $next->handle($request, $response); + $export = $result->export(); + if ($export['status'] >= 200 && $export['status'] < 300) { + $this->store->put($key, new CacheRecord(ResponseSnapshot::capture($result), $now + $this->ttlSeconds)); + } + return $result->header('x-cache', 'MISS'); + } +} + diff --git a/src/Middleware/TransactionalMiddleware.php b/src/Middleware/TransactionalMiddleware.php new file mode 100644 index 0000000..f5d483a --- /dev/null +++ b/src/Middleware/TransactionalMiddleware.php @@ -0,0 +1,30 @@ +transactions->transaction( + static fn (): Response => $next->handle($request, $response), + ); + if (!$result instanceof Response) { + throw new \UnexpectedValueException('Transaction manager must preserve the response result.'); + } + return $result; + } +} + diff --git a/src/Observability/RequestObservation.php b/src/Observability/RequestObservation.php new file mode 100644 index 0000000..442dffa --- /dev/null +++ b/src/Observability/RequestObservation.php @@ -0,0 +1,18 @@ + $document */ + public function __construct(private array $document) + { + } + + public function generate(ClientLanguage $language): string + { + $operations = $this->operations(); + return match ($language) { + ClientLanguage::TypeScript => $this->typescript($operations), + ClientLanguage::Kotlin => $this->kotlin($operations), + ClientLanguage::Swift => $this->swift($operations), + }; + } + + /** @return list */ + private function operations(): array + { + $result = []; + $paths = $this->document['paths'] ?? []; + if (!is_array($paths)) { + return []; + } + foreach ($paths as $path => $methods) { + if (!is_string($path) || !is_array($methods)) { + continue; + } + foreach ($methods as $method => $operation) { + if (!is_string($method) || !is_array($operation) || !is_string($operation['operationId'] ?? null)) { + continue; + } + $result[] = ['id' => self::identifier($operation['operationId']), 'method' => strtoupper($method), 'path' => $path]; + } + } + return $result; + } + + /** @param list $operations */ + private function typescript(array $operations): string + { + $methods = array_map( + static fn (array $operation): string => " {$operation['id']} = () => this.request('{$operation['method']}', '{$operation['path']}');", + $operations, + ); + return "export class PamApiClient {\n constructor(private readonly request: (method: string, path: string) => Promise) {}\n" + . implode("\n", $methods) . "\n}\n"; + } + + /** @param list $operations */ + private function kotlin(array $operations): string + { + $methods = array_map( + static fn (array $operation): string => " suspend fun {$operation['id']}() = request(\"{$operation['method']}\", \"{$operation['path']}\")", + $operations, + ); + return "class PamApiClient(private val request: suspend (String, String) -> Any?) {\n" + . implode("\n", $methods) . "\n}\n"; + } + + /** @param list $operations */ + private function swift(array $operations): string + { + $methods = array_map( + static fn (array $operation): string => " func {$operation['id']}() async throws -> Any { try await request(\"{$operation['method']}\", \"{$operation['path']}\") }", + $operations, + ); + return "struct PamApiClient {\n let request: (String, String) async throws -> Any\n" + . implode("\n", $methods) . "\n}\n"; + } + + private static function identifier(string $value): string + { + $identifier = preg_replace('/[^A-Za-z0-9_]/', '_', $value) ?? 'operation'; + return preg_match('/^[A-Za-z_]/', $identifier) === 1 ? $identifier : '_' . $identifier; + } +} + diff --git a/src/OpenApi/ClientLanguage.php b/src/OpenApi/ClientLanguage.php new file mode 100644 index 0000000..d086365 --- /dev/null +++ b/src/OpenApi/ClientLanguage.php @@ -0,0 +1,13 @@ + $previous + * @param array $current + * @return list + */ + public function breakingChanges(array $previous, array $current): array + { + $oldPaths = is_array($previous['paths'] ?? null) ? $previous['paths'] : []; + $newPaths = is_array($current['paths'] ?? null) ? $current['paths'] : []; + $changes = []; + foreach ($oldPaths as $path => $oldOperations) { + if (!is_string($path) || !array_key_exists($path, $newPaths)) { + $changes[] = new CompatibilityChange( + CompatibilityChangeCode::PathRemoved, + (string) $path, + "Path {$path} was removed.", + ); + continue; + } + if (!is_array($oldOperations) || !is_array($newPaths[$path])) { + continue; + } + foreach ($oldOperations as $method => $operation) { + if (is_string($method) && !array_key_exists($method, $newPaths[$path])) { + $changes[] = new CompatibilityChange( + CompatibilityChangeCode::OperationRemoved, + strtoupper($method) . ' ' . $path, + "Operation {$method} {$path} was removed.", + ); + } + } + } + return $changes; + } +} + diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php new file mode 100644 index 0000000..40e6c79 --- /dev/null +++ b/src/OpenApi/OpenApiGenerator.php @@ -0,0 +1,100 @@ + */ + public function generate(): array + { + $paths = []; + foreach ($this->router->routes() as $route) { + $paths[$route->path][strtolower($route->method)] = $this->operation($route); + } + ksort($paths, SORT_STRING); + return [ + 'openapi' => '3.1.0', + 'info' => ['title' => $this->title, 'version' => $this->version], + 'paths' => $paths, + ]; + } + + public function toJson(): string + { + return json_encode( + $this->generate(), + JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES, + ) . "\n"; + } + + public function client(ClientLanguage $language): string + { + return (new ClientGenerator($this->generate()))->generate($language); + } + + /** @return array */ + private function operation(Route $route): array + { + $operation = [ + 'operationId' => $route->name ?? strtolower($route->method) . str_replace(['/', '{', '}'], ['.', '', ''], $route->path), + 'responses' => [ + '200' => ['description' => 'Successful response'], + '422' => ['description' => 'Validation failed'], + '500' => ['description' => 'Internal server error'], + ], + ]; + if ($route->summary !== null) { + $operation['summary'] = $route->summary; + } + if ($route->tags !== []) { + $operation['tags'] = $route->tags; + } + if ($route->parameterNames !== []) { + $operation['parameters'] = array_map( + static fn (string $name): array => [ + 'name' => $name, + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + $route->parameterNames, + ); + } + if ($route->input !== null) { + $operation['requestBody'] = [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/' . self::shortName($route->input)], + ], + ], + 'x-pam-class' => $route->input, + ]; + } + if ($route->output !== null) { + $operation['responses']['200']['content']['application/json']['schema'] = [ + '$ref' => '#/components/schemas/' . self::shortName($route->output), + ]; + $operation['responses']['200']['x-pam-class'] = $route->output; + } + return $operation; + } + + /** @param class-string $class */ + private static function shortName(string $class): string + { + return (new \ReflectionClass($class))->getShortName(); + } +} diff --git a/src/PendingRoute.php b/src/PendingRoute.php index d585c80..43d975c 100644 --- a/src/PendingRoute.php +++ b/src/PendingRoute.php @@ -26,18 +26,67 @@ public function where(string $parameter, string|RouteConstraint $constraint): se return $this; } - public function middleware(object|callable $middleware): self + /** @param MiddlewareInterface|callable|class-string $middleware */ + public function middleware(object|callable|string $middleware): self { - if (!$middleware instanceof MiddlewareInterface && !is_callable($middleware)) { + if (is_string($middleware)) { + if (is_a($middleware, MiddlewareInterface::class, true)) { + $middleware = new ContainerMiddleware($this->container(), $middleware); + } + } + if (is_object($middleware) && !$middleware instanceof MiddlewareInterface && !method_exists($middleware, '__invoke')) { throw new \InvalidArgumentException('Route middleware must implement the PAM contract or be callable.'); } $this->route->middleware[] = $middleware; return $this; } + private function container(): \Pam\Api\Container\Container + { + return $this->router->container(); + } + public function definition(): Route { return $this->route; } -} + public function summary(string $summary): self + { + if ($summary === '') { + throw new \InvalidArgumentException('Route summary cannot be empty.'); + } + $this->route->summary = $summary; + return $this; + } + + /** @param list $tags */ + public function tags(array $tags): self + { + if ($tags === [] || array_filter($tags, static fn (string $tag): bool => $tag === '') !== []) { + throw new \InvalidArgumentException('Route tags must be non-empty strings.'); + } + $this->route->tags = array_values(array_unique($tags)); + return $this; + } + + /** @param class-string $request */ + public function input(string $request): self + { + if (!class_exists($request)) { + throw new \InvalidArgumentException("Input class {$request} does not exist."); + } + $this->route->input = $request; + return $this; + } + + /** @param class-string $resource */ + public function output(string $resource): self + { + if (!class_exists($resource)) { + throw new \InvalidArgumentException("Output class {$resource} does not exist."); + } + $this->route->output = $resource; + return $this; + } +} diff --git a/src/Resilience/CircuitBreaker.php b/src/Resilience/CircuitBreaker.php new file mode 100644 index 0000000..9894e69 --- /dev/null +++ b/src/Resilience/CircuitBreaker.php @@ -0,0 +1,50 @@ +state === CircuitState::Open && time() >= $this->openedAt + $this->cooldownSeconds) { + $this->state = CircuitState::HalfOpen; + } + return $this->state; + } + + public function call(callable $operation): mixed + { + if ($this->state() === CircuitState::Open) { + throw new CircuitOpenException('Circuit is open.'); + } + try { + $result = $operation(); + $this->failures = 0; + $this->state = CircuitState::Closed; + return $result; + } catch (\Throwable $error) { + ++$this->failures; + if ($this->failures >= $this->failureThreshold) { + $this->state = CircuitState::Open; + $this->openedAt = time(); + } + throw $error; + } + } +} + diff --git a/src/Resilience/CircuitOpenException.php b/src/Resilience/CircuitOpenException.php new file mode 100644 index 0000000..4aeb8a6 --- /dev/null +++ b/src/Resilience/CircuitOpenException.php @@ -0,0 +1,10 @@ +when = $when === null + ? static fn (\Throwable $error): bool => true + : \Closure::fromCallable($when); + } + + public function run(callable $operation): mixed + { + for ($attempt = 1; ; ++$attempt) { + try { + return $operation($attempt); + } catch (\Throwable $error) { + if ($attempt >= $this->attempts || !($this->when)($error)) { + throw $error; + } + $delay = $this->initialDelayMilliseconds * (2 ** ($attempt - 1)); + if ($delay > 0) { + usleep($delay * 1_000); + } + } + } + } +} + diff --git a/src/Route.php b/src/Route.php index d5549cf..1d9d156 100644 --- a/src/Route.php +++ b/src/Route.php @@ -15,6 +15,20 @@ final class Route public ?string $name = null; + public ?string $summary = null; + + /** @var list */ + public array $tags = []; + + /** @var class-string|null */ + public ?string $input = null; + + /** @var class-string|null */ + public ?string $output = null; + + /** @var callable|class-string|array{class-string, non-empty-string}|null */ + public mixed $sourceHandler = null; + /** @param list $parameterNames */ public function __construct( public string $method, diff --git a/src/RouteRegistrar.php b/src/RouteRegistrar.php index b9b363e..4195672 100644 --- a/src/RouteRegistrar.php +++ b/src/RouteRegistrar.php @@ -25,11 +25,14 @@ public function prefix(string $prefix): self return $clone; } - /** @param MiddlewareInterface|callable|list $middleware */ - public function middleware(MiddlewareInterface|callable|array $middleware): self + /** @param MiddlewareInterface|callable|class-string|list> $middleware */ + public function middleware(MiddlewareInterface|callable|string|array $middleware): self { $clone = clone $this; foreach (is_array($middleware) ? $middleware : [$middleware] as $layer) { + if (is_string($layer) && is_a($layer, MiddlewareInterface::class, true)) { + $layer = new ContainerMiddleware($this->app->container(), $layer); + } if (!$layer instanceof MiddlewareInterface && !is_callable($layer)) { throw new \InvalidArgumentException('Group middleware must implement the PAM contract or be callable.'); } diff --git a/src/Router.php b/src/Router.php index ec0b74b..6689378 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,8 +4,18 @@ namespace Pam\Api; +use Pam\Api\Container\Container; + final class Router { + public function __construct(private readonly ?Container $container = null) + { + } + + public function container(): Container + { + return $this->container ?? throw new \LogicException('Router has no application container.'); + } /** @var list */ private array $routes = []; diff --git a/src/Routing/RouteBindable.php b/src/Routing/RouteBindable.php new file mode 100644 index 0000000..dd919a4 --- /dev/null +++ b/src/Routing/RouteBindable.php @@ -0,0 +1,11 @@ +expiresAtNanoseconds = hrtime(true) + (int) ($seconds * 1_000_000_000); + } + + public function expired(): bool + { + return hrtime(true) >= $this->expiresAtNanoseconds; + } + + public function throwIfExpired(): void + { + if ($this->expired()) { + throw new HttpException(504, ProblemCode::Timeout, 'Request deadline exceeded.'); + } + } +} + diff --git a/src/Tenancy/Tenant.php b/src/Tenancy/Tenant.php new file mode 100644 index 0000000..7e5e43b --- /dev/null +++ b/src/Tenancy/Tenant.php @@ -0,0 +1,11 @@ + $query + * @param array $headers + */ + public function request( + string $method, + string $path, + array $query = [], + array $headers = [], + string $body = '', + ): TestResponse { + $normalized = []; + foreach ($headers as $name => $value) { + $normalized[strtolower($name)] = [$value]; + } + $request = new Request(strtoupper($method), $path, $query, $normalized, $body); + return new TestResponse($this->app->handle($request, new Response())->export()); + } + + /** + * @param array $payload + * @param array $headers + */ + public function postJson(string $path, array $payload, array $headers = []): TestResponse + { + $headers['content-type'] = 'application/json'; + return $this->request( + 'POST', + $path, + headers: $headers, + body: json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + ); + } + + /** @param array $headers */ + public function get(string $path, array $headers = []): TestResponse + { + return $this->request('GET', $path, headers: $headers); + } +} diff --git a/src/Testing/TestResponse.php b/src/Testing/TestResponse.php new file mode 100644 index 0000000..fe0d26f --- /dev/null +++ b/src/Testing/TestResponse.php @@ -0,0 +1,58 @@ +>, body: string, chunks: list} $response */ + public function __construct(private array $response) + { + } + + public function status(): int + { + return $this->response['status']; + } + + public function body(): string + { + return $this->response['body']; + } + + public function json(): mixed + { + return json_decode($this->body(), true, 512, JSON_THROW_ON_ERROR); + } + + public function header(string $name): ?string + { + $values = $this->response['headers'][strtolower($name)] ?? null; + return $values === null ? null : implode(', ', $values); + } + + public function assertStatus(int $expected): self + { + if ($this->status() !== $expected) { + throw new \RuntimeException("Expected status {$expected}; received {$this->status()}."); + } + return $this; + } + + public function assertJsonPath(string $path, mixed $expected): self + { + $value = $this->json(); + foreach (explode('.', $path) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + throw new \RuntimeException("JSON path {$path} does not exist."); + } + $value = $value[$segment]; + } + if ($value !== $expected) { + throw new \RuntimeException("JSON path {$path} does not contain the expected value."); + } + return $this; + } +} + diff --git a/src/Transactions/TransactionManager.php b/src/Transactions/TransactionManager.php new file mode 100644 index 0000000..4bd60eb --- /dev/null +++ b/src/Transactions/TransactionManager.php @@ -0,0 +1,11 @@ + $class + * @param array $data + * @return T + */ + public function hydrate(string $class, array $data): object + { + $reflection = new \ReflectionClass($class); + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return $reflection->newInstance(); + } + $arguments = []; + foreach ($constructor->getParameters() as $parameter) { + $name = $parameter->getName(); + if (!array_key_exists($name, $data)) { + if ($parameter->isDefaultValueAvailable()) { + $arguments[] = $parameter->getDefaultValue(); + continue; + } + throw new \InvalidArgumentException("Validated field {$name} is required by DTO {$class}."); + } + $value = $data[$name]; + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + if (is_a($typeName, \BackedEnum::class, true)) { + if (!is_int($value) && !is_string($value)) { + throw new \InvalidArgumentException("DTO enum field {$name} must be an integer or string."); + } + $value = $typeName::tryFrom($value) + ?? throw new \InvalidArgumentException("DTO enum field {$name} is invalid."); + } + } + $arguments[] = $value; + } + return $reflection->newInstanceArgs($arguments); + } +} + diff --git a/src/Validation/FormRequest.php b/src/Validation/FormRequest.php index 3792b00..53a9334 100644 --- a/src/Validation/FormRequest.php +++ b/src/Validation/FormRequest.php @@ -35,6 +35,16 @@ final public function input(string $key, mixed $default = null): mixed return $this->validated[$key] ?? $default; } + /** + * @template T of object + * @param class-string $class + * @return T + */ + final public function dto(string $class): object + { + return (new DtoHydrator())->hydrate($class, $this->validated); + } + /** @return array */ private function validate(): array { @@ -87,4 +97,3 @@ private function validateBuiltin(string $field, mixed $value, bool $exists, stri }; } } - diff --git a/tests/AuthAndIdempotencyTest.php b/tests/AuthAndIdempotencyTest.php new file mode 100644 index 0000000..358c6b7 --- /dev/null +++ b/tests/AuthAndIdempotencyTest.php @@ -0,0 +1,91 @@ +getHeader('authorization') === 'Bearer valid' + ? new TestPrincipal('user-1') + : null; + } + }; + $app->middleware(new AuthenticateMiddleware($authenticator, $app->container())); + $app->get('/me', [AuthController::class, 'show']); + + $unauthorized = $app->handle(new Request('GET', '/me', [], [], ''), new Response())->export(); + $authorized = $app->handle(new Request( + 'GET', '/me', [], ['authorization' => ['Bearer valid']], '', + ), new Response())->export(); + + self::assertSame(401, $unauthorized['status']); + self::assertSame('{"id":"user-1"}', $authorized['body']); + } + + public function testIdempotencyReplaysResponseAndRejectsAnotherPayload(): void + { + $app = new App(discoverPackages: false); + $calls = 0; + $app->post('/orders', static function (Request $request, Response $response) use (&$calls): Response { + ++$calls; + return $response->json(['sequence' => $calls], 201); + })->middleware(new IdempotencyMiddleware(new MemoryIdempotencyStore())); + + $first = $app->handle($this->idempotentRequest('{"amount":10}'), new Response())->export(); + $replay = $app->handle($this->idempotentRequest('{"amount":10}'), new Response())->export(); + $conflict = $app->handle($this->idempotentRequest('{"amount":20}'), new Response())->export(); + + self::assertSame(1, $calls); + self::assertSame($first['body'], $replay['body']); + self::assertSame(['true'], $replay['headers']['idempotency-replayed']); + self::assertSame(409, $conflict['status']); + } + + private function idempotentRequest(string $body): Request + { + return new Request('POST', '/orders', [], ['idempotency-key' => ['order-1']], $body); + } +} + +final readonly class TestPrincipal implements Principal +{ + public function __construct(private string $id) + { + } + + public function identifier(): string + { + return $this->id; + } + + public function can(string $ability): bool + { + return $ability === 'profile.read'; + } +} + +final class AuthController +{ + public function show(Principal $principal, Response $response): Response + { + return $response->json(['id' => $principal->identifier()]); + } +} + diff --git a/tests/ResponseCacheTest.php b/tests/ResponseCacheTest.php new file mode 100644 index 0000000..6cd679d --- /dev/null +++ b/tests/ResponseCacheTest.php @@ -0,0 +1,33 @@ +get('/products', static function (Request $request, Response $response) use (&$calls): Response { + return $response->json(['generation' => ++$calls]); + })->middleware(new ResponseCacheMiddleware(new MemoryResponseCacheStore())); + + $first = $app->handle(new Request('GET', '/products', [], [], ''), new Response())->export(); + $second = $app->handle(new Request('GET', '/products', [], [], ''), new Response())->export(); + + self::assertSame(1, $calls); + self::assertSame(['MISS'], $first['headers']['x-cache']); + self::assertSame(['HIT'], $second['headers']['x-cache']); + self::assertSame($first['body'], $second['body']); + } +} + diff --git a/tests/RouteBindingAndOpenApiTest.php b/tests/RouteBindingAndOpenApiTest.php new file mode 100644 index 0000000..025482e --- /dev/null +++ b/tests/RouteBindingAndOpenApiTest.php @@ -0,0 +1,85 @@ +get('/users/{user}', [BoundUserController::class, 'show']); + + $response = $app->handle(new Request('GET', '/users/42', [], [], ''), new Response())->export(); + + self::assertSame('{"id":42}', $response['body']); + } + + public function testOpenApiUsesFluentRouteMetadata(): void + { + $app = new App(discoverPackages: false); + $app->post('/users/{user}', [BoundUserController::class, 'show']) + ->name('users.update') + ->summary('Update a user') + ->tags(['Users']) + ->input(UpdateUserRequest::class) + ->output(BoundUserResource::class); + + $document = $app->openApi('Example', '2.0.0')->generate(); + $paths = $document['paths']; + self::assertIsArray($paths); + $path = $paths['/users/{user}']; + self::assertIsArray($path); + $operation = $path['post']; + + self::assertIsArray($operation); + self::assertSame('users.update', $operation['operationId']); + self::assertSame(['Users'], $operation['tags']); + self::assertSame('3.1.0', $document['openapi']); + } +} + +final readonly class BoundUser implements RouteBindable +{ + public function __construct(public int $id) + { + } + + public static function resolveRouteBinding(string $value): static + { + return new self((int) $value); + } +} + +final class BoundUserController +{ + public function show(BoundUser $user, Response $response): Response + { + return $response->json(['id' => $user->id]); + } +} + +final class UpdateUserRequest extends FormRequest +{ + public function rules(): array + { + return ['name' => ['required', 'string']]; + } +} + +final readonly class BoundUserResource extends JsonResource +{ + public function toArray(Request $request): array + { + return ['id' => $this->resource instanceof BoundUser ? $this->resource->id : null]; + } +} diff --git a/tests/TestingAndClientGenerationTest.php b/tests/TestingAndClientGenerationTest.php new file mode 100644 index 0000000..f829af7 --- /dev/null +++ b/tests/TestingAndClientGenerationTest.php @@ -0,0 +1,41 @@ +post('/login', static fn (Request $request, Response $response): Response => + $response->json(['data' => ['status' => 1]], 201)); + + $result = (new TestClient($app)) + ->postJson('/login', ['email' => 'dev@pam.dev']) + ->assertStatus(201) + ->assertJsonPath('data.status', 1); + + self::assertSame(201, $result->status()); + } + + public function testOpenApiGeneratesThreeTypedClientSurfaces(): void + { + $app = new App(discoverPackages: false); + $app->get('/users', static fn (Request $request, Response $response): Response => $response->json([])) + ->name('users.index'); + $openApi = $app->openApi(); + + self::assertStringContainsString('users_index', $openApi->client(ClientLanguage::TypeScript)); + self::assertStringContainsString('users_index', $openApi->client(ClientLanguage::Kotlin)); + self::assertStringContainsString('users_index', $openApi->client(ClientLanguage::Swift)); + } +} From 93a3747519846d797dad861820ef97fac5f00fc0 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Thu, 20 Aug 2026 19:47:01 -0300 Subject: [PATCH 05/10] test: make verification clone-independent --- tests/bootstrap.php | 6 +- tests/runtime.php | 150 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 tests/runtime.php diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 77ec93e..348d28f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,8 +2,6 @@ declare(strict_types=1); -$runtimeBootstrap = dirname(__DIR__, 2) . '/runtime/bootstrap.php'; -if (!class_exists(\Pam\Http\Request::class) && is_file($runtimeBootstrap)) { - require_once $runtimeBootstrap; +if (!class_exists(\Pam\Http\Request::class)) { + require_once __DIR__ . '/runtime.php'; } - diff --git a/tests/runtime.php b/tests/runtime.php new file mode 100644 index 0000000..6d9a208 --- /dev/null +++ b/tests/runtime.php @@ -0,0 +1,150 @@ + $query + * @param array> $headers + * @param array $routeParameters + */ + public function __construct( + public readonly string $method, + public readonly string $path, + private readonly array $query, + private readonly array $headers, + private readonly string $body, + private readonly array $routeParameters = [], + ) { + } + + public function getHeader(string $name, ?string $default = null): ?string + { + $values = $this->headers[strtolower($name)] ?? []; + return $values === [] ? $default : implode(', ', $values); + } + + /** @return array */ + public function query(): array + { + return $this->query; + } + + public function body(): string + { + return $this->body; + } + + public function json(): mixed + { + return json_decode($this->body, true, 512, JSON_THROW_ON_ERROR); + } + + public function route(string $key, ?string $default = null): ?string + { + return $this->routeParameters[$key] ?? $default; + } + + /** @return array */ + public function routeParameters(): array + { + return $this->routeParameters; + } + + /** @param array $parameters */ + public function withRouteParameters(array $parameters): self + { + return new self($this->method, $this->path, $this->query, $this->headers, $this->body, $parameters); + } + } + + final class Response + { + private int $status = 200; + + /** @var array> */ + private array $headers = []; + + private string $body = ''; + + public function status(int $status): self + { + $this->status = $status; + return $this; + } + + public function header(string $name, string $value): self + { + $this->headers[strtolower($name)] = [$value]; + return $this; + } + + public function addHeader(string $name, string $value): self + { + $this->headers[strtolower($name)][] = $value; + return $this; + } + + public function send(string|int|float|bool|null $body): self + { + $this->body = $body === null ? '' : (is_bool($body) ? ($body ? 'true' : 'false') : (string) $body); + $this->headers['content-type'] ??= ['text/plain; charset=utf-8']; + return $this; + } + + public function json(mixed $data, int $status = 200): self + { + $this->status($status)->header('content-type', 'application/json; charset=utf-8'); + $this->body = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + return $this; + } + + public function isEmpty(): bool + { + return $this->body === ''; + } + + /** @return array{status: int, headers: array>, body: string, chunks: list} */ + public function export(): array + { + return ['status' => $this->status, 'headers' => $this->headers, 'body' => $this->body, 'chunks' => []]; + } + } + + final class Server + { + public static function create(callable $handler): self + { + return new self(); + } + + /** @param array $options */ + public function listen(int $port, string $host = '127.0.0.1', array $options = []): void + { + } + } +} + +namespace Pam\Internal { + final class Runtime + { + public static function registerPsrHandler(object $handler): void + { + } + + public static function registerMiddleware(object $middleware): void + { + } + + /** @param array $options */ + public static function listen(int $port, string $host, array $options): void + { + } + + public static function describeRoute(string $method, string $path): void + { + } + } +} From 9baa738513653f791cd836a9f76df7404542e92f Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Thu, 20 Aug 2026 19:49:12 -0300 Subject: [PATCH 06/10] fix: preserve chained route registration --- src/App.php | 2 +- src/PendingRoute.php | 40 +++++++++++++++++++++++++++++++++++++- tests/RouterFluentTest.php | 10 +++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/App.php b/src/App.php index 389c065..a4aa9be 100644 --- a/src/App.php +++ b/src/App.php @@ -279,6 +279,6 @@ private function registerRoute(string $method, string $path, callable|string|arr $this->assertMutable(); $route = $this->router->register($method, $path, $this->handlerResolver->resolve($handler)); $route->sourceHandler = $handler; - return new PendingRoute($this->router, $route); + return new PendingRoute($this, $this->router, $route); } } diff --git a/src/PendingRoute.php b/src/PendingRoute.php index 43d975c..33fe4a5 100644 --- a/src/PendingRoute.php +++ b/src/PendingRoute.php @@ -4,16 +4,54 @@ namespace Pam\Api; +use Pam\App; use Pam\Contracts\Http\MiddlewareInterface; final readonly class PendingRoute { public function __construct( + private App $app, private Router $router, private Route $route, ) { } + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function get(string $path, callable|string|array $handler): self + { + return $this->app->get($path, $handler); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function post(string $path, callable|string|array $handler): self + { + return $this->app->post($path, $handler); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function put(string $path, callable|string|array $handler): self + { + return $this->app->put($path, $handler); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function patch(string $path, callable|string|array $handler): self + { + return $this->app->patch($path, $handler); + } + + /** @param callable|class-string|array{class-string, non-empty-string} $handler */ + public function delete(string $path, callable|string|array $handler): self + { + return $this->app->delete($path, $handler); + } + + /** @param array $options */ + public function listen(int $port, string $host = '127.0.0.1', array $options = []): void + { + $this->app->listen($port, $host, $options); + } + public function name(string $name): self { $this->router->name($this->route, $name); @@ -43,7 +81,7 @@ public function middleware(object|callable|string $middleware): self private function container(): \Pam\Api\Container\Container { - return $this->router->container(); + return $this->app->container(); } public function definition(): Route diff --git a/tests/RouterFluentTest.php b/tests/RouterFluentTest.php index 74c6a7b..07a496f 100644 --- a/tests/RouterFluentTest.php +++ b/tests/RouterFluentTest.php @@ -13,6 +13,15 @@ final class RouterFluentTest extends TestCase { + public function testLegacyVerbChainingRemainsSupported(): void + { + $app = new App(discoverPackages: false); + $app->get('/one', static fn (Request $request, Response $response): Response => $response->send('one')) + ->post('/two', static fn (Request $request, Response $response): Response => $response->send('two')); + + self::assertSame(200, $app->handle($this->request('POST', '/two'), new Response())->export()['status']); + } + public function testAConstraintPreventsAnInvalidDynamicMatch(): void { $app = new App(discoverPackages: false); @@ -54,4 +63,3 @@ private function request(string $method, string $path): Request return new Request($method, $path, [], [], ''); } } - From b151b46542a66bff54389cb8c2ede6491c2fe7da Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Thu, 20 Aug 2026 19:51:36 -0300 Subject: [PATCH 07/10] fix: generate resolvable OpenAPI clients --- src/OpenApi/ClientGenerator.php | 69 ++++++++++++++++++++---- src/OpenApi/OpenApiGenerator.php | 16 +++++- tests/RouteBindingAndOpenApiTest.php | 7 +++ tests/TestingAndClientGenerationTest.php | 7 +++ 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/OpenApi/ClientGenerator.php b/src/OpenApi/ClientGenerator.php index ee44cde..b19f82b 100644 --- a/src/OpenApi/ClientGenerator.php +++ b/src/OpenApi/ClientGenerator.php @@ -21,7 +21,7 @@ public function generate(ClientLanguage $language): string }; } - /** @return list */ + /** @return list}> */ private function operations(): array { $result = []; @@ -37,39 +37,64 @@ private function operations(): array if (!is_string($method) || !is_array($operation) || !is_string($operation['operationId'] ?? null)) { continue; } - $result[] = ['id' => self::identifier($operation['operationId']), 'method' => strtoupper($method), 'path' => $path]; + preg_match_all('/\{([A-Za-z_][A-Za-z0-9_]*)\}/', $path, $matches); + $parameters = array_values(array_unique($matches[1])); + $result[] = [ + 'id' => self::identifier($operation['operationId']), + 'method' => strtoupper($method), + 'path' => $path, + 'parameters' => $parameters, + ]; } } return $result; } - /** @param list $operations */ + /** @param list}> $operations */ private function typescript(array $operations): string { $methods = array_map( - static fn (array $operation): string => " {$operation['id']} = () => this.request('{$operation['method']}', '{$operation['path']}');", + static fn (array $operation): string => sprintf( + ' %s = (%s) => this.request(\'%s\', `%s`);', + $operation['id'], + implode(', ', array_map(static fn (string $parameter): string => "{$parameter}: string | number", $operation['parameters'])), + $operation['method'], + self::typescriptPath($operation['path'], $operation['parameters']), + ), $operations, ); return "export class PamApiClient {\n constructor(private readonly request: (method: string, path: string) => Promise) {}\n" . implode("\n", $methods) . "\n}\n"; } - /** @param list $operations */ + /** @param list}> $operations */ private function kotlin(array $operations): string { $methods = array_map( - static fn (array $operation): string => " suspend fun {$operation['id']}() = request(\"{$operation['method']}\", \"{$operation['path']}\")", + static fn (array $operation): string => sprintf( + ' suspend fun %s(%s) = request("%s", "%s")', + $operation['id'], + implode(', ', array_map(static fn (string $parameter): string => "{$parameter}: String", $operation['parameters'])), + $operation['method'], + self::kotlinPath($operation['path'], $operation['parameters']), + ), $operations, ); return "class PamApiClient(private val request: suspend (String, String) -> Any?) {\n" . implode("\n", $methods) . "\n}\n"; } - /** @param list $operations */ + /** @param list}> $operations */ private function swift(array $operations): string { $methods = array_map( - static fn (array $operation): string => " func {$operation['id']}() async throws -> Any { try await request(\"{$operation['method']}\", \"{$operation['path']}\") }", + static fn (array $operation): string => sprintf( + ' func %s(%s) async throws -> Any { try await request("%s", "%s") }', + $operation['id'], + implode(', ', array_map(static fn (string $parameter): string => "{$parameter}: String", $operation['parameters'])), + $operation['method'], + self::swiftPath($operation['path'], $operation['parameters']), + ), $operations, ); return "struct PamApiClient {\n let request: (String, String) async throws -> Any\n" @@ -81,5 +106,31 @@ private static function identifier(string $value): string $identifier = preg_replace('/[^A-Za-z0-9_]/', '_', $value) ?? 'operation'; return preg_match('/^[A-Za-z_]/', $identifier) === 1 ? $identifier : '_' . $identifier; } -} + /** @param list $parameters */ + private static function typescriptPath(string $path, array $parameters): string + { + foreach ($parameters as $parameter) { + $path = str_replace("{{$parameter}}", "\${encodeURIComponent(String({$parameter}))}", $path); + } + return $path; + } + + /** @param list $parameters */ + private static function kotlinPath(string $path, array $parameters): string + { + foreach ($parameters as $parameter) { + $path = str_replace("{{$parameter}}", "\${java.net.URLEncoder.encode({$parameter}, Charsets.UTF_8)}", $path); + } + return $path; + } + + /** @param list $parameters */ + private static function swiftPath(string $path, array $parameters): string + { + foreach ($parameters as $parameter) { + $path = str_replace("{{$parameter}}", "\\({$parameter}.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? {$parameter})", $path); + } + return $path; + } +} diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php index 40e6c79..1078d25 100644 --- a/src/OpenApi/OpenApiGenerator.php +++ b/src/OpenApi/OpenApiGenerator.php @@ -20,15 +20,29 @@ public function __construct( public function generate(): array { $paths = []; + $schemas = []; foreach ($this->router->routes() as $route) { $paths[$route->path][strtolower($route->method)] = $this->operation($route); + foreach ([$route->input, $route->output] as $class) { + if ($class !== null) { + $schemas[self::shortName($class)] = [ + 'type' => 'object', + 'x-pam-class' => $class, + ]; + } + } } ksort($paths, SORT_STRING); - return [ + $document = [ 'openapi' => '3.1.0', 'info' => ['title' => $this->title, 'version' => $this->version], 'paths' => $paths, ]; + if ($schemas !== []) { + ksort($schemas, SORT_STRING); + $document['components'] = ['schemas' => $schemas]; + } + return $document; } public function toJson(): string diff --git a/tests/RouteBindingAndOpenApiTest.php b/tests/RouteBindingAndOpenApiTest.php index 025482e..9d0c10e 100644 --- a/tests/RouteBindingAndOpenApiTest.php +++ b/tests/RouteBindingAndOpenApiTest.php @@ -45,6 +45,13 @@ public function testOpenApiUsesFluentRouteMetadata(): void self::assertSame('users.update', $operation['operationId']); self::assertSame(['Users'], $operation['tags']); self::assertSame('3.1.0', $document['openapi']); + $components = $document['components']; + self::assertIsArray($components); + $schemas = $components['schemas']; + self::assertIsArray($schemas); + $schema = $schemas['UpdateUserRequest']; + self::assertIsArray($schema); + self::assertSame(UpdateUserRequest::class, $schema['x-pam-class']); } } diff --git a/tests/TestingAndClientGenerationTest.php b/tests/TestingAndClientGenerationTest.php index f829af7..0dd7083 100644 --- a/tests/TestingAndClientGenerationTest.php +++ b/tests/TestingAndClientGenerationTest.php @@ -37,5 +37,12 @@ public function testOpenApiGeneratesThreeTypedClientSurfaces(): void self::assertStringContainsString('users_index', $openApi->client(ClientLanguage::TypeScript)); self::assertStringContainsString('users_index', $openApi->client(ClientLanguage::Kotlin)); self::assertStringContainsString('users_index', $openApi->client(ClientLanguage::Swift)); + + $app->get('/users/{id}', static fn (Request $request, Response $response): Response => $response->json([])) + ->name('users.show'); + self::assertStringContainsString( + 'encodeURIComponent(String(id))', + $app->openApi()->client(ClientLanguage::TypeScript), + ); } } From c0f80d4f7da61014ad18240ab4a356c962bda109 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Thu, 20 Aug 2026 19:53:23 -0300 Subject: [PATCH 08/10] test: cover production application primitives --- tests/ProductionPrimitivesTest.php | 158 +++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/ProductionPrimitivesTest.php diff --git a/tests/ProductionPrimitivesTest.php b/tests/ProductionPrimitivesTest.php new file mode 100644 index 0000000..0216b94 --- /dev/null +++ b/tests/ProductionPrimitivesTest.php @@ -0,0 +1,158 @@ +hydrate(CreateAccountData::class, [ + 'email' => 'dev@pam.dev', + 'type' => 2, + ]); + + self::assertInstanceOf(CreateAccountData::class, $dto); + self::assertSame(AccountType::Administrator, $dto->type); + } + + public function testEventsJobsRetryAndCircuitStatesAreDeterministic(): void + { + $events = new SyncEventDispatcher(new Container()); + $received = null; + $events->listen(AccountCreated::class, static function (AccountCreated $event) use (&$received): void { + $received = $event->id; + }); + $events->dispatch(new AccountCreated(10)); + self::assertSame(10, $received); + + $job = (new MemoryJobDispatcher())->dispatch(new SendWelcomeEmail(10)); + self::assertSame(JobState::Pending, $job->state); + + $attempts = 0; + $result = (new RetryPolicy(attempts: 3, initialDelayMilliseconds: 0))->run( + static function () use (&$attempts): string { + if (++$attempts < 3) { + throw new \RuntimeException('temporary'); + } + return 'ok'; + }, + ); + self::assertSame('ok', $result); + self::assertSame(3, $attempts); + + $circuit = new CircuitBreaker(failureThreshold: 1); + try { + $circuit->call(static fn (): never => throw new \RuntimeException('down')); + } catch (\RuntimeException) { + } + self::assertSame(CircuitState::Open, $circuit->state()); + } + + public function testHealthRegistryReturnsTheWorstIntegerState(): void + { + $registry = new HealthRegistry(); + $registry->add('database', new class implements HealthCheck { + public function check(): HealthResult + { + return new HealthResult(HealthState::Degraded, ['latencyMs' => 150]); + } + }); + + self::assertSame(HealthState::Degraded->value, $registry->run()['state']); + } + + public function testCorsPreflightValidatesRequestedMethod(): void + { + $app = new App(discoverPackages: false); + $app->middleware(new CorsMiddleware(['https://app.example.com'], methods: ['GET'])); + $app->route('OPTIONS', '/resource', static fn (Request $request, Response $response): Response => $response->send(null)); + + $denied = $app->handle($this->preflight('DELETE'), new Response())->export(); + $allowed = $app->handle($this->preflight('GET'), new Response())->export(); + + self::assertSame(403, $denied['status']); + self::assertSame(204, $allowed['status']); + } + + public function testObservationsUseNormalizedRouteTemplates(): void + { + $observer = new CollectingObserver(); + $app = new App(discoverPackages: false); + $app->middleware(new ObserveRequestMiddleware($observer, $app->container())); + $app->get('/users/{id}', static fn (Request $request, Response $response): Response => $response->send('ok')); + + $app->handle(new Request('GET', '/users/42', [], [], ''), new Response()); + + self::assertNotNull($observer->last); + self::assertSame('/users/{id}', $observer->last->route); + self::assertSame(200, $observer->last->status); + } + + private function preflight(string $method): Request + { + return new Request('OPTIONS', '/resource', [], [ + 'origin' => ['https://app.example.com'], + 'access-control-request-method' => [$method], + ], ''); + } +} + +enum AccountType: int +{ + case Regular = 1; + case Administrator = 2; +} + +final readonly class CreateAccountData +{ + public function __construct(public string $email, public AccountType $type) + { + } +} + +final readonly class AccountCreated +{ + public function __construct(public int $id) + { + } +} + +final readonly class SendWelcomeEmail +{ + public function __construct(public int $accountId) + { + } +} + +final class CollectingObserver implements RequestObserver +{ + public ?RequestObservation $last = null; + + public function record(RequestObservation $observation): void + { + $this->last = $observation; + } +} From a792f3181c9180b2711015201a4dd20c1b901250 Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Thu, 20 Aug 2026 20:01:48 -0300 Subject: [PATCH 09/10] fix: isolate request scopes per fiber --- src/Container/Container.php | 76 +++++++++++++++++++++++-------- tests/Container/ContainerTest.php | 27 ++++++++++- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/Container/Container.php b/src/Container/Container.php index d69107b..a81054b 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -14,14 +14,20 @@ final class Container /** @var array */ private array $singletons = []; - /** @var array */ - private array $scoped = []; + /** @var \WeakMap> */ + private \WeakMap $fiberScopes; - private bool $scopeActive = false; + /** @var array|null */ + private ?array $mainScope = null; /** @var array */ private array $routeBindings = []; + public function __construct() + { + $this->fiberScopes = new \WeakMap(); + } + /** @param class-string $class @param callable(string, Container): object $resolver */ public function bindRoute(string $class, callable $resolver): self { @@ -55,24 +61,27 @@ public function instance(string $id, mixed $instance): self public function scopedInstance(string $id, mixed $instance): self { - if (!$this->scopeActive) { + $scope = $this->currentScope(); + if ($scope === null) { throw new \LogicException("Scoped entry {$id} cannot be registered outside a request scope."); } - $this->scoped[$id] = $instance; + $scope[$id] = $instance; + $this->replaceCurrentScope($scope); return $this; } public function scopedValue(string $id): mixed { - return $this->scoped[$id] ?? null; + return $this->currentScope()[$id] ?? null; } /** @return array{state: int, scopedEntries: int, singletonEntries: int, bindings: int} */ public function diagnostics(): array { + $scope = $this->currentScope(); return [ - 'state' => ($this->scopeActive ? ContainerState::RequestActive : ContainerState::Idle)->value, - 'scopedEntries' => count($this->scoped), + 'state' => ($scope !== null ? ContainerState::RequestActive : ContainerState::Idle)->value, + 'scopedEntries' => count($scope ?? []), 'singletonEntries' => count($this->singletons), 'bindings' => count($this->bindings), ]; @@ -80,17 +89,20 @@ public function diagnostics(): array public function beginScope(): void { - if ($this->scopeActive) { + if ($this->currentScope() !== null) { throw new \LogicException('A PAM API container scope is already active.'); } - $this->scoped = []; - $this->scopeActive = true; + $this->replaceCurrentScope([]); } public function endScope(): void { - $this->scoped = []; - $this->scopeActive = false; + $fiber = \Fiber::getCurrent(); + if ($fiber === null) { + $this->mainScope = null; + return; + } + unset($this->fiberScopes[$fiber]); } public function get(string $id): mixed @@ -98,8 +110,9 @@ public function get(string $id): mixed if (array_key_exists($id, $this->singletons)) { return $this->singletons[$id]; } - if (array_key_exists($id, $this->scoped)) { - return $this->scoped[$id]; + $scope = $this->currentScope(); + if ($scope !== null && array_key_exists($id, $scope)) { + return $scope[$id]; } $binding = $this->bindings[$id] ?? null; @@ -114,10 +127,11 @@ public function get(string $id): mixed if ($binding->lifetime === BindingLifetime::Singleton) { $this->singletons[$id] = $value; } elseif ($binding->lifetime === BindingLifetime::Scoped) { - if (!$this->scopeActive) { + if ($scope === null) { throw new \LogicException("Scoped entry {$id} was resolved outside a request scope."); } - $this->scoped[$id] = $value; + $scope[$id] = $value; + $this->replaceCurrentScope($scope); } return $value; } @@ -227,10 +241,36 @@ private function register( default => $factory, }; $this->bindings[$id] = new Binding($resolver, $lifetime); - unset($this->singletons[$id], $this->scoped[$id]); + unset($this->singletons[$id]); + $scope = $this->currentScope(); + if ($scope !== null) { + unset($scope[$id]); + $this->replaceCurrentScope($scope); + } return $this; } + /** @return array|null */ + private function currentScope(): ?array + { + $fiber = \Fiber::getCurrent(); + if ($fiber === null) { + return $this->mainScope; + } + return $this->fiberScopes[$fiber] ?? null; + } + + /** @param array $scope */ + private function replaceCurrentScope(array $scope): void + { + $fiber = \Fiber::getCurrent(); + if ($fiber === null) { + $this->mainScope = $scope; + return; + } + $this->fiberScopes[$fiber] = $scope; + } + private static function routeValue(mixed $value): string { if (is_string($value)) { diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index 61de415..91c04a2 100644 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -41,6 +41,32 @@ public function testScopedBindingsCannotResolveOutsideARequest(): void $this->expectException(\LogicException::class); $container->get(ScopedValue::class); } + + public function testConcurrentFibersKeepIndependentRequestScopes(): void + { + $container = new Container(); + $container->scoped(ScopedValue::class); + + $request = static function () use ($container): void { + $container->beginScope(); + $value = $container->get(ScopedValue::class); + \Fiber::suspend($value); + self::assertSame($value, $container->get(ScopedValue::class)); + $container->endScope(); + }; + + $first = new \Fiber($request); + $second = new \Fiber($request); + $firstValue = $first->start(); + $secondValue = $second->start(); + + self::assertInstanceOf(ScopedValue::class, $firstValue); + self::assertInstanceOf(ScopedValue::class, $secondValue); + self::assertNotSame($firstValue, $secondValue); + + $first->resume(); + $second->resume(); + } } final class Dependency @@ -57,4 +83,3 @@ public function __construct(public Dependency $dependency) final class ScopedValue { } - From 059f640c6883b6020ee9ca668b38756c938e829d Mon Sep 17 00:00:00 2001 From: davidbalbino Date: Fri, 21 Aug 2026 20:35:50 -0300 Subject: [PATCH 10/10] refactor: rename PAM HTTP package --- README.md | 10 +++++----- composer.json | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6b82f2d..c97a56d 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ Express-like routing. Laravel-like application structure. PAM-native execution. -**[Official documentation](https://push-in.github.io/pam-docs/packages/api/) · +**[Official documentation](https://push-in.github.io/pam-docs/packages/http/) · [PAM introduction](https://push-in.github.io/pam-docs/introduction/) · -[Report an issue](https://github.com/push-in/pam-api/issues)** +[Report an issue](https://github.com/push-in/pam-http/issues)** ```bash -pam composer require pushinbr/pam-api +pam composer require pushinbr/pam-http ``` ```php @@ -174,7 +174,7 @@ modify, and distribute this package for any purpose, including commercially. ## Recommended PAM workflow -Start new applications with `pam init my-api --template api`. In an existing PAM project, install the higher-level router with `pam composer require pushinbr/pam-api`; PAM runs Composer inside its private Embed SAPI. +Start new applications with `pam init my-api --template api`. In an existing PAM project, install the higher-level router with `pam composer require pushinbr/pam-http`; PAM runs Composer inside its private Embed SAPI. Run `pam doctor` after dependency changes and before creating a release. The project remains a normal Composer project with a standard manifest, lockfile, PSR-4 autoloading, and `vendor/autoload.php`. @@ -210,6 +210,6 @@ Route parameters are available through `$request->route()`. A path that exists f - [PAM introduction](https://push-in.github.io/pam-docs/introduction/) - [Package ecosystem](https://push-in.github.io/pam-docs/packages/overview/) - [Runtime compatibility](https://push-in.github.io/pam-docs/runtime/compatibility/) -- [Report an issue](https://github.com/push-in/pam-api/issues) +- [Report an issue](https://github.com/push-in/pam-http/issues) Report security vulnerabilities through GitHub private vulnerability reporting or the PAM security policy, not a public issue. diff --git a/composer.json b/composer.json index faddf57..993152d 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "pushinbr/pam-api", + "name": "pushinbr/pam-http", "description": "Elegant HTTP routing and middleware for the Pam runtime.", "type": "library", "license": "Apache-2.0", @@ -16,16 +16,16 @@ ], "homepage": "https://github.com/push-in/pam", "support": { - "docs": "https://push-in.github.io/pam-docs/packages/api/", + "docs": "https://push-in.github.io/pam-docs/packages/http/", "issues": "https://github.com/push-in/pam/issues", - "source": "https://github.com/push-in/pam-api" + "source": "https://github.com/push-in/pam-http" }, "require": { "php": "^8.4", - "pushinbr/pam-core-api": "^1.0" + "pushinbr/pam-contracts": "^1.0" }, "suggest": { - "pushinbr/pam-psr-bridge": "PSR-7, PSR-15 and PSR-17 interoperability.", + "pushinbr/pam-psr": "PSR-7, PSR-15 and PSR-17 interoperability.", "pushinbr/pam-socket": "High-level event and room APIs for Pam WebSockets." }, "autoload": {