Skip to content

Commit 24f3fdd

Browse files
author
Ibrahim BinAlshikh
committed
feat: add ServiceRouter::discover() for auto-registering API routes
Scans a namespace for routable classes and registers API routes: - #[RestController] attributed classes (uses attribute name or derived) - WebService subclasses without attribute (uses getName()) - WebServicesManager subclasses (registered as manager routes) - Non-service classes are skipped Includes DI container integration for service instantiation. Closes #382 Closes #384
1 parent d2d0a64 commit 24f3fdd

8 files changed

Lines changed: 390 additions & 0 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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\Router;
13+
14+
use ReflectionClass;
15+
use WebFiori\Container\ContainerFacade;
16+
use WebFiori\Framework\App;
17+
use WebFiori\Http\Annotations\RestController;
18+
use WebFiori\Http\RequestProcessor;
19+
use WebFiori\Http\WebService;
20+
use WebFiori\Http\WebServicesManager;
21+
22+
/**
23+
* Discovers and registers API routes from a namespace.
24+
*
25+
* Supports three types of classes:
26+
* - Classes with #[RestController] attribute (uses attribute name or derived name)
27+
* - WebService subclasses without attribute (uses getName())
28+
* - WebServicesManager subclasses (registered as manager routes)
29+
*
30+
* @author Ibrahim
31+
*/
32+
class ServiceRouter {
33+
/**
34+
* @var array Discovered service map for introspection.
35+
*/
36+
private static array $discovered = [];
37+
38+
/**
39+
* Discover and register routes for all services in a namespace.
40+
*
41+
* @param string $namespace Fully qualified namespace (e.g. 'App\\Apis').
42+
* @param string $basePath URL prefix (e.g. '/apis').
43+
* @param array $routeOptions Shared route options applied to all routes.
44+
* @param string|null $directory Directory to scan. If null, derived from namespace relative to ROOT_PATH.
45+
*
46+
* @return int Number of routes registered.
47+
*/
48+
public static function discover(string $namespace, string $basePath, array $routeOptions = [], ?string $directory = null): int {
49+
$dir = $directory ?? self::namespaceToPath($namespace);
50+
$map = self::scanNamespace($namespace, $dir);
51+
$count = 0;
52+
$basePath = rtrim($basePath, '/');
53+
54+
foreach ($map as $name => $entry) {
55+
$class = $entry['class'];
56+
$type = $entry['type'];
57+
$path = $basePath . '/' . $name;
58+
59+
$options = array_merge($routeOptions, [
60+
RouteOption::PATH => $path,
61+
]);
62+
63+
if ($type === 'manager') {
64+
$options[RouteOption::TO] = $class;
65+
} else {
66+
$options[RouteOption::TO] = self::createServiceClosure($class);
67+
}
68+
69+
Router::api($options);
70+
self::$discovered[$name] = [
71+
'class' => $class,
72+
'type' => $type,
73+
'path' => $path,
74+
];
75+
$count++;
76+
}
77+
78+
return $count;
79+
}
80+
81+
/**
82+
* Returns all discovered services.
83+
*
84+
* @return array Associative array keyed by name with class, type, and path.
85+
*/
86+
public static function getDiscovered(): array {
87+
return self::$discovered;
88+
}
89+
90+
/**
91+
* Reset discovered services.
92+
*/
93+
public static function reset(): void {
94+
self::$discovered = [];
95+
}
96+
97+
/**
98+
* Scan a namespace directory for routable classes.
99+
*
100+
* @param string $namespace The namespace to scan.
101+
* @param string $dir The directory to scan.
102+
*
103+
* @return array Map of name => ['class' => FQCN, 'type' => 'service'|'manager']
104+
*/
105+
private static function scanNamespace(string $namespace, string $dir): array {
106+
$map = [];
107+
108+
if (!is_dir($dir)) {
109+
return $map;
110+
}
111+
112+
$files = glob($dir . DIRECTORY_SEPARATOR . '*.php');
113+
114+
if ($files === false) {
115+
return $map;
116+
}
117+
118+
foreach ($files as $file) {
119+
$className = basename($file, '.php');
120+
$fqcn = $namespace . '\\' . $className;
121+
122+
if (!class_exists($fqcn)) {
123+
continue;
124+
}
125+
126+
$ref = new ReflectionClass($fqcn);
127+
128+
if ($ref->isAbstract() || $ref->isInterface()) {
129+
continue;
130+
}
131+
132+
// Priority 1: #[RestController] attribute
133+
$attrs = $ref->getAttributes(RestController::class);
134+
135+
if (!empty($attrs)) {
136+
$attr = $attrs[0]->newInstance();
137+
$name = !empty($attr->name) ? $attr->name : self::deriveNameFromClass($className);
138+
$map[$name] = ['class' => $fqcn, 'type' => 'service'];
139+
140+
continue;
141+
}
142+
143+
// Priority 2: WebServicesManager subclass
144+
if ($ref->isSubclassOf(WebServicesManager::class)) {
145+
$name = self::deriveNameFromClass($className);
146+
$map[$name] = ['class' => $fqcn, 'type' => 'manager'];
147+
148+
continue;
149+
}
150+
151+
// Priority 3: WebService subclass without attribute
152+
if ($ref->isSubclassOf(WebService::class)) {
153+
$instance = new $fqcn();
154+
$name = $instance->getName();
155+
156+
if (!empty($name)) {
157+
$map[$name] = ['class' => $fqcn, 'type' => 'service'];
158+
}
159+
}
160+
}
161+
162+
return $map;
163+
}
164+
165+
/**
166+
* Convert namespace to filesystem path.
167+
*/
168+
private static function namespaceToPath(string $namespace): string {
169+
return ROOT_PATH . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $namespace);
170+
}
171+
172+
/**
173+
* Derive route name from class name.
174+
* OrderService → orders, ProductManager → product
175+
*/
176+
private static function deriveNameFromClass(string $className): string {
177+
$name = preg_replace('/(Service|Manager|Controller)$/', '', $className);
178+
179+
return strtolower($name);
180+
}
181+
182+
/**
183+
* Create a closure that instantiates and processes a service.
184+
*/
185+
private static function createServiceClosure(string $class): callable {
186+
return function () use ($class) {
187+
$service = ContainerFacade::has($class)
188+
? ContainerFacade::make($class)
189+
: new $class();
190+
(new RequestProcessor())->process($service, App::getRequest());
191+
};
192+
}
193+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
namespace WebFiori\Tests\ServiceRouterFixtures;
3+
4+
/**
5+
* A helper class that is NOT a WebService or Manager — should be skipped.
6+
*/
7+
class HelperUtil {
8+
public function doSomething(): string {
9+
return 'not a service';
10+
}
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
namespace WebFiori\Tests\ServiceRouterFixtures;
3+
4+
use WebFiori\Http\WebService;
5+
6+
class LegacyService extends WebService {
7+
public function __construct() {
8+
parent::__construct('legacy');
9+
}
10+
11+
public function processRequest() {
12+
}
13+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
namespace WebFiori\Tests\ServiceRouterFixtures;
3+
4+
use WebFiori\Http\Annotations\RestController;
5+
use WebFiori\Http\WebService;
6+
7+
#[RestController('orders')]
8+
class OrderService extends WebService {
9+
public function __construct() {
10+
parent::__construct('orders');
11+
}
12+
13+
public function processRequest() {
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
namespace WebFiori\Tests\ServiceRouterFixtures;
3+
4+
use WebFiori\Http\Annotations\RestController;
5+
use WebFiori\Http\WebService;
6+
7+
#[RestController]
8+
class ProductService extends WebService {
9+
public function __construct() {
10+
parent::__construct('products');
11+
}
12+
13+
public function processRequest() {
14+
}
15+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
namespace WebFiori\Tests\ServiceRouterFixtures;
3+
4+
use WebFiori\Http\WebServicesManager;
5+
6+
class UsersManager extends WebServicesManager {
7+
public function __construct() {
8+
parent::__construct();
9+
}
10+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
/**
3+
* This file is licensed under MIT License.
4+
*
5+
* Copyright (c) 2026-present WebFiori Framework
6+
*
7+
* For more information on the license, please visit:
8+
* https://github.com/WebFiori/.github/blob/main/LICENSE
9+
*
10+
*/
11+
namespace WebFiori\Framework\Test\Router;
12+
13+
use PHPUnit\Framework\TestCase;
14+
use WebFiori\Framework\Router\Router;
15+
use WebFiori\Framework\Router\ServiceRouter;
16+
17+
class ServiceRouterTest extends TestCase {
18+
private string $fixturesDir;
19+
private string $namespace = 'WebFiori\\Tests\\ServiceRouterFixtures';
20+
21+
protected function setUp(): void {
22+
parent::setUp();
23+
ServiceRouter::reset();
24+
$this->fixturesDir = dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'ServiceRouterFixtures';
25+
}
26+
27+
protected function tearDown(): void {
28+
ServiceRouter::reset();
29+
parent::tearDown();
30+
}
31+
32+
/** @test */
33+
public function testDiscoverFindsAttributedService() {
34+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir);
35+
$discovered = ServiceRouter::getDiscovered();
36+
37+
$this->assertArrayHasKey('orders', $discovered);
38+
$this->assertEquals('WebFiori\\Tests\\ServiceRouterFixtures\\OrderService', $discovered['orders']['class']);
39+
$this->assertEquals('service', $discovered['orders']['type']);
40+
$this->assertEquals('/apis/orders', $discovered['orders']['path']);
41+
}
42+
43+
/** @test */
44+
public function testDiscoverDerivesNameFromClassWhenAttributeNameEmpty() {
45+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir);
46+
$discovered = ServiceRouter::getDiscovered();
47+
48+
$this->assertArrayHasKey('product', $discovered);
49+
$this->assertEquals('service', $discovered['product']['type']);
50+
}
51+
52+
/** @test */
53+
public function testDiscoverFindsLegacyWebService() {
54+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir);
55+
$discovered = ServiceRouter::getDiscovered();
56+
57+
$this->assertArrayHasKey('legacy', $discovered);
58+
$this->assertEquals('WebFiori\\Tests\\ServiceRouterFixtures\\LegacyService', $discovered['legacy']['class']);
59+
$this->assertEquals('service', $discovered['legacy']['type']);
60+
}
61+
62+
/** @test */
63+
public function testDiscoverFindsWebServicesManager() {
64+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir);
65+
$discovered = ServiceRouter::getDiscovered();
66+
67+
$this->assertArrayHasKey('users', $discovered);
68+
$this->assertEquals('WebFiori\\Tests\\ServiceRouterFixtures\\UsersManager', $discovered['users']['class']);
69+
$this->assertEquals('manager', $discovered['users']['type']);
70+
$this->assertEquals('/apis/users', $discovered['users']['path']);
71+
}
72+
73+
/** @test */
74+
public function testDiscoverSkipsNonServiceClasses() {
75+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir);
76+
$discovered = ServiceRouter::getDiscovered();
77+
78+
foreach ($discovered as $name => $entry) {
79+
$this->assertNotEquals('WebFiori\\Tests\\ServiceRouterFixtures\\HelperUtil', $entry['class']);
80+
}
81+
}
82+
83+
/** @test */
84+
public function testDiscoverReturnsCount() {
85+
$count = ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir);
86+
87+
$this->assertEquals(4, $count); // orders, product, legacy, users
88+
}
89+
90+
/** @test */
91+
public function testDiscoverWithNonExistentNamespace() {
92+
$count = ServiceRouter::discover(
93+
'WebFiori\\Tests\\Fixtures\\NonExistent',
94+
'/apis'
95+
);
96+
97+
$this->assertEquals(0, $count);
98+
$this->assertEmpty(ServiceRouter::getDiscovered());
99+
}
100+
101+
/** @test */
102+
public function testDiscoverWithoutDirectoryUsesNamespaceToPath() {
103+
// App\Apis is a real directory relative to ROOT_PATH
104+
// This exercises namespaceToPath() with a valid path
105+
$count = ServiceRouter::discover('App\\Apis', '/app-apis');
106+
// May find services or may not — depends on what's in App/Apis
107+
// The point is it doesn't crash
108+
$this->assertIsInt($count);
109+
}
110+
111+
/** @test */
112+
public function testDiscoverAppliesBasePath() {
113+
ServiceRouter::discover($this->namespace, '/v2/apis', [], $this->fixturesDir);
114+
$discovered = ServiceRouter::getDiscovered();
115+
116+
$this->assertEquals('/v2/apis/orders', $discovered['orders']['path']);
117+
}
118+
119+
/** @test */
120+
public function testGetDiscoveredInitiallyEmpty() {
121+
$this->assertEmpty(ServiceRouter::getDiscovered());
122+
}
123+
124+
/** @test */
125+
public function testResetClearsDiscovered() {
126+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir);
127+
$this->assertNotEmpty(ServiceRouter::getDiscovered());
128+
129+
ServiceRouter::reset();
130+
$this->assertEmpty(ServiceRouter::getDiscovered());
131+
}
132+
}

0 commit comments

Comments
 (0)