|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace Foundation\Core\Providers; |
| 6 | + |
| 7 | +use DirectoryIterator; |
| 8 | +use FilesystemIterator; |
| 9 | +use Foundation\Base\Providers\V1\BaseRouteServiceProvider\BaseRouteServiceProvider; |
| 10 | +use Illuminate\Support\Facades\Cache; |
| 11 | +use Illuminate\Support\Facades\Route; |
| 12 | +use Illuminate\Support\Str; |
| 13 | +use Nwidart\Modules\Laravel\Module; |
| 14 | +use RecursiveDirectoryIterator; |
| 15 | +use RecursiveIteratorIterator; |
| 16 | +use SplFileInfo; |
| 17 | + |
| 18 | +/** |
| 19 | + * ModuleRouteServiceProvider |
| 20 | + * |
| 21 | + * - Discovers enabled modules and wires up their web + versioned API routes |
| 22 | + * - Uses the module alias from module.json for routing/naming (fallback to kebab of name) |
| 23 | + * - Provides sensible defaults + per-module (alias-based) overrides |
| 24 | + * - Caches module discovery in production for speed |
| 25 | + */ |
| 26 | +final class ModuleRouteServiceProvider extends BaseRouteServiceProvider |
| 27 | +{ |
| 28 | + /** Default API versions if a module doesn't expose version folders */ |
| 29 | + private const DEFAULT_API_VERSIONS = ['V1']; |
| 30 | + |
| 31 | + /** Default API route configuration applied to every module/version */ |
| 32 | + private const DEFAULT_API_CONFIG = [ |
| 33 | + 'prefix' => 'api', |
| 34 | + 'middleware' => ['api'], |
| 35 | + ]; |
| 36 | + |
| 37 | + /** |
| 38 | + * Module-specific API configuration overrides keyed by **module alias**. |
| 39 | + * |
| 40 | + * Example: |
| 41 | + * [ |
| 42 | + * 'billing' => ['middleware' => ['api', 'throttle:billing']], |
| 43 | + * 'foo-bar' => ['prefix' => 'api'], |
| 44 | + * ] |
| 45 | + */ |
| 46 | + private const MODULE_API_CONFIGS = [ |
| 47 | + // 'billing' => ['middleware' => ['api', 'throttle:billing']], |
| 48 | + ]; |
| 49 | + |
| 50 | + /** |
| 51 | + * Discovery cache TTL in seconds. |
| 52 | + * - 0 => do not cache (always recompute) |
| 53 | + * - -1 => cache forever |
| 54 | + * - >0 => cache for N seconds |
| 55 | + * |
| 56 | + * Can be overridden with ENV ROUTES_DISCOVERY_TTL. |
| 57 | + */ |
| 58 | + private const DISCOVERY_CACHE_TTL = 900; // 15 minutes |
| 59 | + |
| 60 | + /** Entrypoint */ |
| 61 | + public function map(): void |
| 62 | + { |
| 63 | + foreach ($this->discoverEnabledModules() as $module) |
| 64 | + { |
| 65 | + $this->mapWebRoutes($module->getPath()); |
| 66 | + $this->mapApiRoutes($module); |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + /** |
| 71 | + * Discover enabled modules (cache names only; re-hydrate to Module). |
| 72 | + * |
| 73 | + * @return Module[] |
| 74 | + */ |
| 75 | + private function discoverEnabledModules(): array |
| 76 | + { |
| 77 | + $ttlFromEnv = (int) env('ROUTES_DISCOVERY_TTL', self::DISCOVERY_CACHE_TTL); |
| 78 | + |
| 79 | + $effectiveTtl = !$this->app->hasDebugModeEnabled() && null !== env('ROUTES_DISCOVERY_TTL') |
| 80 | + ? 0 |
| 81 | + : $ttlFromEnv; |
| 82 | + |
| 83 | + $cacheKey = 'routes:modules:enabled:names'; |
| 84 | + |
| 85 | + // Cache ONLY primitive names (strings) to avoid serializing objects/closures |
| 86 | + $names = $this->rememberCached( |
| 87 | + key: $cacheKey, |
| 88 | + ttl: $effectiveTtl, |
| 89 | + resolver: function (): array { |
| 90 | + /** |
| 91 | + * @var array<int, Module> $mods |
| 92 | + * @var \Nwidart\Modules\Facades\Module $repo |
| 93 | + */ |
| 94 | + $repo = $this->app['modules']; |
| 95 | + $mods = $repo->allEnabled(); |
| 96 | + $names = array_map(static fn (Module $m) => $m->getName(), $mods); |
| 97 | + sort($names, SORT_STRING); |
| 98 | + |
| 99 | + return $names; |
| 100 | + } |
| 101 | + ); |
| 102 | + |
| 103 | + // Re-hydrate Module objects from names each boot |
| 104 | + /** @var \Nwidart\Modules\Facades\Module $repo */ |
| 105 | + $repo = $this->app['modules']; |
| 106 | + $modules = []; |
| 107 | + foreach ($names as $name) |
| 108 | + { |
| 109 | + // Depending on nwidart/modules version, use find() |
| 110 | + $m = method_exists($repo, 'find') ? $repo->find($name) : null; |
| 111 | + if ($m instanceof Module && $m->isEnabled()) |
| 112 | + { |
| 113 | + $modules[] = $m; |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + // Stable ordering (just in case) |
| 118 | + usort($modules, fn (Module $a, Module $b) => strcmp($a->getName(), $b->getName())); |
| 119 | + |
| 120 | + return $modules; |
| 121 | + } |
| 122 | + |
| 123 | + /** Map web.php if it exists under the module */ |
| 124 | + private function mapWebRoutes(string $modulePath): void |
| 125 | + { |
| 126 | + $web = $modulePath.'/Routes/web.php'; |
| 127 | + if (is_file($web)) |
| 128 | + { |
| 129 | + Route::middleware('web')->group($web); |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + /** Map API routes for all discovered versions in a module */ |
| 134 | + private function mapApiRoutes(Module $module): void |
| 135 | + { |
| 136 | + $apiRoot = $module->getPath().'/Routes/Api'; |
| 137 | + if (!is_dir($apiRoot)) |
| 138 | + { |
| 139 | + return; |
| 140 | + } |
| 141 | + |
| 142 | + foreach ($this->discoverApiVersions($apiRoot) as $version) |
| 143 | + { |
| 144 | + $this->mapApiVersion($module, $apiRoot, $version); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + /** Discover version directories (e.g., V1, V2) under Routes/Api */ |
| 149 | + private function discoverApiVersions(string $apiRoot): array |
| 150 | + { |
| 151 | + $versions = []; |
| 152 | + |
| 153 | + foreach ($this->versionDirsOf($apiRoot) as $dir) |
| 154 | + { |
| 155 | + $entry = basename($dir); |
| 156 | + if (1 === preg_match('/^V\d+$/', $entry)) |
| 157 | + { |
| 158 | + $versions[] = $entry; |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + return $versions ?: self::DEFAULT_API_VERSIONS; // fall back to V1 if nothing is found |
| 163 | + } |
| 164 | + |
| 165 | + /** Map a single API version folder for a module */ |
| 166 | + private function mapApiVersion(Module $module, string $apiRoot, string $version): void |
| 167 | + { |
| 168 | + $versionDir = $apiRoot.'/'.$version; |
| 169 | + if (!is_dir($versionDir)) |
| 170 | + { |
| 171 | + return; |
| 172 | + } |
| 173 | + |
| 174 | + $config = $this->buildVersionApiConfig($module, $version); |
| 175 | + |
| 176 | + // 1) routes.php at the version root (optional) |
| 177 | + $rootRoutes = $versionDir.'/routes.php'; |
| 178 | + if (is_file($rootRoutes)) |
| 179 | + { |
| 180 | + Route::group($config, $rootRoutes); |
| 181 | + } |
| 182 | + |
| 183 | + // 2) Per-resource routes in subfolders, e.g. Routes/Api/V1/Users/*.php |
| 184 | + foreach ($this->subdirectoriesOf($versionDir) as $resourceDir) |
| 185 | + { |
| 186 | + $this->mapResourceRoutes($resourceDir, $config); |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + /** Return immediate subdirectories of a path, excluding dotfiles. */ |
| 191 | + private function subdirectoriesOf(string $path): array |
| 192 | + { |
| 193 | + $dirs = []; |
| 194 | + foreach (new DirectoryIterator($path) as $info) |
| 195 | + { |
| 196 | + if ($info->isDot()) |
| 197 | + { |
| 198 | + continue; |
| 199 | + } |
| 200 | + if ($info->isDir()) |
| 201 | + { |
| 202 | + $dirs[] = $info->getPathname(); |
| 203 | + } |
| 204 | + } |
| 205 | + natsort($dirs); |
| 206 | + |
| 207 | + return array_values($dirs); |
| 208 | + } |
| 209 | + |
| 210 | + /** Return version directories of an API root */ |
| 211 | + private function versionDirsOf(string $apiRoot): array |
| 212 | + { |
| 213 | + if (!is_dir($apiRoot)) |
| 214 | + { |
| 215 | + return []; |
| 216 | + } |
| 217 | + |
| 218 | + $dirs = []; |
| 219 | + foreach (new DirectoryIterator($apiRoot) as $info) |
| 220 | + { |
| 221 | + if ($info->isDot() || !$info->isDir()) |
| 222 | + { |
| 223 | + continue; |
| 224 | + } |
| 225 | + $dirs[] = $info->getPathname(); |
| 226 | + } |
| 227 | + natsort($dirs); |
| 228 | + |
| 229 | + return array_values($dirs); |
| 230 | + } |
| 231 | + |
| 232 | + /** Map all PHP route files in a resource directory with a derived name/alias */ |
| 233 | + private function mapResourceRoutes(string $resourceDir, array $baseConfig): void |
| 234 | + { |
| 235 | + $resource = basename($resourceDir); |
| 236 | + $resourceAlias = Str::kebab($resource); |
| 237 | + |
| 238 | + foreach ($this->phpFilesUnder($resourceDir) as $filePath) |
| 239 | + { |
| 240 | + $fileName = pathinfo($filePath, PATHINFO_FILENAME); |
| 241 | + $fileAlias = Str::kebab($fileName); |
| 242 | + |
| 243 | + $config = $this->buildResourceApiConfig( |
| 244 | + base: $baseConfig, |
| 245 | + resourceAlias: $resourceAlias, |
| 246 | + fileAlias: $fileAlias, |
| 247 | + sameName: $fileName === $resource |
| 248 | + ); |
| 249 | + |
| 250 | + Route::group($config, $filePath); |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + /** Recursively list PHP files under a path */ |
| 255 | + private function phpFilesUnder(string $path): array |
| 256 | + { |
| 257 | + $files = []; |
| 258 | + $iterator = new RecursiveIteratorIterator( |
| 259 | + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS) |
| 260 | + ); |
| 261 | + |
| 262 | + /** @var SplFileInfo $file */ |
| 263 | + foreach ($iterator as $file) |
| 264 | + { |
| 265 | + if ($file->isFile() && 'php' === $file->getExtension()) |
| 266 | + { |
| 267 | + $files[] = $file->getPathname(); |
| 268 | + } |
| 269 | + } |
| 270 | + |
| 271 | + sort($files); |
| 272 | + |
| 273 | + return $files; |
| 274 | + } |
| 275 | + |
| 276 | + /** Build final API config for a module (uses module **alias** for lookup) */ |
| 277 | + private function buildModuleApiConfig(Module $module): array |
| 278 | + { |
| 279 | + $alias = $this->moduleAlias($module); |
| 280 | + $overrides = self::MODULE_API_CONFIGS[$alias] ?? []; |
| 281 | + |
| 282 | + // Shallow merge is intentional here (prefix/middleware/as) |
| 283 | + return array_replace(self::DEFAULT_API_CONFIG, $overrides); |
| 284 | + } |
| 285 | + |
| 286 | + /** Build API config for a specific version (adds version prefix + name space) */ |
| 287 | + private function buildVersionApiConfig(Module $module, string $version): array |
| 288 | + { |
| 289 | + $base = $this->buildModuleApiConfig($module); |
| 290 | + $versionLower = Str::of($version)->lower()->toString(); |
| 291 | + $alias = $this->moduleAlias($module); |
| 292 | + |
| 293 | + // Preserve existing "as" if the module override supplied one |
| 294 | + $namePrefix = mb_rtrim((string) ($base['as'] ?? ''), '.'); |
| 295 | + $namePrefix = '' !== $namePrefix ? $namePrefix.'.' : ''; |
| 296 | + |
| 297 | + return array_replace($base, [ |
| 298 | + 'prefix' => mb_trim(($base['prefix'] ?? 'api').'/'.$versionLower, '/'), |
| 299 | + 'as' => $namePrefix.$versionLower.'.'.$alias.'.', |
| 300 | + ]); |
| 301 | + } |
| 302 | + |
| 303 | + /** Build API config for resource route files under a version */ |
| 304 | + private function buildResourceApiConfig( |
| 305 | + array $base, |
| 306 | + string $resourceAlias, |
| 307 | + string $fileAlias, |
| 308 | + bool $sameName, |
| 309 | + ): array { |
| 310 | + $as = mb_rtrim((string) ($base['as'] ?? ''), '.'); |
| 311 | + |
| 312 | + // v1.alias.resource[.file]. |
| 313 | + $as .= ('' === $as ? '' : '.').$resourceAlias; |
| 314 | + if (!$sameName) |
| 315 | + { |
| 316 | + $as .= '.'.$fileAlias; |
| 317 | + } |
| 318 | + $base['as'] = $as.'.'; |
| 319 | + |
| 320 | + return $base; |
| 321 | + } |
| 322 | + |
| 323 | + /** Resolve routing alias from module.json; fallback to kebab of name */ |
| 324 | + private function moduleAlias(Module $module): string |
| 325 | + { |
| 326 | + $alias = mb_trim((string) ($module->get('alias') ?? '')); |
| 327 | + |
| 328 | + return '' !== $alias ? $alias : Str::kebab($module->getName()); |
| 329 | + } |
| 330 | + |
| 331 | + /* --------------------------------------------------------------------- |
| 332 | + | Internal helpers |
| 333 | + |--------------------------------------------------------------------- */ |
| 334 | + |
| 335 | + /** |
| 336 | + * Cache helper with policy: |
| 337 | + * ttl = 0 -> do not cache, compute every time |
| 338 | + * ttl = -1 -> remember forever |
| 339 | + * ttl > 0 -> cache N seconds |
| 340 | + * |
| 341 | + * @template T |
| 342 | + * |
| 343 | + * @param callable():T $resolver |
| 344 | + * @return T |
| 345 | + */ |
| 346 | + private function rememberCached(string $key, int $ttl, callable $resolver) |
| 347 | + { |
| 348 | + $store = app()->runningInConsole() ? Cache::store('array') : Cache::store(); |
| 349 | + |
| 350 | + if (0 === $ttl) |
| 351 | + { |
| 352 | + return $resolver(); |
| 353 | + } |
| 354 | + if ($ttl < 0) |
| 355 | + { |
| 356 | + return $store->rememberForever($key, $resolver); |
| 357 | + } |
| 358 | + |
| 359 | + return $store->remember($key, $ttl, $resolver); |
| 360 | + } |
| 361 | +} |
0 commit comments