Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions docs/releases/FRAMEWORK_OVERVIEW_v2.1.1.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion docs/technical/routing/SYNTAX_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
29 changes: 22 additions & 7 deletions src/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
29 changes: 29 additions & 0 deletions tests/Http/RequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
47 changes: 47 additions & 0 deletions tests/Integration/Routing/BraceParameterIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace PivotPHP\Core\Tests\Integration\Routing;

use PHPUnit\Framework\TestCase;
use PivotPHP\Core\Core\Application;
use PivotPHP\Core\Http\Request;
use PivotPHP\Routing\Router\Router;

/**
* Cobre o fluxo completo de rota com parâmetro `{id}` (SPEC-002):
* o router casa a rota e o Request extrai o parâmetro para $req->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']);
}
}
Loading