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
49 changes: 39 additions & 10 deletions benchmarks/benchmarks/Http/HttpKernelBench.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,66 @@
namespace Evolve\Benchmarks\PhpBench\Http;

use Evolve\Benchmarks\Support\BenchmarkFixtureFactory;
use Evolve\Http\HttpKernel;
use PhpBench\Attributes as Bench;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;

#[Bench\Revs(50)]
#[Bench\Iterations(10)]
#[Bench\Warmup(2)]
final class HttpKernelBench
{
private HttpKernel $kernel;

private ServerRequestInterface $request;

private string $scenario = 'static';

#[Bench\BeforeMethods(['setUpKernelScenario'])]
#[Bench\Groups(['http', 'kernel'])]
#[Bench\ParamProviders(['kernelScenarios'])]
public function benchHttpKernelScenario(array $params): void
{
$this->handlePreparedKernelRequest();
}

#[Bench\BeforeMethods(['setUpWarmStaticScenario'])]
#[Bench\Groups(['http', 'kernel', 'warm'])]
public function benchRepeatedWarmStaticRequestsThroughSameKernel(): void
{
// One prepared handle() call per revolution preserves the intended warm-kernel benchmark
// semantics without re-creating the fixture inside the timed subject.
$this->handlePreparedKernelRequest();
}

public function setUpKernelScenario(array $params): void
{
$fixture = BenchmarkFixtureFactory::httpKernelFixture($params['scenario']);
$this->scenario = $params['scenario'];
$this->kernel = $fixture['kernel'];
$this->request = $fixture['request'];
}

public function setUpWarmStaticScenario(): void
{
$fixture = BenchmarkFixtureFactory::httpKernelFixture('static');
$this->scenario = 'static';
$this->kernel = $fixture['kernel'];
$this->request = $fixture['request'];
}

public function handlePreparedKernelRequest(): void
{
try {
$fixture['kernel']->handle($fixture['request']);
$this->kernel->handle($this->request);
} catch (Throwable $exception) {
if (!in_array($params['scenario'], ['not-found', 'method-mismatch'], true)) {
if (!in_array($this->scenario, ['not-found', 'method-mismatch'], true)) {
throw $exception;
}
}
}

#[Bench\Groups(['http', 'kernel', 'warm'])]
public function benchRepeatedWarmStaticRequestsThroughSameKernel(): void
{
$fixture = BenchmarkFixtureFactory::httpKernelFixture('static');
$fixture['kernel']->handle($fixture['request']);
$fixture['kernel']->handle($fixture['request']);
}

/**
* @return array<string, array{scenario: string}>
*/
Expand Down
36 changes: 36 additions & 0 deletions benchmarks/tests/FixtureCorrectnessTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,34 @@ public function testContainerResolutionBenchmarkPreparesCachedResolutionBeforeMe
self::assertSame('bench.application.9', $bench->applicationServiceId());
}

public function testHttpKernelBenchmarkPreparesKernelThroughBeforeMethods(): void
{
$bench = new \Evolve\Benchmarks\PhpBench\Http\HttpKernelBench();
$scenarioMethod = new \ReflectionMethod(\Evolve\Benchmarks\PhpBench\Http\HttpKernelBench::class, 'benchHttpKernelScenario');
$setupMethod = new \ReflectionMethod(\Evolve\Benchmarks\PhpBench\Http\HttpKernelBench::class, 'setUpKernelScenario');

self::assertNotEmpty($scenarioMethod->getAttributes('PhpBench\\Attributes\\BeforeMethods'));
self::assertSame(['setUpKernelScenario'], $scenarioMethod->getAttributes('PhpBench\\Attributes\\BeforeMethods')[0]->newInstance()->methods);
self::assertNotEmpty($setupMethod);

$bench->setUpKernelScenario(['scenario' => 'static']);
$bench->handlePreparedKernelRequest();
self::assertSame('static', $this->readScenario($bench));
}

public function testWarmStaticBenchmarkUsesPreparedKernelAndWarmupExecution(): void
{
$bench = new \Evolve\Benchmarks\PhpBench\Http\HttpKernelBench();
$warmMethod = new \ReflectionMethod(\Evolve\Benchmarks\PhpBench\Http\HttpKernelBench::class, 'benchRepeatedWarmStaticRequestsThroughSameKernel');

self::assertNotEmpty($warmMethod->getAttributes('PhpBench\\Attributes\\BeforeMethods'));
self::assertSame(['setUpWarmStaticScenario'], $warmMethod->getAttributes('PhpBench\\Attributes\\BeforeMethods')[0]->newInstance()->methods);

$bench->setUpWarmStaticScenario();
$bench->handlePreparedKernelRequest();
self::assertSame('static', $this->readScenario($bench));
}

public function testFirstResolutionBenchmarkMethodsDeclareSingleRevAndZeroWarmup(): void
{
$appMethod = new \ReflectionMethod(\Evolve\Benchmarks\PhpBench\Core\ContainerResolutionBench::class, 'benchApplicationFirstResolution');
Expand All @@ -135,6 +163,14 @@ public function testFirstResolutionBenchmarkMethodsDeclareSingleRevAndZeroWarmup
self::assertSame([0], $this->warmupFor($executionMethod));
}

private function readScenario(object $bench): string
{
$reflection = new \ReflectionProperty($bench, 'scenario');
$reflection->setAccessible(true);

return $reflection->getValue($bench);
}

private function revsFor(\ReflectionMethod $method): array
{
$attributes = $method->getAttributes('PhpBench\\Attributes\\Revs');
Expand Down
40 changes: 40 additions & 0 deletions packages/http/src/Routing/Internal/RoutePattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/
private array $segments;

private bool $isStatic;

/**
* @param list<array{kind: 'static'|'parameter', value: string}> $segments
*/
Expand All @@ -24,6 +26,21 @@ private function __construct(
array $segments,
) {
$this->segments = $segments;
$this->isStatic = !$this->hasParameters($segments);
}

/**
* @param list<array{kind: 'static'|'parameter', value: string}> $segments
*/
private static function hasParameters(array $segments): bool
{
foreach ($segments as $segment) {
if ($segment['kind'] === 'parameter') {
return true;
}
}

return false;
}

public static function fromPath(string $path): self
Expand Down Expand Up @@ -101,10 +118,25 @@ public function match(string $path): ?array
return null;
}

if ($this->isStatic) {
return $path === $this->path ? [] : null;
}

$candidateSegments = $path === '/'
? []
: explode('/', substr($path, 1));

return $this->matchSegments($candidateSegments);
}

/**
* @param list<string> $candidateSegments
* @return array<string, string>|null
*
* @internal
*/
public function matchSegments(array $candidateSegments): ?array
{
if (count($candidateSegments) !== count($this->segments)) {
return null;
}
Expand All @@ -131,4 +163,12 @@ public function match(string $path): ?array

return $parameters;
}

/**
* @internal
*/
public function isStatic(): bool
{
return $this->isStatic;
}
}
49 changes: 47 additions & 2 deletions packages/http/src/Routing/RouteMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,22 @@ public function match(ServerRequestInterface $request): ?RouteMatch
$method = $request->getMethod();
$path = $request->getUri()->getPath();

$candidateSegments = null;
$segmentsParsed = false;

foreach ($this->compiledRoutes as $entry) {
$parameters = $entry['pattern']->match($path);
$pattern = $entry['pattern'];

if ($pattern->isStatic()) {
$parameters = $pattern->match($path);
} else {
if (!$segmentsParsed) {
$candidateSegments = $this->parseCandidate($path);
$segmentsParsed = true;
}

$parameters = $pattern->matchSegments($candidateSegments);
}

if ($parameters === null) {
continue;
Expand All @@ -50,16 +64,47 @@ public function match(ServerRequestInterface $request): ?RouteMatch
return null;
}

/**
* @return list<string>
*/
private function parseCandidate(string $path): array
{
if ($path === '') {
return [];
}

if ($path[0] !== '/') {
return [];
}

return $path === '/' ? [] : explode('/', substr($path, 1));
}

/**
* @return list<string>
*/
public function allowedMethods(string $path): array
{
$methods = [];
$seen = [];
$candidateSegments = null;
$segmentsParsed = false;

foreach ($this->compiledRoutes as $entry) {
if ($entry['pattern']->match($path) === null) {
$pattern = $entry['pattern'];

if ($pattern->isStatic()) {
$matches = $pattern->match($path) !== null;
} else {
if (!$segmentsParsed) {
$candidateSegments = $this->parseCandidate($path);
$segmentsParsed = true;
}

$matches = $pattern->matchSegments($candidateSegments) !== null;
}

if (!$matches) {
continue;
}

Expand Down
69 changes: 69 additions & 0 deletions packages/http/tests/Unit/Routing/RoutingFoundationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,75 @@ public function test_parameters_from_one_request_do_not_leak_into_another(): voi
self::assertSame(['id' => '99'], $matcher->match($this->request('GET', '/users/99'))?->parameters());
}

public function test_large_static_route_table_matches_correctly(): void
{
$routes = [];
for ($i = 0; $i < 100; $i++) {
$routes[] = $this->route(['GET'], "/static/path/{$i}");
}
$targetRoute = $this->route(['GET'], '/static/path/50');
$routes[50] = $targetRoute;

$matcher = $this->matcher($routes);

self::assertSame($targetRoute, $matcher->match($this->request('GET', '/static/path/50'))?->route());
}

public function test_mixed_static_and_parameterized_large_table_maintains_order(): void
{
$routes = [];
for ($i = 0; $i < 50; $i++) {
$routes[] = $this->route(['GET'], "/static/item/{$i}");
$routes[] = $this->route(['POST'], '/param/item/{id}');
}

$matcher = $this->matcher($routes);

$match = $matcher->match($this->request('GET', '/static/item/0'));
self::assertNotNull($match);
self::assertSame('/static/item/0', $match->route()->path());

$match = $matcher->match($this->request('POST', '/param/item/xyz'));
self::assertNotNull($match);
self::assertSame(['id' => 'xyz'], $match->parameters());
}

public function test_allowed_methods_with_mixed_static_and_parameterized_routes(): void
{
$routes = [
$this->route(['GET'], '/users/{id}'),
$this->route(['POST'], '/users/{id}/action'),
$this->route(['PUT'], '/users/{id}'),
$this->route(['PATCH'], '/users/42'),
];

$matcher = $this->matcher($routes);

self::assertSame(['GET', 'PUT', 'PATCH'], $matcher->allowedMethods('/users/42'));
self::assertSame(['GET', 'PUT'], $matcher->allowedMethods('/users/99'));
}

public function test_repeated_matching_with_same_matcher_instance(): void
{
$routes = [
$this->route(['GET'], '/api/users/{id}'),
$this->route(['POST'], '/api/data/{type}'),
];

$matcher = $this->matcher($routes);

$match1 = $matcher->match($this->request('GET', '/api/users/alice'));
self::assertSame(['id' => 'alice'], $match1?->parameters());

$match2 = $matcher->match($this->request('POST', '/api/data/reports'));
self::assertSame(['type' => 'reports'], $match2?->parameters());

$match3 = $matcher->match($this->request('GET', '/api/users/bob'));
self::assertSame(['id' => 'bob'], $match3?->parameters());

self::assertNotSame($match1->parameters(), $match3->parameters());
}

/**
* @param iterable<Route> $routes
*/
Expand Down