1818use WebFiori \Http \RequestProcessor ;
1919use WebFiori \Http \WebService ;
2020use 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, ProductManager → product
277+ * Derive route name from class name using kebab-case .
278+ * OrderService → order, UserProfileService → user-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 /**
0 commit comments