1: <?php
2:
3: namespace LaravelUi5\Core\Ui5;
4:
5: use Illuminate\Database\Eloquent\Model;
6: use Illuminate\Database\Eloquent\Relations\Relation;
7: use LaravelUi5\Core\Attributes\Ability;
8: use LaravelUi5\Core\Attributes\Role;
9: use LaravelUi5\Core\Attributes\SemanticLink;
10: use LaravelUi5\Core\Attributes\SemanticObject;
11: use LaravelUi5\Core\Attributes\Setting;
12: use LaravelUi5\Core\Enums\ArtifactType;
13: use LaravelUi5\Core\Ui5\Contracts\ReportActionInterface;
14: use LaravelUi5\Core\Ui5\Contracts\SluggableInterface;
15: use LaravelUi5\Core\Ui5\Contracts\Ui5ActionInterface;
16: use LaravelUi5\Core\Ui5\Contracts\Ui5AppInterface;
17: use LaravelUi5\Core\Ui5\Contracts\Ui5ArtifactInterface;
18: use LaravelUi5\Core\Ui5\Contracts\Ui5CardInterface;
19: use LaravelUi5\Core\Ui5\Contracts\Ui5DashboardInterface;
20: use LaravelUi5\Core\Ui5\Contracts\Ui5DialogInterface;
21: use LaravelUi5\Core\Ui5\Contracts\Ui5KpiInterface;
22: use LaravelUi5\Core\Ui5\Contracts\Ui5ModuleInterface;
23: use LaravelUi5\Core\Ui5\Contracts\Ui5RegistryInterface;
24: use LaravelUi5\Core\Ui5\Contracts\Ui5ReportInterface;
25: use LaravelUi5\Core\Ui5\Contracts\Ui5ResourceInterface;
26: use LaravelUi5\Core\Ui5\Contracts\Ui5TileInterface;
27: use LogicException;
28: use ReflectionClass;
29: use ReflectionException;
30: use Throwable;
31:
32: class Ui5Registry implements Ui5RegistryInterface
33: {
34: /**
35: * @var array<string, Ui5ModuleInterface>
36: */
37: protected array $modules = [];
38:
39: /**
40: * @var array<string, Ui5ArtifactInterface>
41: */
42: protected array $artifacts = [];
43:
44: /**
45: * @var array<string, string>
46: */
47: protected array $namespaceToModule = [];
48:
49: /**
50: * @var array<class-string<Ui5ArtifactInterface>, string>
51: */
52: protected array $artifactToModule = [];
53:
54: /**
55: * @var array<string, Ui5ArtifactInterface>
56: */
57: protected array $slugs = [];
58:
59: /**
60: * @var array<string, string>
61: */
62: protected array $roles = [];
63:
64: /**
65: * @var array<string, array<string, array<string, string[]>>>
66: */
67: protected array $abilities = [];
68:
69: /**
70: * @var array<string, array<string, string[]>>
71: */
72: protected array $settings = [];
73:
74: /**
75: * @var array<class-string<Model>, array<string, mixed>>
76: */
77: protected array $objects = [];
78:
79: /**
80: * @var array<class-string<Model>, class-string<Model>[]>
81: */
82: protected array $links = [];
83:
84: /**
85: * @throws ReflectionException
86: */
87: public function __construct(?array $config = null)
88: {
89: if ($config) {
90: $this->loadFromArray($config);
91: } else {
92: $this->loadFromArray(config('ui5'));
93: }
94: }
95:
96: /**
97: * @throws ReflectionException
98: */
99: public static function fromArray(array $config): self
100: {
101: return new self($config);
102: }
103:
104: /**
105: * @throws ReflectionException
106: */
107: protected function loadFromArray(array $config): void
108: {
109: $modules = $config['modules'] ?? [];
110:
111: // Pass 1: Reflect Roles
112: foreach ($modules as $slug => $moduleClass) {
113: /** @var Ui5ModuleInterface $module */
114: $module = new $moduleClass($slug);
115:
116: $this->registerRoles($module);
117: }
118:
119: // Pass 2: Reflect everything else
120: foreach ($modules as $slug => $moduleClass) {
121:
122: /** @var Ui5ModuleInterface $module */
123: $module = new $moduleClass($slug);
124:
125: $this->registerSemanticObject($module);
126:
127: $this->modules[$slug] = $module;
128:
129: if ($module->hasApp() && ($app = $module->getApp())) {
130: $this->registerArtifact($app, $slug);
131: }
132: if ($module->hasLibrary() && ($lib = $module->getLibrary())) {
133: $this->registerArtifact($lib, $slug);
134: }
135: foreach ($module->getCards() as $card) {
136: $this->registerArtifact($card, $slug);
137: }
138: foreach ($module->getKpis() as $kpi) {
139: $this->registerArtifact($kpi, $slug);
140: }
141: foreach ($module->getTiles() as $tile) {
142: $this->registerArtifact($tile, $slug);
143: }
144: foreach ($module->getActions() as $action) {
145: $this->registerArtifact($action, $slug);
146: }
147: foreach ($module->getResources() as $resource) {
148: $this->registerArtifact($resource, $slug);
149: }
150: }
151:
152: // Pass 3: Register inter module dependencies
153: $this->registerSemanticLinks();
154:
155: // Register independent artifacts
156: $dashboards = $config['dashboards'] ?? [];
157: foreach ($dashboards as $dashboardClass) {
158: $dashboard = new $dashboardClass;
159: $this->registerArtifact($dashboard, null);
160: }
161:
162: $reports = $config['reports'] ?? [];
163: foreach ($reports as $reportClass) {
164: $report = new $reportClass;
165: $this->registerArtifact($report, null);
166: }
167:
168: // Extension Hook
169: $this->afterLoad($config);
170: }
171:
172: protected function afterLoad(array $config): void
173: {
174: // extension hook (no-op by default)
175: }
176:
177: /**
178: * Extract all role definitions from a given Ui5Module class.
179: *
180: * @param Ui5ModuleInterface $module
181: *
182: * @throws LogicException If a role is declared twice
183: */
184: protected function registerRoles(Ui5ModuleInterface $module): void
185: {
186: $ref = new ReflectionClass($module);
187: $attributes = $ref->getAttributes(Role::class);
188: foreach ($attributes as $attribute) {
189: /** @var Role $role */
190: $role = $attribute->newInstance();
191: if (array_key_exists($role->role, $this->roles)) {
192: $namespace = $module->getArtifactRoot()->getNamespace();
193: throw new LogicException("Role '$role->role' declared in module '$namespace' already exists.");
194: }
195: $this->roles[$role->role] = [
196: 'note' => $role->note,
197: 'scope' => $role->scope,
198: 'abilities' => [],
199: ];
200: }
201: }
202:
203: /**
204: * Registers a UI5 artifact within the registry.
205: *
206: * This method adds the artifact to the internal lookup maps by namespace
207: * and module context. If the artifact is sluggable (i.e., addressable via URI),
208: * it also registers the composed `urlKey` for reverse lookup.
209: *
210: * @param Ui5ArtifactInterface $artifact
211: * @param string|null $moduleSlug
212: * @throws ReflectionException
213: */
214: protected function registerArtifact(Ui5ArtifactInterface $artifact, ?string $moduleSlug): void
215: {
216: $namespace = $artifact->getNamespace();
217: $this->artifacts[$namespace] = $artifact;
218:
219: if (null !== $moduleSlug) {
220: $this->namespaceToModule[$namespace] = $moduleSlug;
221: $this->artifactToModule[get_class($artifact)] = $moduleSlug;
222: }
223:
224: $this->discoverAbilities($artifact);
225: $this->discoverSettings($artifact);
226:
227: if ($artifact instanceof SluggableInterface) {
228: $urlKey = ArtifactType::urlKeyFromArtifact($artifact);
229: $this->slugs[$urlKey] = $artifact;
230: }
231: }
232:
233: /**
234: * Discover and register Ability attributes defined on Ui5 artifacts or report actions.
235: *
236: * - Only one Ability per class is allowed.
237: * - Type::Use and Type::See are not permitted on backend classes.
238: * - Type::Act must appear only on Ui5ActionInterface or ReportActionInterface.
239: * - Type::Access must appear only on respective artifacts
240: *
241: * @throws ReflectionException|LogicException
242: */
243: protected function discoverAbilities(Ui5ArtifactInterface|ReportActionInterface $artifact): void
244: {
245: $ref = new ReflectionClass($artifact);
246:
247: // The module root (i.e. app/lib) defines the canonical namespace grouping for Abilities
248: $namespace = $artifact->getModule()->getArtifactRoot()->getNamespace();
249: $attributes = $ref->getAttributes(Ability::class);
250:
251: // PHP natively prevents multiple non-repeatable attributes.
252: // Therefore, no explicit duplicate Ability check is required.
253:
254: if (count($attributes) === 1) {
255: /** @var Ability $ability */
256: $ability = $attributes[0]->newInstance();
257:
258: if ($ability->type->shouldBeInManifest()) {
259: throw new LogicException(sprintf(
260: 'AbilityType::%s for ability %s cannot be declared in backend artifacts (%s). Move this definition to your manifest.json file.',
261: $ability->ability,
262: $ability->type->name,
263: get_class($artifact)
264: ));
265: }
266:
267: if ($ability->type->isAct() && !($artifact instanceof Ui5ActionInterface || $artifact instanceof ReportActionInterface)) {
268: throw new LogicException(sprintf(
269: 'AbilityType::Act for ability %s must be declared on an executable artifact, found on (%s).',
270: $ability->ability,
271: get_class($artifact)
272: ));
273: }
274:
275: if ($ability->type->isAccess() && !(
276: $artifact instanceof Ui5AppInterface
277: || $artifact instanceof Ui5CardInterface
278: || $artifact instanceof Ui5ReportInterface
279: || $artifact instanceof Ui5TileInterface
280: || $artifact instanceof Ui5KpiInterface
281: || $artifact instanceof Ui5DashboardInterface
282: || $artifact instanceof Ui5ResourceInterface
283: || $artifact instanceof Ui5DialogInterface
284: )) {
285: throw new LogicException(
286: sprintf('AbilityType::Access is only valid for entry-level artifacts (%s)', get_class($artifact))
287: );
288: }
289:
290: if (array_key_exists($ability->ability, $this->abilities[$namespace][$ability->type->label()] ?? [])) {
291: throw new LogicException(sprintf(
292: 'Duplicate ability [%s] found on [%s].',
293: $ability->ability,
294: get_class($artifact)
295: ));
296: }
297:
298: if (!array_key_exists($ability->role, $this->roles)) {
299: throw new LogicException(sprintf(
300: 'Role [%s] referenced by ability [%s] is not declared [%s].',
301: $ability->role,
302: $ability->ability,
303: get_class($artifact)
304: ));
305: }
306:
307: $this->abilities[$namespace][$ability->type->label()][$ability->ability] = [
308: 'type' => $ability->type,
309: 'role' => $ability->role,
310: 'note' => $ability->note,
311: ];
312: $this->roles[$ability->role]['abilities'][] = [
313: 'namespace' => $namespace,
314: 'ability' => $ability->ability,
315: 'type' => $ability->type,
316: 'note' => $ability->note,
317: ];
318:
319: if ($artifact instanceof Ui5ReportInterface) {
320: foreach ($artifact->getActions() as $action) {
321: $this->discoverAbilities($action);
322: }
323: }
324: }
325: }
326:
327: /**
328: * Detects and registers a module's declared SemanticObject.
329: *
330: * @throws LogicException
331: */
332: protected function registerSemanticObject(Ui5ModuleInterface $module): void
333: {
334: $ref = new ReflectionClass($module);
335: $attributes = $ref->getAttributes(SemanticObject::class);
336:
337: if (count($attributes) === 0) {
338: return; // Module may not declare a semantic object
339: }
340:
341: // PHP natively prevents multiple non-repeatable attributes.
342: // Therefore, no explicit duplicate Ability check is required.
343:
344: /** @var SemanticObject $semantic */
345: $semantic = $attributes[0]->newInstance();
346:
347: // Validate required fields
348: if (empty($semantic->model) || empty($semantic->name)) {
349: throw new LogicException(sprintf(
350: 'Invalid SemanticObject definition in [%s]. Parameters $model, $name, and $routes are required.',
351: $ref->getName()
352: ));
353: }
354:
355: // Ensure at least one route
356: if (count($semantic->routes) < 1) {
357: throw new LogicException(sprintf(
358: 'SemanticObject [%s] must define at least one route intent.',
359: $semantic->name
360: ));
361: }
362:
363: // Prevent duplicate model ownership
364: if (isset($this->objects[$semantic->model])) {
365: throw new LogicException(sprintf(
366: 'Model [%s] is already registered as a SemanticObject by [%s].',
367: $semantic->model,
368: $this->objects[$semantic->model]['name']
369: ));
370: }
371:
372: $slug = $module->getSlug();
373:
374: $this->objects[$semantic->model] = [
375: 'module' => $slug,
376: 'name' => $semantic->name,
377: 'model' => $semantic->model,
378: 'routes' => $semantic->routes,
379: 'icon' => $semantic->icon,
380: ];
381: }
382:
383: /**
384: * Performs the second discovery pass to resolve SemanticLink attributes.
385: *
386: * Scans only models registered as SemanticObjects and validates that each link
387: * points to another registered SemanticObject model.
388: *
389: * @throws ReflectionException|LogicException
390: */
391: protected function registerSemanticLinks(): void
392: {
393: if (empty($this->objects)) {
394: return; // nothing to process
395: }
396:
397: foreach ($this->objects as $slug => $object) {
398: $ref = new ReflectionClass($slug);
399:
400: foreach ($ref->getMethods() as $method) {
401: foreach ($method->getAttributes(SemanticLink::class) as $attribute) {
402: /** @var SemanticLink $link */
403: $link = $attribute->newInstance();
404: $model = $link->model ?? null;
405:
406: if (!$model) {
407: try {
408: $instance ??= app($slug);
409: $relation = $instance->{$method->getName()}();
410:
411: if ($relation instanceof Relation) {
412: $model = get_class($relation->getRelated());
413: }
414: } catch (Throwable $e) {
415: // ignore if method cannot be executed safely (non-ORM class etc.)
416: }
417: }
418:
419: // Validation: target model must exist as a registered semantic object
420: if (!$model || !isset($this->objects[$model])) {
421: throw new LogicException(sprintf(
422: 'SemanticLink on [%s::%s] points to unknown model [%s]. Target must be declared as a SemanticObject.',
423: $slug,
424: $method->getName(),
425: $link->model
426: ));
427: }
428:
429: $this->links[$slug][] = $model;
430: }
431: }
432: }
433: }
434:
435: /**
436: * Discover and register Setting attributes defined on Ui5 artifacts.
437: *
438: * @param Ui5ArtifactInterface $artifact
439: */
440: protected function discoverSettings(Ui5ArtifactInterface $artifact): void
441: {
442: $ref = new ReflectionClass($artifact);
443: $attributes = $ref->getAttributes(Setting::class);
444:
445: if (empty($attributes)) {
446: return;
447: }
448:
449: $namespace = $artifact->getModule()->getArtifactRoot()->getNamespace();
450:
451: foreach ($attributes as $attr) {
452: /** @var Setting $setting */
453: $setting = $attr->newInstance();
454:
455: if (array_key_exists($setting->setting, $this->settings[$namespace] ?? [])) {
456: throw new LogicException(sprintf(
457: 'Duplicate setting [%s] found in [%s].',
458: $setting->setting,
459: get_class($artifact)
460: ));
461: }
462:
463: $this->settings[$namespace][$setting->setting] = [
464: 'default' => $setting->default,
465: 'type' => $setting->type,
466: 'scope' => $setting->scope,
467: 'role' => $setting->role,
468: 'note' => $setting->note,
469: ];
470: }
471: }
472:
473: /** -- Lookup ---------------------------------------------------------- */
474:
475: public function hasModule(string $slug): bool
476: {
477: return isset($this->modules[$slug]);
478: }
479:
480: public function getModule(string $slug): ?Ui5ModuleInterface
481: {
482: if (isset($this->modules[$slug])) {
483: return $this->modules[$slug];
484: }
485:
486: return null;
487: }
488:
489: public function modules(): array
490: {
491: return $this->modules;
492: }
493:
494: public function has(string $namespace): bool
495: {
496: return isset($this->artifacts[$namespace]);
497: }
498:
499: public function get(string $namespace): ?Ui5ArtifactInterface
500: {
501: if (isset($this->artifacts[$namespace])) {
502: return $this->artifacts[$namespace];
503: }
504:
505: return null;
506: }
507:
508: public function artifacts(): array
509: {
510: return $this->artifacts;
511: }
512:
513: /** -- Introspection --------------------------------------------------- */
514: public function roles(): array
515: {
516: return $this->roles;
517: }
518:
519: public function abilities(?string $namespace = null, ?ArtifactType $type = null): array
520: {
521: if ($namespace === null) {
522: if ($type === null) {
523: return $this->abilities;
524: }
525:
526: $result = [];
527: foreach ($this->abilities as $ns => $types) {
528: if (isset($types[$type->label()])) {
529: $result[$ns] = $types[$type->label()];
530: }
531: }
532:
533: return $result;
534: }
535:
536: if ($type === null) {
537: return $this->abilities[$namespace] ?? [];
538: }
539:
540: return $this->abilities[$namespace][$type->label()] ?? [];
541: }
542:
543: public function settings(?string $namespace = null): array
544: {
545: if (null === $namespace) {
546: return $this->settings;
547: }
548:
549: return $this->settings[$namespace] ?? [];
550: }
551:
552: public function objects(): array
553: {
554: return $this->objects;
555: }
556:
557: /** -- Laravel routing ------------------------------------------------- */
558:
559: public function fromSlug(string $slug): ?Ui5ArtifactInterface
560: {
561: return $this->slugs[$slug] ?? null;
562: }
563:
564: public function slugFor(Ui5ArtifactInterface $artifact): ?string
565: {
566: return ArtifactType::urlKeyFromArtifact($artifact);
567: }
568:
569: /** -- manifest.json facing -------------------------------------------- */
570: public function resolveIntents(string $slug): array
571: {
572: $sourceModel = null;
573: foreach ($this->objects as $model => $meta) {
574: if ($meta['module'] === $slug) {
575: $sourceModel = $model;
576: break;
577: }
578: }
579:
580: if (!$sourceModel) {
581: return []; // module has no semantic object → no intents to expose
582: }
583:
584: $referencingModels = [];
585: foreach ($this->links as $fromModel => $targets) {
586: foreach ($targets as $toModel) {
587: if ($toModel === $sourceModel) {
588: $referencingModels[] = $fromModel;
589: }
590: }
591: }
592:
593: $intents = [];
594: foreach ($referencingModels as $model) {
595: $semantic = $this->objects[$model];
596: if (!$semantic) {
597: continue;
598: }
599:
600: $objectName = $semantic['name'];
601: foreach ($semantic['routes'] as $intent => $route) {
602: $intents[$objectName][$intent] = [
603: 'label' => $route['label'] ?? $intent,
604: 'icon' => $route['icon'] ?? null,
605: ];
606: }
607: }
608:
609: return $intents;
610: }
611:
612: public function resolve(string $namespace): ?string
613: {
614: $artifact = $this->get($namespace);
615: if ($artifact) {
616: return '/ui5/' . $this->slugFor($artifact) . '/' . $artifact->getVersion();
617: }
618:
619: return null;
620: }
621:
622: public function resolveRoots(array $namespaces): array
623: {
624: return collect($namespaces)->mapWithKeys(fn($ns) => [$ns => $this->resolve($ns)])->all();
625: }
626:
627: /** -- Export ---------------------------------------------------------- */
628: public function exportToCache(): array
629: {
630: return [
631: 'modules' => $this->modules,
632: 'artifacts' => $this->artifacts,
633: 'namespaceToModule' => $this->namespaceToModule,
634: 'artifactToModule' => $this->artifactToModule,
635: 'slugs' => $this->slugs,
636: 'roles' => $this->roles,
637: 'abilities' => $this->abilities,
638: 'settings' => $this->settings,
639: 'objects' => $this->objects,
640: 'links' => $this->links,
641: ];
642: }
643: }
644: