Skip to content

Commit 8fff4db

Browse files
committed
feat: handle review feedback - basePath forwarding, type coercion, inspector hrefs, test improvements
1 parent 5b13cc6 commit 8fff4db

4 files changed

Lines changed: 188 additions & 12 deletions

File tree

resources/templates/inspector/routes.sugar.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22
/**
33
* @var array<\Glaze\Content\ContentPage> $pages
4+
* @var string $basePath Configured site base path (empty string when not set).
45
*/
56
?>
67
<!DOCTYPE html>
@@ -43,7 +44,7 @@
4344
<tbody>
4445
<tr s:foreach="$pages as $page">
4546
<td>
46-
<a href="<?= $page->urlPath ?>" class="glaze-link">
47+
<a href="<?= $basePath . $page->urlPath ?>" class="glaze-link">
4748
<?= $page->urlPath ?>
4849
</a>
4950
</td>

src/Http/Controller/InspectorController.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ public function routes(): array
5252
$this->config->contentTypes,
5353
);
5454

55-
return ['pages' => $pages];
55+
return [
56+
'pages' => $pages,
57+
'basePath' => $this->config->site->basePath ?? '',
58+
];
5659
}
5760
}

src/Http/Middleware/ControllerMiddleware.php

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,14 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
7474
{
7575
$this->discoverOnce();
7676

77-
$request = $request->withUri(
78-
$request->getUri()->withPath($this->stripBasePathFromRequestPath($request->getUri()->getPath())),
79-
);
77+
// Use a stripped copy for route matching so the original request (with any
78+
// basePath prefix intact) is forwarded unchanged when no route matches.
79+
// Downstream handlers such as DevPageRequestHandler rely on the original
80+
// path for canonical redirects and Location headers.
81+
$strippedPath = $this->stripBasePathFromRequestPath($request->getUri()->getPath());
82+
$routingRequest = $request->withUri($request->getUri()->withPath($strippedPath));
8083

81-
$match = $this->router->match($request);
84+
$match = $this->router->match($routingRequest);
8285
if (!$match instanceof MatchedRoute) {
8386
return $handler->handle($request);
8487
}
@@ -87,7 +90,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
8790
$controller = $this->container->get($match->controllerClass);
8891

8992
$reflectionMethod = new ReflectionMethod($controller, $match->actionMethod);
90-
$args = $this->resolveArguments($reflectionMethod, $request, $match->params);
93+
$args = $this->resolveArguments($reflectionMethod, $routingRequest, $match->params);
9194

9295
$result = $reflectionMethod->invokeArgs($controller, $args);
9396

@@ -165,9 +168,20 @@ protected function resolveArguments(
165168
continue;
166169
}
167170

168-
// 2. Path parameter by name.
171+
// 2. Path parameter by name — coerced to the declared builtin type when possible.
169172
if (array_key_exists($name, $params)) {
170-
$args[] = $params[$name];
173+
$rawValue = $params[$name];
174+
175+
if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
176+
$rawValue = $this->coercePathParam(
177+
$rawValue,
178+
$type->getName(),
179+
$name,
180+
$method,
181+
);
182+
}
183+
184+
$args[] = $rawValue;
171185
continue;
172186
}
173187

@@ -193,4 +207,56 @@ protected function resolveArguments(
193207

194208
return $args;
195209
}
210+
211+
/**
212+
* Coerce a raw path-parameter string to the declared PHP builtin type.
213+
*
214+
* Handles `int`, `float`, and `bool`; passes all other types through as-is.
215+
*
216+
* @param string $raw Raw string extracted from the URL path.
217+
* @param string $typeName PHP builtin type name (e.g. 'int', 'float', 'bool', 'string').
218+
* @param string $paramName Parameter name (used in error messages).
219+
* @param \ReflectionMethod $method Reflected action method (used in error messages).
220+
* @return string|float|int|bool Coerced value.
221+
* @throws \RuntimeException When the value cannot be converted to the required type.
222+
*/
223+
private function coercePathParam(
224+
string $raw,
225+
string $typeName,
226+
string $paramName,
227+
ReflectionMethod $method,
228+
): int|float|bool|string {
229+
return match ($typeName) {
230+
'int' => is_numeric($raw) && !str_contains($raw, '.')
231+
? (int)$raw
232+
: throw new RuntimeException(sprintf(
233+
'Path parameter "$%s" for "%s::%s" expects int, got "%s".',
234+
$paramName,
235+
$method->getDeclaringClass()->getName(),
236+
$method->getName(),
237+
$raw,
238+
)),
239+
'float' => is_numeric($raw)
240+
? (float)$raw
241+
: throw new RuntimeException(sprintf(
242+
'Path parameter "$%s" for "%s::%s" expects float, got "%s".',
243+
$paramName,
244+
$method->getDeclaringClass()->getName(),
245+
$method->getName(),
246+
$raw,
247+
)),
248+
'bool' => match (strtolower($raw)) {
249+
'1', 'true', 'yes', 'on' => true,
250+
'0', 'false', 'no', 'off', '' => false,
251+
default => throw new RuntimeException(sprintf(
252+
'Path parameter "$%s" for "%s::%s" expects bool, got "%s".',
253+
$paramName,
254+
$method->getDeclaringClass()->getName(),
255+
$method->getName(),
256+
$raw,
257+
)),
258+
},
259+
default => $raw,
260+
};
261+
}
196262
}

tests/Unit/Http/Middleware/ControllerMiddlewareTest.php

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Psr\Http\Message\ResponseInterface;
1717
use Psr\Http\Message\ServerRequestInterface;
1818
use Psr\Http\Server\RequestHandlerInterface;
19+
use ReflectionProperty;
1920

2021
/**
2122
* Tests for ControllerMiddleware routing and injection behavior.
@@ -174,8 +175,9 @@ public function check(ServerRequestInterface $request): ResponseInterface {
174175
/**
175176
* Ensure controller discovery only runs once even across multiple requests.
176177
*
177-
* A temp directory is created with one controller file. Both requests match
178-
* the same route, confirming discovery persisted after the first request.
178+
* After the first request triggers discovery, the route table is inspected
179+
* via reflection before and after a second request to confirm the entries
180+
* were not duplicated or cleared.
179181
*/
180182
public function testDiscoveryRunsOnlyOnce(): void
181183
{
@@ -213,9 +215,113 @@ public function index(): ResponseInterface {
213215
$response1 = $middleware->process($request, $this->fallbackHandler());
214216
$this->assertSame(200, $response1->getStatusCode());
215217

216-
// Second request — discovery must NOT clear and re-run (route still matched).
218+
// Capture route table size immediately after discovery.
219+
$routesProp = new ReflectionProperty($router, 'routes');
220+
/** @var array<mixed> $routesAfterFirst */
221+
$routesAfterFirst = $routesProp->getValue($router);
222+
$routeCountAfterFirstRequest = count($routesAfterFirst);
223+
$this->assertGreaterThan(0, $routeCountAfterFirstRequest, 'Routes must be registered after first request.');
224+
225+
// Second request — discovery must NOT re-run; the route table must be unchanged.
217226
$response2 = $middleware->process($request, $this->fallbackHandler());
218227
$this->assertSame(200, $response2->getStatusCode());
228+
/** @var array<mixed> $routesAfterSecond */
229+
$routesAfterSecond = $routesProp->getValue($router);
230+
$this->assertCount(
231+
$routeCountAfterFirstRequest,
232+
$routesAfterSecond,
233+
'Route count must not change after the second request; discoverOnce must not have re-run.',
234+
);
235+
}
236+
237+
/**
238+
* Ensure the original (basePath-prefixed) request is forwarded unchanged when no route matches.
239+
*
240+
* Downstream handlers such as DevPageRequestHandler rely on the full original
241+
* path (including any basePath prefix) for canonical redirects and Location headers.
242+
*/
243+
public function testProcessForwardsOriginalRequestPathOnMiss(): void
244+
{
245+
$projectRoot = $this->createProjectRoot();
246+
file_put_contents($projectRoot . '/glaze.neon', "site:\n basePath: /app\n");
247+
$config = BuildConfig::fromProjectRoot($projectRoot, true);
248+
249+
$router = new ControllerRouter();
250+
$middleware = new ControllerMiddleware(
251+
$router,
252+
$this->makeViewRenderer($config),
253+
$this->container(),
254+
$config,
255+
$this->createTempDirectory(),
256+
);
257+
258+
$capturedPath = null;
259+
$capturingHandler = new class ($capturedPath) implements RequestHandlerInterface {
260+
public function __construct(public ?string &$path)
261+
{
262+
}
263+
264+
/**
265+
* @inheritDoc
266+
*/
267+
public function handle(ServerRequestInterface $request): ResponseInterface
268+
{
269+
$this->path = $request->getUri()->getPath();
270+
271+
return (new Response(['charset' => 'UTF-8']))->withStatus(404)->withStringBody('miss');
272+
}
273+
};
274+
275+
$request = (new ServerRequestFactory())->createServerRequest('GET', '/app/about/');
276+
$middleware->process($request, $capturingHandler);
277+
278+
$this->assertSame('/app/about/', $capturedPath, 'The original basePath-prefixed path must be forwarded unchanged.');
279+
}
280+
281+
/**
282+
* Ensure path parameters are coerced to declared scalar types (int, float, bool).
283+
*/
284+
public function testProcessCoercesScalarPathParams(): void
285+
{
286+
$projectRoot = $this->createProjectRoot();
287+
$config = BuildConfig::fromProjectRoot($projectRoot, true);
288+
289+
$controllersDir = $projectRoot . '/controllers';
290+
mkdir($controllersDir, 0755, true);
291+
file_put_contents($controllersDir . '/TypedController.php', <<<'PHP'
292+
<?php
293+
declare(strict_types=1);
294+
use Cake\Http\Response;
295+
use Glaze\Http\Attribute\Route;
296+
use Psr\Http\Message\ResponseInterface;
297+
final class TypedController {
298+
#[Route('/items/{id}/{score}/{active}')]
299+
public function show(int $id, float $score, bool $active): ResponseInterface {
300+
return (new Response(['charset' => 'UTF-8']))
301+
->withStatus(200)
302+
->withStringBody(json_encode(['id' => $id, 'score' => $score, 'active' => $active]));
303+
}
304+
}
305+
PHP);
306+
307+
$router = new ControllerRouter();
308+
$middleware = new ControllerMiddleware(
309+
$router,
310+
$this->makeViewRenderer($config),
311+
$this->container(),
312+
$config,
313+
$controllersDir,
314+
);
315+
316+
$request = (new ServerRequestFactory())->createServerRequest('GET', '/items/42/3.14/true');
317+
$response = $middleware->process($request, $this->fallbackHandler());
318+
319+
$this->assertSame(200, $response->getStatusCode());
320+
$decoded = json_decode((string)$response->getBody(), true);
321+
$this->assertIsArray($decoded);
322+
$this->assertSame(42, $decoded['id']);
323+
$this->assertEqualsWithDelta(3.14, $decoded['score'], PHP_FLOAT_EPSILON);
324+
$this->assertTrue($decoded['active']);
219325
}
220326

221327
/**

0 commit comments

Comments
 (0)