From 2845d2590a4b5b7e550635d74a433dd78fc1a2c6 Mon Sep 17 00:00:00 2001 From: Caio Fernandes Date: Thu, 8 Oct 2026 23:02:52 -0300 Subject: [PATCH 1/2] feat(http): support {id} route parameters in Request::parsePath() - Request::parsePath() now extracts :id and {id} (and {id}) named parameters, using the constraint for the match pattern. - Update routing syntax docs. Refs: SPEC-002 (PivotPHP/pivotphp-core-routing#2) --- docs/technical/routing/SYNTAX_GUIDE.md | 7 ++- src/Http/Request.php | 29 +++++++++--- tests/Http/RequestTest.php | 29 ++++++++++++ .../Routing/BraceParameterIntegrationTest.php | 47 +++++++++++++++++++ 4 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 tests/Integration/Routing/BraceParameterIntegrationTest.php diff --git a/docs/technical/routing/SYNTAX_GUIDE.md b/docs/technical/routing/SYNTAX_GUIDE.md index 69a9698..46afaef 100644 --- a/docs/technical/routing/SYNTAX_GUIDE.md +++ b/docs/technical/routing/SYNTAX_GUIDE.md @@ -20,12 +20,17 @@ $app->get('/users', function($req, $res) { return $res->json(['users' => []]); }); -// Rota com parâmetros +// Rota com parâmetros — sintaxes `:id` e `{id}` são equivalentes $app->get('/users/:id', function($req, $res) { $id = $req->param('id'); return $res->json(['user_id' => $id]); }); +$app->get('/books/{isbn}', function($req, $res) { + $isbn = $req->param('isbn'); + return $res->json(['isbn' => $isbn]); +}); + // Rota POST com dados $app->post('/users', function($req, $res) { $data = $req->input(); diff --git a/src/Http/Request.php b/src/Http/Request.php index 1a21f1a..d4f69dd 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -844,24 +844,39 @@ private function parseRoute(): void */ private function parsePath(): void { - $pattern = preg_replace('/\/:([^\/]+)/', '/([^/]+)', $this->path); - $pattern = rtrim($pattern ?: '', '/'); + $paramNames = []; + + $pattern = preg_replace_callback( + '/[{:]([a-zA-Z_][a-zA-Z0-9_]*)(?:<([^>]+)>)?\}?/', + function (array $matches) use (&$paramNames): string { + $paramNames[] = $matches[1]; + $constraint = isset($matches[2]) && $matches[2] !== '' ? $matches[2] : '[^/]+'; + + return '(' . $constraint . ')'; + }, + $this->path + ); + + if ($pattern === null) { + $pattern = $this->path; + } + + $pattern = rtrim($pattern, '/'); $pattern = '#^' . $pattern . '/?$#'; + $matchResult = preg_match($pattern, rtrim($this->pathCallable ?: '', '/'), $values); if ($matchResult && !empty($values)) { array_shift($values); } else { $values = []; } - preg_match_all('/\/:([^\/]+)/', $this->path, $params); - $params = $params[1]; - if (count($params) > count($values)) { + if (count($paramNames) > count($values)) { throw new InvalidArgumentException('Number of parameters does not match the number of values'); } - if (!empty($params)) { - $paramsArray = array_combine($params, array_slice($values, 0, count($params))); + if (!empty($paramNames)) { + $paramsArray = array_combine($paramNames, array_slice($values, 0, count($paramNames))); if ($paramsArray !== false) { foreach ($paramsArray as $key => $value) { if (is_numeric($value)) { diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index 2e40a1f..f35a310 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -225,4 +225,33 @@ public function testRequestWithArrayParameters(): void unset($_SERVER['QUERY_STRING']); } + + public function testBraceParameterExtraction(): void + { + $request = new Request('GET', '/users/{id}', '/users/123'); + + $this->assertSame(123, $request->param('id')); + } + + public function testBraceParameterWithConstraint(): void + { + $request = new Request('GET', '/api/items/{id<\d+>}', '/api/items/42'); + + $this->assertSame(42, $request->param('id')); + } + + public function testMixedBraceAndColonParameters(): void + { + $request = new Request('GET', '/users/{userId}/posts/:postId', '/users/456/posts/789'); + + $this->assertSame(456, $request->param('userId')); + $this->assertSame(789, $request->param('postId')); + } + + public function testColonParameterStillExtracts(): void + { + $request = new Request('GET', '/users/:id', '/users/123'); + + $this->assertSame(123, $request->param('id')); + } } diff --git a/tests/Integration/Routing/BraceParameterIntegrationTest.php b/tests/Integration/Routing/BraceParameterIntegrationTest.php new file mode 100644 index 0000000..ea4e025 --- /dev/null +++ b/tests/Integration/Routing/BraceParameterIntegrationTest.php @@ -0,0 +1,47 @@ +param(). + */ +class BraceParameterIntegrationTest extends TestCase +{ + protected function setUp(): void + { + Router::clear(); + } + + protected function tearDown(): void + { + Router::clear(); + } + + public function testBraceParameterEndToEnd(): void + { + $app = new Application(__DIR__ . '/../../..'); + + $app->get( + '/users/{id}', + function ($req, $res) { + return $res->json(['id' => $req->param('id')]); + } + ); + + // Request construído com o path real (como Request::createFromGlobals()). + $response = $app->handle(new Request('GET', '/users/123', '/users/123')); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode($response->getBodyAsString(), true); + $this->assertSame(123, $body['id']); + } +} From 9a165c13004db64504c9c98b7957bc506589b24e Mon Sep 17 00:00:00 2001 From: Caio Fernandes Date: Thu, 8 Oct 2026 23:02:52 -0300 Subject: [PATCH 2/2] docs(releases): add FRAMEWORK_OVERVIEW_v2.1.1.md Fixes documentation validation that requires a framework overview for the current version (2.1.1). --- docs/releases/FRAMEWORK_OVERVIEW_v2.1.1.md | 101 +++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/releases/FRAMEWORK_OVERVIEW_v2.1.1.md diff --git a/docs/releases/FRAMEWORK_OVERVIEW_v2.1.1.md b/docs/releases/FRAMEWORK_OVERVIEW_v2.1.1.md new file mode 100644 index 0000000..3931b18 --- /dev/null +++ b/docs/releases/FRAMEWORK_OVERVIEW_v2.1.1.md @@ -0,0 +1,101 @@ +# PivotPHP v2.1.1 - Framework Overview + +**Version:** 2.1.1 (PSR-7 2.0 Compatibility Fix) +**Release Date:** 2026-07-15 +**PHP Requirements:** 8.1+ + +--- + +## 🎯 Executive Summary + +PivotPHP v2.1.1 is a **patch release** that fixes a real incompatibility with +`psr/http-message` 2.0, which `composer.json` had been declaring support for +(`"psr/http-message": "^1.1|^2.0"`) since v2.1.0 without the implementation +actually honoring it. PSR-7 2.0 adds strict return type declarations to the +interface methods, so whenever Composer resolved `psr/http-message` to `^2.0`, +every request hit a fatal `Declaration must be compatible` error. + +### Release Highlights + +- ✅ **Real PSR-7 2.0 support** — `^1.1` and `^2.0` both work again. +- ✅ **~46 method signatures retyped** across 8 PSR-7 classes. +- ✅ **Pooled-object data leaks fixed** (concurrent/async runtimes: Swoole, ReactPHP, FrankenPHP). +- ✅ **Single response-emission point** (`Application::run()`). +- ✅ **No public-API change** for documented usage — pure compatibility restoration. +- ✅ **1114 tests passing** (1083 CI + 31 integration) at PHPStan level 9. + +--- + +## 🏗️ What Changed + +### 1. PSR-7 2.0 Return-Type Compatibility + +The following files had their method signatures retyped against the real PSR-7 2.0 +interface sources (`php-fig/http-message`, tag `2.0`): + +- `Http/Psr7/Message.php` +- `Http/Psr7/Request.php` +- `Http/Psr7/Response.php` +- `Http/Psr7/ServerRequest.php` +- `Http/Psr7/Stream.php` +- `Http/Psr7/Uri.php` +- `Http/Psr7/UploadedFile.php` +- `Http/Response.php` (the Express.js/PSR-7 hybrid class) + +### 2. Response Emission & Pool Safety (carried from v2.1.0) + +- `json()`/`text()`/`html()` no longer auto-emit; `Application::run()` is the single, + guaranteed emission point (guarded by `isSent()`). +- `Psr7Pool::resetServerRequest()` now applies `$serverParams` (no more leaking + `REMOTE_ADDR`/`HTTPS`/auth data between pooled requests). +- `Psr7Pool::resetStream()` no longer reuses streams without `truncate()`. +- `CustomHeaderCollection` no longer discards real headers fetched from + `getallheaders()`. + +### 3. Deprecation Cycle (removal planned for v3.0.0) + +- `Core\Container` → `Providers\Container` +- `Middleware\LoadShedder` / `Middleware\Performance\RateLimitMiddleware` → `Middleware\RateLimiter` +- `Request::getIp()` → `Request::ip()` +- `Providers\Logger` → `Logging\PsrLogger` +- `Providers\EventDispatcher` → `Events\EventDispatcher` + +--- + +## 📊 Technical Metrics + +| Metric | v2.1.1 | +|--------|--------| +| **PSR-7** | `psr/http-message` `^1.1\|^2.0` (dual support) | +| **PSR-15** | Compliant | +| **PSR-3** | `Logging\PsrLogger` | +| **PSR-11** | `Providers\Container` | +| **PSR-14** | `Events\EventDispatcher` | +| **PHPStan** | Level 9 | +| **Coding style** | PSR-12 | +| **Tests** | 1114 (1083 CI + 31 integration) | + +### Performance + +PivotPHP v2.1.1 maintains the performance characteristics of the v2.x line. The +object-pooling and JSON-optimization systems continue to operate with the same +thresholds and reuse rates as v2.0.0 — no performance regression was introduced by +the return-type corrections (which are compile-time declarations, not runtime +hot-path changes). + +--- + +## 🔧 Compatibility + +- **Backward compatible** with all documented v2.x usage via deprecation aliases + through the v2.x line. Deprecated APIs are removed in v3.0.0. +- The only affected caller is undocumented direct use of `Response::getBody()` in + test mode expecting a raw string — use `getBodyAsString()` instead. + +--- + +## 📚 Related Documentation + +- `docs/technical/INCONSISTENCIES_REPORT.md` — full architectural review. +- `CHANGELOG.md` — detailed change log for v2.1.1 and earlier. +- `docs/releases/FRAMEWORK_OVERVIEW_v2.0.0.md` — previous major release overview.