Skip to content

Commit 86f3507

Browse files
author
Ibrahim BinAlshikh
committed
feat: add recursive scanning and kebab-case route derivation
- discover() accepts recursive flag for subdirectory scanning - Subdirectory names become kebab-cased URL path segments - Class names converted to kebab-case via CaseConverter - #[RestController] name with '/' is rejected (skipped) - Explicit attribute name is always flat (ignores directory nesting) - Non-recursive remains the default
1 parent e2446f6 commit 86f3507

3 files changed

Lines changed: 90 additions & 10 deletions

File tree

WebFiori/Framework/Router/ServiceRouter.php

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use WebFiori\Http\RequestProcessor;
1919
use WebFiori\Http\WebService;
2020
use WebFiori\Http\WebServicesManager;
21+
use WebFiori\Json\CaseConverter;
2122

2223
/**
2324
* Discovers and registers API routes from a namespace.
@@ -42,12 +43,13 @@ class ServiceRouter {
4243
* @param string $basePath URL prefix (e.g. '/apis').
4344
* @param array $routeOptions Shared route options applied to all routes.
4445
* @param string|null $directory Directory to scan. If null, derived from namespace relative to ROOT_PATH.
46+
* @param bool $recursive If true, scan subdirectories. Subdirectory names become URL path segments.
4547
*
4648
* @return int Number of routes registered.
4749
*/
48-
public static function discover(string $namespace, string $basePath, array $routeOptions = [], ?string $directory = null): int {
50+
public static function discover(string $namespace, string $basePath, array $routeOptions = [], ?string $directory = null, bool $recursive = false): int {
4951
$dir = $directory ?? self::namespaceToPath($namespace);
50-
$map = self::scanNamespace($namespace, $dir);
52+
$map = self::scanNamespace($namespace, $dir, '', $recursive);
5153
$count = 0;
5254
$basePath = rtrim($basePath, '/');
5355

@@ -173,10 +175,12 @@ public static function handle(string $controllerName, string $namespace, ?string
173175
*
174176
* @param string $namespace The namespace to scan.
175177
* @param string $dir The directory to scan.
178+
* @param string $pathPrefix Relative path prefix for recursive scanning (kebab-cased).
179+
* @param bool $recursive Whether to scan subdirectories.
176180
*
177181
* @return array Map of name => ['class' => FQCN, 'type' => 'service'|'manager']
178182
*/
179-
private static function scanNamespace(string $namespace, string $dir): array {
183+
private static function scanNamespace(string $namespace, string $dir, string $pathPrefix = '', bool $recursive = false): array {
180184
$map = [];
181185

182186
if (!is_dir($dir)) {
@@ -208,15 +212,25 @@ private static function scanNamespace(string $namespace, string $dir): array {
208212

209213
if (!empty($attrs)) {
210214
$attr = $attrs[0]->newInstance();
211-
$name = !empty($attr->name) ? $attr->name : self::deriveNameFromClass($className);
215+
216+
if (!empty($attr->name)) {
217+
if (str_contains($attr->name, '/')) {
218+
continue; // Invalid: name must not contain slashes
219+
}
220+
221+
$name = $attr->name;
222+
} else {
223+
$name = $pathPrefix . self::deriveNameFromClass($className);
224+
}
225+
212226
$map[$name] = ['class' => $fqcn, 'type' => 'service'];
213227

214228
continue;
215229
}
216230

217231
// Priority 2: WebServicesManager subclass
218232
if ($ref->isSubclassOf(WebServicesManager::class)) {
219-
$name = self::deriveNameFromClass($className);
233+
$name = $pathPrefix . self::deriveNameFromClass($className);
220234
$map[$name] = ['class' => $fqcn, 'type' => 'manager'];
221235

222236
continue;
@@ -225,14 +239,30 @@ private static function scanNamespace(string $namespace, string $dir): array {
225239
// Priority 3: WebService subclass without attribute
226240
if ($ref->isSubclassOf(WebService::class)) {
227241
$instance = new $fqcn();
228-
$name = $instance->getName();
242+
$svcName = $instance->getName();
229243

230-
if (!empty($name)) {
244+
if (!empty($svcName)) {
245+
$name = $pathPrefix . $svcName;
231246
$map[$name] = ['class' => $fqcn, 'type' => 'service'];
232247
}
233248
}
234249
}
235250

251+
// Recursive: scan subdirectories
252+
if ($recursive) {
253+
$subdirs = glob($dir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
254+
255+
if ($subdirs !== false) {
256+
foreach ($subdirs as $subdir) {
257+
$subDirName = basename($subdir);
258+
$subNamespace = $namespace . '\\' . $subDirName;
259+
$subPrefix = $pathPrefix . CaseConverter::toKebabCase($subDirName, 'lower') . '/';
260+
$subMap = self::scanNamespace($subNamespace, $subdir, $subPrefix, true);
261+
$map = array_merge($map, $subMap);
262+
}
263+
}
264+
}
265+
236266
return $map;
237267
}
238268

@@ -244,13 +274,17 @@ private static function namespaceToPath(string $namespace): string {
244274
}
245275

246276
/**
247-
* Derive route name from class name.
248-
* OrderService → orders, ProductManagerproduct
277+
* Derive route name from class name using kebab-case.
278+
* OrderService → order, UserProfileServiceuser-profile
249279
*/
250280
private static function deriveNameFromClass(string $className): string {
251281
$name = preg_replace('/(Service|Manager|Controller)$/', '', $className);
252282

253-
return strtolower($name);
283+
if (empty($name)) {
284+
$name = $className;
285+
}
286+
287+
return CaseConverter::toKebabCase($name, 'lower');
254288
}
255289

256290
/**
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\UserAuth;
3+
4+
use WebFiori\Http\Annotations\RestController;
5+
use WebFiori\Http\WebService;
6+
7+
#[RestController]
8+
class LoginService extends WebService {
9+
public function __construct() {
10+
parent::__construct('login');
11+
}
12+
13+
public function processRequest() {
14+
}
15+
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,37 @@ public function testResetClearsDiscovered() {
130130
$this->assertEmpty(ServiceRouter::getDiscovered());
131131
}
132132

133+
/** @test */
134+
public function testDiscoverRecursiveFindsNestedServices() {
135+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir, true);
136+
$discovered = ServiceRouter::getDiscovered();
137+
138+
// LoginService in UserAuth/ subdirectory
139+
$this->assertArrayHasKey('user-auth/login', $discovered);
140+
$this->assertEquals('/apis/user-auth/login', $discovered['user-auth/login']['path']);
141+
}
142+
143+
/** @test */
144+
public function testDiscoverNonRecursiveSkipsSubdirectories() {
145+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir, false);
146+
$discovered = ServiceRouter::getDiscovered();
147+
148+
$this->assertArrayNotHasKey('user-auth/login', $discovered);
149+
}
150+
151+
/** @test */
152+
public function testDiscoverSkipsAttributeNameWithSlash() {
153+
// OrderService has #[RestController('orders')] — valid
154+
// If we had one with slash it would be skipped
155+
ServiceRouter::discover($this->namespace, '/apis', [], $this->fixturesDir);
156+
$discovered = ServiceRouter::getDiscovered();
157+
158+
// All discovered names should not contain slashes (non-recursive)
159+
foreach ($discovered as $name => $entry) {
160+
$this->assertStringNotContainsString('/', $name);
161+
}
162+
}
163+
133164
/** @test */
134165
public function testDynamicRegistersRoute() {
135166
$routesBefore = Router::routesCount();

0 commit comments

Comments
 (0)