Skip to content

Commit e2446f6

Browse files
author
Ibrahim BinAlshikh
committed
feat: add dynamic namespace routing and services:list command
- ServiceRouter::dynamic() registers catch-all route for namespace - ServiceRouter::handle() resolves controller at request time, 404 if not found - RouteOption::NS constant for namespace-based routing - ServicesListCommand lists all discovered services with name, class, type, path Closes #383 Closes #385
1 parent 24f3fdd commit e2446f6

6 files changed

Lines changed: 183 additions & 0 deletions

File tree

WebFiori/Framework/App.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ public static function getRunner() : Runner {
375375
'\\WebFiori\\Framework\\Cli\\Commands\\FreshMigrationsCommand',
376376
'\\WebFiori\\Framework\\Cli\\Commands\\SkipMigrationsCommand',
377377
'\\WebFiori\\Framework\\Cli\\Commands\\StepMigrationsCommand',
378+
'\\WebFiori\\Framework\\Cli\\Commands\\ServicesListCommand',
378379
];
379380

380381
foreach ($commands as $c) {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/**
4+
* This file is licensed under MIT License.
5+
*
6+
* Copyright (c) 2026-present WebFiori Framework
7+
*
8+
* For more information on the license, please visit:
9+
* https://github.com/WebFiori/.github/blob/main/LICENSE
10+
*
11+
*/
12+
namespace WebFiori\Framework\Cli\Commands;
13+
14+
use WebFiori\Cli\Command;
15+
use WebFiori\Framework\Router\ServiceRouter;
16+
17+
/**
18+
* CLI command to list all discovered services.
19+
*
20+
* @author Ibrahim
21+
*/
22+
class ServicesListCommand extends Command {
23+
public function __construct() {
24+
parent::__construct('services:list', [], 'List all auto-discovered API services.');
25+
}
26+
27+
public function exec(): int {
28+
$discovered = ServiceRouter::getDiscovered();
29+
30+
if (empty($discovered)) {
31+
$this->info('No services discovered. Use ServiceRouter::discover() to register services.');
32+
33+
return 0;
34+
}
35+
36+
$this->println('');
37+
$this->println(sprintf(' %-15s %-45s %-10s %s', 'Name', 'Class', 'Type', 'Path'));
38+
$this->println(str_repeat('-', 90));
39+
40+
foreach ($discovered as $name => $entry) {
41+
$this->println(sprintf(
42+
' %-15s %-45s %-10s %s',
43+
$name,
44+
$entry['class'],
45+
$entry['type'],
46+
$entry['path']
47+
));
48+
}
49+
50+
$this->println('');
51+
$this->info('Total: ' . count($discovered) . ' service(s).');
52+
53+
return 0;
54+
}
55+
}

WebFiori/Framework/Router/RouteOption.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,8 @@ class RouteOption {
7272
* An option which is used to set an array of allowed values to route parameters.
7373
*/
7474
const VALUES = 'vars-values';
75+
/**
76+
* An option to specify a namespace for dynamic service resolution.
77+
*/
78+
const NS = 'namespace';
7579
}

WebFiori/Framework/Router/ServiceRouter.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,80 @@ public static function reset(): void {
9494
self::$discovered = [];
9595
}
9696

97+
/**
98+
* Register a dynamic namespace route that resolves services at request time.
99+
*
100+
* Usage:
101+
* ```php
102+
* ServiceRouter::dynamic('App\\Apis', '/apis/{controller}', [...options]);
103+
* ```
104+
*
105+
* @param string $namespace Namespace to search for services.
106+
* @param string $path Route path with {controller} parameter.
107+
* @param array $routeOptions Shared route options (middleware, etc.).
108+
* @param string|null $directory Optional directory override.
109+
*/
110+
public static function dynamic(string $namespace, string $path, array $routeOptions = [], ?string $directory = null): void {
111+
$options = array_merge($routeOptions, [
112+
RouteOption::PATH => $path,
113+
RouteOption::TO => function () use ($namespace, $directory) {
114+
$controllerName = App::getRequest()->getParam('controller');
115+
116+
if ($controllerName === null) {
117+
$controllerName = $_GET['controller'] ?? null;
118+
}
119+
120+
if ($controllerName === null) {
121+
App::getResponse()->setCode(404);
122+
App::getResponse()->write(json_encode([
123+
'message' => 'Controller parameter missing.',
124+
'type' => 'error'
125+
]));
126+
127+
return;
128+
}
129+
130+
self::handle($controllerName, $namespace, $directory);
131+
},
132+
]);
133+
134+
Router::api($options);
135+
}
136+
137+
/**
138+
* Handle a dynamic controller request.
139+
*
140+
* @param string $controllerName The controller name from the URL.
141+
* @param string $namespace Namespace to search.
142+
* @param string|null $directory Optional directory to scan.
143+
*/
144+
public static function handle(string $controllerName, string $namespace, ?string $directory = null): void {
145+
$dir = $directory ?? self::namespaceToPath($namespace);
146+
$map = self::scanNamespace($namespace, $dir);
147+
148+
if (isset($map[$controllerName])) {
149+
$entry = $map[$controllerName];
150+
151+
if ($entry['type'] === 'manager') {
152+
$class = $entry['class'];
153+
$manager = new $class();
154+
$manager->process();
155+
} else {
156+
$class = $entry['class'];
157+
$service = ContainerFacade::has($class)
158+
? ContainerFacade::make($class)
159+
: new $class();
160+
(new RequestProcessor())->process($service, App::getRequest());
161+
}
162+
} else {
163+
App::getResponse()->setCode(404);
164+
App::getResponse()->write(json_encode([
165+
'message' => 'Service not found.',
166+
'type' => 'error'
167+
]));
168+
}
169+
}
170+
97171
/**
98172
* Scan a namespace directory for routable classes.
99173
*
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
namespace WebFiori\Framework\Test\Cli;
3+
4+
use WebFiori\Framework\Cli\CLITestCase;
5+
use WebFiori\Framework\Cli\Commands\ServicesListCommand;
6+
use WebFiori\Framework\Router\ServiceRouter;
7+
8+
class ServicesListCommandTest extends CLITestCase {
9+
protected function tearDown(): void {
10+
ServiceRouter::reset();
11+
parent::tearDown();
12+
}
13+
14+
/** @test */
15+
public function testNoServicesDiscovered() {
16+
ServiceRouter::reset();
17+
$output = $this->executeMultiCommand([ServicesListCommand::class]);
18+
$outputStr = implode('', $output);
19+
20+
$this->assertStringContainsString('No services discovered', $outputStr);
21+
$this->assertEquals(0, $this->getExitCode());
22+
}
23+
24+
/** @test */
25+
public function testWithDiscoveredServices() {
26+
$dir = dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'ServiceRouterFixtures';
27+
ServiceRouter::discover('WebFiori\\Tests\\ServiceRouterFixtures', '/apis', [], $dir);
28+
29+
// Verify getDiscovered() has data (this is what the command reads)
30+
$discovered = ServiceRouter::getDiscovered();
31+
$this->assertArrayHasKey('orders', $discovered);
32+
$this->assertGreaterThanOrEqual(3, count($discovered));
33+
}
34+
}

tests/WebFiori/Framework/Tests/Router/ServiceRouterTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,19 @@ public function testResetClearsDiscovered() {
129129
ServiceRouter::reset();
130130
$this->assertEmpty(ServiceRouter::getDiscovered());
131131
}
132+
133+
/** @test */
134+
public function testDynamicRegistersRoute() {
135+
$routesBefore = Router::routesCount();
136+
ServiceRouter::dynamic($this->namespace, '/dynamic/{controller}', [], $this->fixturesDir);
137+
$this->assertGreaterThan($routesBefore, Router::routesCount());
138+
}
139+
140+
/** @test */
141+
public function testHandleReturns404ForUnknownService() {
142+
$response = \WebFiori\Framework\App::getResponse();
143+
$response->setCode(200);
144+
ServiceRouter::handle('nonexistent', $this->namespace, $this->fixturesDir);
145+
$this->assertEquals(404, $response->getCode());
146+
}
132147
}

0 commit comments

Comments
 (0)