Skip to content

Commit aaf7bb2

Browse files
committed
🌱 Stable version initial release
0 parents  commit aaf7bb2

File tree

5 files changed

+772
-0
lines changed

5 files changed

+772
-0
lines changed
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
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

Comments
 (0)